From 8d1615e1ed7c97507a4fd07fc0fa25582164b242 Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 27 May 2026 09:48:35 +0800 Subject: [PATCH] Harden mobile smoke and contract ownership rules --- .../generated_contract_repository.py | 6 + .../services/contract_generation_service.py | 20 ++- backend/tests/test_contracts_api.py | 84 +++++++++- .../generated_contract_result_screen.dart | 26 +++- .../presentation/contract_screens_test.dart | 2 + mobile/tools/real-device-smoke.ps1 | 147 ++++++++++++++++-- 6 files changed, 255 insertions(+), 30 deletions(-) diff --git a/backend/app/repositories/generated_contract_repository.py b/backend/app/repositories/generated_contract_repository.py index b19d42de..784edc19 100644 --- a/backend/app/repositories/generated_contract_repository.py +++ b/backend/app/repositories/generated_contract_repository.py @@ -39,12 +39,15 @@ class GeneratedContractRepository: tenant_id: str, page: int, page_size: int, + generated_by: Optional[str] = None, contract_type: Optional[str] = None, document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, ) -> List[GeneratedContract]: query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) + if generated_by: + query = query.filter(GeneratedContract.generated_by == generated_by) if contract_type: query = query.filter(GeneratedContract.contract_type == contract_type) if document_type_code: @@ -67,12 +70,15 @@ class GeneratedContractRepository: def count( self, tenant_id: str, + generated_by: Optional[str] = None, contract_type: Optional[str] = None, document_type_code: Optional[str] = None, lifecycle_status: Optional[str] = None, keyword: Optional[str] = None, ) -> int: query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) + if generated_by: + query = query.filter(GeneratedContract.generated_by == generated_by) if contract_type: query = query.filter(GeneratedContract.contract_type == contract_type) if document_type_code: diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index c724763b..030ca70b 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -452,11 +452,13 @@ class ContractGenerationService: keyword: Optional[str] = None, ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] + generated_by = None if current_user.get("role") == "admin" else str(current_user.get("id") or "") normalized_lifecycle_status = self._normalize_optional_lifecycle_status(lifecycle_status) items = self.generated_contract_repository.list_paginated( tenant_id=tenant_id, page=page, page_size=page_size, + generated_by=generated_by, contract_type=contract_type, document_type_code=document_type_code, lifecycle_status=normalized_lifecycle_status, @@ -464,6 +466,7 @@ class ContractGenerationService: ) total = self.generated_contract_repository.count( tenant_id=tenant_id, + generated_by=generated_by, contract_type=contract_type, document_type_code=document_type_code, lifecycle_status=normalized_lifecycle_status, @@ -478,8 +481,7 @@ class ContractGenerationService: def get_generated_contract(self, current_user: Dict[str, Any], contract_id: str) -> Dict: - tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问") return self._to_generated_contract_item(generated_contract) @@ -497,7 +499,7 @@ class ContractGenerationService: lifecycle_note: Optional[str] = None, ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"): @@ -723,7 +725,7 @@ class ContractGenerationService: def list_generated_contract_versions(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") versions = self.generated_contract_version_repository.list_by_contract(tenant_id, contract_id) @@ -766,7 +768,7 @@ class ContractGenerationService: def get_generated_contract_version(self, current_user: Dict[str, Any], contract_id: str, version_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") version = self.generated_contract_version_repository.get_by_id(tenant_id, contract_id, version_id) @@ -833,11 +835,7 @@ class ContractGenerationService: locate_status: str = "applied", ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) - if not generated_contract: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") - if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) normalized_content = str(content or "").strip() if not normalized_content: @@ -875,7 +873,7 @@ class ContractGenerationService: def get_generated_contract_preview(self, current_user: Dict[str, Any], contract_id: str) -> Dict: tenant_id = current_user["tenant_id"] - generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) if not generated_contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问") return { diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index 21a09d3d..9c2a762e 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -56,7 +56,15 @@ class StubContractGenerationService: "chat_url": f"/chat?review={review_id}", } - def _record(self, record_id, tenant_id="tenant-demo-001", contract_type="procurement", document_type_code="procurement_goods"): + def _record( + self, + record_id, + tenant_id="tenant-demo-001", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-admin", + generated_by_name="Admin", + ): return { "id": record_id, "template_id": "tpl-123", @@ -90,8 +98,8 @@ class StubContractGenerationService: "updated_at": None, }, "metadata": { - "generated_by": "user-admin", - "generated_by_name": "Admin", + "generated_by": generated_by, + "generated_by_name": generated_by_name, "tenant_id": tenant_id, "original_template": "Procurement Template", "document_type_code": document_type_code, @@ -109,9 +117,21 @@ class StubContractGenerationService: records = [ self._record("contract-123"), self._record("contract-456", contract_type="employment", document_type_code="labor_contract"), + self._record( + "contract-member-owned", + tenant_id="tenant-demo-001", + generated_by="user-member", + generated_by_name="Member", + ), self._record("contract-other-tenant", tenant_id="tenant-other-001"), ] tenant_records = [record for record in records if record["tenant_id"] == current_user["tenant_id"]] + if current_user.get("role") != "admin": + tenant_records = [ + record + for record in tenant_records + if record["metadata"]["generated_by"] == current_user["id"] + ] if contract_type: tenant_records = [record for record in tenant_records if record["contract_type"] == contract_type] if document_type_code: @@ -546,6 +566,14 @@ class ContractsApiTestCase(unittest.TestCase): "role": "admin", } + def _login_member(self, tenant_id="tenant-demo-001"): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": tenant_id, + "display_name": "Member", + "role": "member", + } + def test_user_can_list_contract_templates(self): self._login() response = self.client.get("/api/v1/contracts/templates", params={"contract_type": "procurement"}) @@ -561,6 +589,14 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(body["data"]["items"][0]["document_type_code"], "labor_contract") self.assertEqual(body["data"]["items"][0]["lifecycle"]["status"], "generated") + def test_member_generated_contract_list_only_returns_owned_records(self): + self._login_member() + response = self.client.get("/api/v1/contracts") + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["total"], 1) + self.assertEqual(body["data"]["items"][0]["id"], "contract-member-owned") + def test_user_can_update_contract_lifecycle_and_write_audit_log(self): self._login() response = self.client.patch( @@ -1326,6 +1362,48 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(ctx.exception.status_code, 409) + def test_create_generated_contract_version_rejects_non_owner_member(self): + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-editor-owner-only", + tenant_id="tenant-demo-001", + template_id="tpl-editor", + template_name="Editor Template", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-admin", + generated_by_name="Admin", + export_format="docx", + file_id=None, + file_name="editor-owner-only.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Original contract content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.commit() + + with self.assertRaises(HTTPException) as ctx: + self.service.create_generated_contract_version( + current_user={ + "id": "user-member", + "tenant_id": "tenant-demo-001", + "display_name": "Member", + "role": "member", + }, + contract_id="contract-editor-owner-only", + content="Original contract content.\nAcceptance clause added.", + change_summary="member edit attempt", + ) + + self.assertEqual(ctx.exception.status_code, 404) + def test_diff_generated_contract_versions_returns_paragraph_changes(self): now = datetime.now(timezone.utc) self.db.add( diff --git a/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart b/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart index d240765f..d295e7fa 100644 --- a/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart +++ b/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart @@ -860,7 +860,31 @@ class _GeneratedContractVersionEditorScreenState @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('编辑合同版本')), + appBar: AppBar( + title: const Text('编辑合同版本'), + actions: [ + ValueListenableBuilder( + valueListenable: widget.controller.isSaving, + builder: (context, isSaving, _) { + return Semantics( + label: 'save-contract-version-appbar', + button: true, + child: IconButton( + onPressed: isSaving ? null : _save, + tooltip: '保存新版本', + icon: isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + ), + ); + }, + ), + ], + ), bottomNavigationBar: SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), diff --git a/mobile/test/features/contracts/presentation/contract_screens_test.dart b/mobile/test/features/contracts/presentation/contract_screens_test.dart index 21cce782..fd64f4a6 100644 --- a/mobile/test/features/contracts/presentation/contract_screens_test.dart +++ b/mobile/test/features/contracts/presentation/contract_screens_test.dart @@ -180,6 +180,8 @@ void main() { findsOneWidget); expect(find.bySemanticsLabel('contract-version-content-input'), findsOneWidget); + expect(find.bySemanticsLabel('save-contract-version-appbar'), + findsOneWidget); final summaryField = find.descendant( of: find.bySemanticsLabel('contract-version-change-summary-input'), diff --git a/mobile/tools/real-device-smoke.ps1 b/mobile/tools/real-device-smoke.ps1 index 4ce066af..acb0bc1b 100644 --- a/mobile/tools/real-device-smoke.ps1 +++ b/mobile/tools/real-device-smoke.ps1 @@ -90,7 +90,15 @@ function Wait-ForAdbDeviceOnline { $repairAttempted = $false for ($attempt = 1; $attempt -le $RetryCount; $attempt++) { - $state = ((& $script:adb -s $Serial get-state 2>&1) -join [Environment]::NewLine).Trim() + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $state = ((& $script:adb -s $Serial get-state 2>&1) -join [Environment]::NewLine).Trim() + } catch { + $state = ($_.Exception.Message -replace '\s+', ' ').Trim() + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } if ($state -eq "device") { return $true } @@ -140,6 +148,33 @@ function Get-AppSurface { return "unknown" } +function Test-ContractVersionListContainsMarker { + param( + [string]$UiDump, + [string]$Marker + ) + + if ([string]::IsNullOrWhiteSpace($Marker)) { + return $false + } + if ($UiDump -match 'contract-version-editor') { + return $false + } + if ($UiDump -notmatch 'contract-version-chain') { + return $false + } + + $normalized = $UiDump.ToLowerInvariant() + $parts = $Marker.ToLowerInvariant().Split('_') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + foreach ($part in $parts) { + if ($normalized -notmatch [regex]::Escape($part)) { + return $false + } + } + + return $true +} + function Get-ApkPath { $candidate = Join-Path $PSScriptRoot "..\build\app\outputs\flutter-apk\app-debug.apk" $resolved = Resolve-Path $candidate -ErrorAction SilentlyContinue @@ -271,6 +306,47 @@ function Get-ContentDescBounds { return $results } +function Get-NestedButtonBounds { + param([string]$UiDump) + + $matches = [regex]::Matches( + $UiDump, + 'class="android\.widget\.Button".*?content-desc="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]".*?