305 lines
22 KiB
PHP
305 lines
22 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Services\PortalEditorialWorkflow;
|
|
use App\Support\IsolatedQaDatabase;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
use Tests\Support\AuthenticatesQaActors;
|
|
use Tests\TestCase;
|
|
|
|
final class PortalEditorialWorkflowTest extends TestCase
|
|
{
|
|
use AuthenticatesQaActors, DatabaseTransactions;
|
|
|
|
private User $author;
|
|
|
|
private User $reviewer;
|
|
|
|
private User $legal;
|
|
|
|
private User $publisher;
|
|
|
|
private int $resource;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->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' => '<p>'.$body.'</p>']);
|
|
$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'));
|
|
}
|
|
}
|