assertTrue(IsolatedQaDatabase::allowsSchemaChanges()); if (! Schema::hasTable('portal_editorial_editions')) { (require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->up(); } $this->assertTrue(Schema::hasColumns('portal_editorial_editions', ['payload', 'content_hash', 'state', 'version', 'approval', 'legal_validation'])); if (! Schema::hasColumn('portal_editorial_notifications', 'opportunity_id')) { (require database_path('migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php'))->up(); } config(['editorial_workflow.enabled' => true, 'editorial_workflow.independent_approval.kinds' => ['article', 'legislation', 'document']]); $this->withoutVite(); Storage::fake('local'); [$this->author, $this->reviewer, $this->legal, $this->publisher] = User::factory()->count(4)->create()->all(); config(['portal.managers' => [$this->author->email, $this->reviewer->email, $this->legal->email, $this->publisher->email], 'editorial_workflow.legal_reviewers' => [$this->legal->id]]); $document = $this->document('Conteúdo público original.'); $this->resource = DB::table('portal_resources')->insertGetId(['document_id' => $document, 'title' => 'Artigo sintético QA', 'category' => 'guide', 'topic' => 'qa', 'document_type' => 'guide', 'published' => true, 'approved_by' => $this->reviewer->id, 'created_by' => $this->author->id, 'source_reference' => 'Fonte sintética de teste isolado', 'source_url' => 'https://example.invalid/source', 'source_confirmation' => 'identified', 'rights_status' => 'authorized', 'independent_review' => true, 'metadata_version' => 1, 'editorial_content' => json_encode(['body' => 'Conteúdo público original.', 'image_mode' => 'none', 'preserve_original_html' => false]), 'created_at' => now(), 'updated_at' => now()]); } private function document(string $body): string { $id = (string) Str::uuid(); $bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'content_html' => '
'.$body.'
']); $hash = hash('sha256', $bytes); Storage::disk('local')->put('qa/'.$id.'.json', $bytes); DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic.json', 'media_type' => 'application/json', 'storage_key' => 'qa/'.$id.'.json', 'sha256' => $hash, 'malware_scanned_sha256' => $hash, 'malware_scan_status' => 'clean', 'rights_status' => 'authorized', 'status' => 'active', 'data_classification' => 'public', 'source_system' => 'portal-services', 'uploaded_by' => $this->author->id]); return $id; } private function save(array $fields = []): \stdClass { $this->actingAsWithMfa($this->author); $resource = DB::table('portal_resources')->where('id', $this->resource)->first(); return app(PortalEditorialWorkflow::class)->save($this->author, $this->resource, (int) app(PortalEditorialWorkflow::class)->workingResource($resource)->metadata_version, $fields); } private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = []) { $this->actingAsWithMfa($actor); $response = $this->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão sintética fundamentada QA', ...$extra])->assertStatus($status); return $status === 200 ? DB::table('portal_editorial_editions')->where('id', $edition->id)->first() : $response; } public function test_editing_and_private_preview_preserve_the_public_article_and_its_original_version(): void { $before = DB::table('portal_resources')->where('id', $this->resource)->first(); $this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Nova edição privada.', 'image_mode' => 'none'])->assertRedirect(); $r = DB::table('portal_resources')->where('id', $this->resource)->first(); $this->assertEquals($before, $r, 'The complete public resource must remain unchanged while a private edition is saved.'); $this->assertTrue((bool) $r->published); $this->assertSame(1, (int) $r->metadata_version, 'A private edit must not change even the public metadata version.'); $this->assertSame('Conteúdo público original.', json_decode($r->editorial_content, true)['body']); $this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count()); $this->get('/management/editorial/resources/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('current', true)->where('canApprove', false)->where('resource.public_edition_no', 1)); $this->get('/management/portal-services/resources/'.$this->resource.'/article/preview')->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.')); $this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.')); $this->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Resposta atrasada.', 'image_mode' => 'none'])->assertConflict(); $this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count()); $e = app(PortalEditorialWorkflow::class)->latest($this->resource); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'approve'); $this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.')); $this->action($this->publisher, $e, 'publish'); $this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.')); } public function test_legal_approval_and_publication_are_separate_version_bound_operations(): void { $e = $this->save(['title' => 'Legislação revista QA', 'legislation' => true, 'document_type' => 'legislation', 'source_confirmation' => 'official_confirmed']); $e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]); $e = $this->action($this->reviewer, $e, 'start_review'); $this->action($this->author, $e, 'approve', 403); $this->action($this->reviewer, $e, 'approve', 422); $this->action($this->reviewer, $e, 'legal_validate', 403); $e = $this->action($this->legal, $e, 'legal_validate'); $e = $this->action($this->reviewer, $e, 'approve'); $this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title')); $this->assertSame('approved', $e->state); $approved = $e; $e = $this->action($this->publisher, $e, 'publish'); $this->assertSame('published', $e->state); $this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title')); $this->action($this->publisher, $approved, 'publish', 409); $draft = $this->save(['title' => 'Alteração posterior privada']); $this->assertNull($draft->approval); $this->assertNull($draft->legal_validation); $this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title')); $this->actingAsWithMfa($this->author)->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.id', $draft->id)->assertJsonPath('resource.published', true); $this->assertGreaterThan(0, DB::table('portal_editorial_notifications')->where('user_id', $this->author->id)->count()); $this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'editorial_publish')->count()); $this->getJson('/management/editorial/resources/'.$this->resource.'?edition='.$approved->id)->assertOk()->assertJsonPath('current', false)->assertJsonPath('edition.payload.resource.title', 'Legislação revista QA'); } public function test_replacing_an_attachment_invalidates_approval_and_private_file_routes_are_scoped(): void { $doc = $this->document('Anexo novo, apenas na edição privada.'); $e = $this->save(['document_id' => $doc]); $url = '/management/editorial/resources/'.$this->resource.'/editions/'.$e->id.'/files/'.$doc; $this->get($url)->assertOk()->assertHeader('Cache-Control', 'no-store, private'); $outsider = User::factory()->create(); $this->actingAsWithMfa($outsider)->getJson($url)->assertForbidden(); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'approve'); DB::table('documents')->where('id', $doc)->increment('version_no'); $this->action($this->publisher, $e, 'publish', 409); $this->actingAsWithMfa($this->author)->getJson($url)->assertConflict(); $this->assertNotSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id')); } public function test_returns_rejection_archive_permissions_and_legacy_publication_cannot_bypass_review(): void { $e = $this->save(['title' => 'Edição para devolver']); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'return'); $this->assertSame('returned', $e->state); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'reject'); $e = $this->action($this->author, $e, 'archive'); $this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published')); $this->actingAsWithMfa($this->publisher)->post('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertConflict(); $this->actingAs(User::factory()->create())->getJson('/management/editorial')->assertForbidden(); $this->actingAs($this->author)->withSession(['portal.mfa_at' => 0])->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$e->id, ['action' => 'submit', 'version' => $e->version, 'reason' => 'MFA expirado'])->assertForbidden(); } public function test_document_metadata_replacement_remains_private_and_is_recovered_with_its_original_file(): void { $doc = $this->document('Documento de substituição privado.'); DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']); $fields = ['document_id' => $doc, 'title' => 'Documento revisto QA', 'description' => 'Descrição privada', 'category' => 'guide', 'topic' => 'qa', 'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized', 'independent_review' => true, 'source_reference' => 'Ensaio sintético de referência documental', 'source_url' => 'https://example.invalid/document', 'legislation' => false, 'essential' => false, 'data_classification' => 'public', 'metadata_version' => 1, 'issuer' => 'Emissor sintético QA']; $before = DB::table('portal_resources')->where('id', $this->resource)->first(); $this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource, $fields)->assertSessionHasNoErrors()->assertRedirect(); $this->assertSame($before->document_id, DB::table('portal_resources')->where('id', $this->resource)->value('document_id')); $this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification')); $this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.payload.resource.title', $fields['title'])->assertJsonPath('edition.payload.resource.document_id', $doc); $this->get('/documentos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('resource.title', $before->title)); $e = app(PortalEditorialWorkflow::class)->latest($this->resource); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'approve'); $e = $this->action($this->publisher, $e, 'publish'); $this->assertSame('public', DB::table('documents')->where('id', $doc)->value('data_classification')); $this->assertSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id')); $this->assertSame('Emissor sintético QA', DB::table('documents')->where('id', $doc)->value('issuer')); } public function test_notice_receipt_never_grants_access_and_revocation_blocks_publication(): void { $e = $this->save(); $e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]); $this->actingAsWithMfa($this->reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id); $this->getJson('/management/editorial?state=submitted&kind=article')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id); $this->getJson('/management/editorial?state=approved')->assertOk()->assertJsonPath('items.total', 0); $notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->value('id'); $this->postJson('/management/editorial/notices/'.$notice.'/read')->assertOk(); $this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice)->value('read_at')); $this->actingAsWithMfa($this->author)->postJson('/management/editorial/notices/'.$notice.'/read')->assertNotFound(); $outsider = User::factory()->create(); DB::table('portal_editorial_notifications')->insert(['edition_id' => $e->id, 'user_id' => $outsider->id, 'action' => 'submit', 'created_at' => now()]); $this->actingAsWithMfa($outsider)->getJson('/management/editorial')->assertForbidden(); $this->getJson('/management/editorial/resources/'.$this->resource)->assertForbidden(); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'approve'); config(['portal.managers' => [$this->author->email, $this->publisher->email, $this->legal->email]]); $this->action($this->publisher, $e, 'publish', 403); $this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title')); } public function test_all_contributors_are_excluded_from_independent_approval_and_modified_bytes_block_review(): void { $this->save(['description' => 'Contributo deliberado da autora.']); $this->actingAsWithMfa($this->reviewer); $e = app(PortalEditorialWorkflow::class)->save($this->reviewer, $this->resource, 2, ['title' => 'Edição colaborativa']); $e = $this->action($this->reviewer, $e, 'submit'); $e = $this->action($this->legal, $e, 'start_review'); $this->action($this->author, $e, 'approve', 403); $this->action($this->reviewer, $e, 'approve', 403); $e = $this->action($this->legal, $e, 'approve'); $this->assertSame('approved', $e->state); $doc = $this->document('Anexo íntegro inicial.'); $e = $this->save(['document_id' => $doc]); $key = DB::table('documents')->where('id', $doc)->value('storage_key'); Storage::disk('local')->put($key, 'Alteração dos bytes sem actualizar os metadados.'); $this->action($this->author, $e, 'submit', 404); $this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.fresh', false); $this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.')); } public function test_new_document_is_prepared_privately_without_disclosure_or_direct_publication(): void { $doc = $this->document('Documento novo não publicado.'); DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']); $marker = 'SYNTHEDITORIAL'.Str::random(10); $this->actingAsWithMfa($this->author)->post('/management/portal-services/resources', ['document_id' => $doc, 'title' => $marker, 'category' => 'guide', 'topic' => 'qa', 'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized', 'independent_review' => true, 'source_reference' => 'Fonte sintética QA, sem divulgação', 'source_url' => 'https://example.invalid/source', 'legislation' => false, 'essential' => false, 'data_classification' => 'public'])->assertSessionHasNoErrors()->assertRedirect(); $id = DB::table('portal_resources')->where('title', $marker)->value('id'); $this->assertNotNull($id); $this->assertSame('draft', app(PortalEditorialWorkflow::class)->latest($id)->state); $this->assertFalse((bool) DB::table('portal_resources')->where('id', $id)->value('published')); $this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification')); $this->get('/documentos/'.$id.'/download')->assertNotFound(); $this->get('/pesquisa?q='.$marker)->assertOk()->assertInertia(fn (Assert $p) => $p->where('results.total', 0)); $this->resource = (int) $id; $e = app(PortalEditorialWorkflow::class)->latest($this->resource); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->reviewer, $e, 'start_review'); $e = $this->action($this->reviewer, $e, 'approve'); $this->get('/documentos/'.$id.'/download')->assertNotFound(); $this->action($this->publisher, $e, 'publish'); $this->get('/documentos/'.$id.'/download')->assertOk(); } public function test_migration_refuses_an_operational_target_and_destructive_rollback_with_history(): void { $this->save(); $migration = require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'); try { $migration->down(); $this->fail('Existing editions must not be removed by rollback.'); } catch (\RuntimeException $exception) { $this->assertStringContainsString('Preserve editions', $exception->getMessage()); } config(['database.connections.pgsql.port' => 5432]); try { $migration->up(); $this->fail('Operational-target schema change must be refused.'); } catch (\RuntimeException $exception) { $this->assertStringContainsString('only in isolated QA', $exception->getMessage()); } finally { config(['database.connections.pgsql.port' => 55441]); } $this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count()); $this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published')); } public function test_missing_editorial_schema_blocks_writes_without_blocking_the_existing_dashboard(): void { (require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->down(); $before = DB::table('portal_resources')->where('id', $this->resource)->first(); $this->actingAsWithMfa($this->author)->get('/dashboard')->assertOk(); $this->putJson('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Não aplicar sem estrutura.', 'image_mode' => 'none'])->assertStatus(503); $this->postJson('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertStatus(503); $this->assertEquals($before, DB::table('portal_resources')->where('id', $this->resource)->first()); } public function test_review_independence_fact_is_not_a_policy_and_cannot_be_claimed_by_the_author(): void { config(['editorial_workflow.independent_approval.kinds' => [], 'editorial_workflow.independent_approval.resources' => []]); $e = $this->save(['independent_review' => true]); $this->assertFalse(app(PortalEditorialWorkflow::class)->requiresIndependent($e)); $this->assertFalse(app(PortalEditorialWorkflow::class)->payload($e)['resource']['independent_review']); $e = $this->action($this->author, $e, 'submit'); $e = $this->action($this->author, $e, 'start_review'); $e = $this->action($this->author, $e, 'approve'); $this->action($this->author, $e, 'publish'); $this->assertFalse((bool) DB::table('portal_resources')->where('id', $this->resource)->value('independent_review')); } }