175 lines
12 KiB
PHP
175 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Course;
|
|
use App\Models\LabourDemand;
|
|
use App\Models\Occupation;
|
|
use App\Models\Project;
|
|
use App\Models\Skill;
|
|
use App\Models\TrainingCapacity;
|
|
use App\Models\TrainingProvider;
|
|
use App\Models\User;
|
|
use App\Services\EducationReviews;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
final class EducationReviewPreparationTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
private User $reviewer;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->withoutVite();
|
|
config(['education.enabled' => true]);
|
|
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
|
|
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
|
|
$this->assertSame(55441, (int) $identity->port);
|
|
$this->reviewer = $this->manager();
|
|
}
|
|
|
|
private function manager(): User
|
|
{
|
|
$u = User::factory()->create();
|
|
config(['portal.managers' => [...config('portal.managers', []), $u->email]]);
|
|
$this->actingAs($u);
|
|
|
|
return $u;
|
|
}
|
|
|
|
private function record(string $kind): LabourDemand|TrainingCapacity
|
|
{
|
|
$place = (string) Str::uuid();
|
|
DB::table('geo_locations')->insert(['id' => $place, 'name' => 'SYNTHETIC province', 'code' => $place, 'location_type' => 'province']);
|
|
$data = ['occupation_id' => Occupation::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC occupation'])->id, 'skill_id' => Skill::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC skill'])->id,
|
|
'geo_location_id' => $place, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'period_start' => today()->addMonth()->toDateString(), 'period_end' => today()->addMonths(2)->toDateString(),
|
|
'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'submitted_by' => User::factory()->create()->id, 'status' => 'submitted', 'version_no' => 1];
|
|
if ($kind === 'demands') {
|
|
return LabourDemand::create([...$data, 'project_id' => Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC project', 'status' => 'draft'])->id, 'employer' => 'SYNTHETIC employer']);
|
|
}
|
|
$provider = TrainingProvider::create(['name' => 'SYNTHETIC provider', 'status' => 'candidate']);
|
|
$course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC course', 'status' => 'draft']);
|
|
|
|
return TrainingCapacity::create([...$data, 'course_id' => $course->id]);
|
|
}
|
|
|
|
private function state(LabourDemand|TrainingCapacity $record): array
|
|
{
|
|
$base = ['version_no' => 1, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'status' => 'submitted', 'period_start' => $record->getRawOriginal('period_start'), 'period_end' => $record->getRawOriginal('period_end'),
|
|
'valid_until' => $record->getRawOriginal('valid_until'), 'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'employer' => '', 'references' => []];
|
|
|
|
return ['values' => ['version_no' => 1, 'status' => 'reviewed', 'review_reason' => ' SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE '], 'generation' => ['status' => 0, 'review_reason' => 1], 'base' => $base, 'pending' => null];
|
|
}
|
|
|
|
private function url(string $kind, LabourDemand|TrainingCapacity $r): string
|
|
{
|
|
return '/management/education/'.$kind.'/'.$r->id.'/review-preparation';
|
|
}
|
|
|
|
public function test_private_preparation_is_encrypted_actor_scoped_idempotent_and_preserves_the_declaration(): void
|
|
{
|
|
foreach (['demands', 'capacities'] as $kind) {
|
|
$record = $this->record($kind);
|
|
$before = $record->fresh()->getAttributes();
|
|
$url = $this->url($kind, $record);
|
|
$body = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($record)];
|
|
$response = $this->postJson($url, $body)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('version', 1);
|
|
$this->assertStringContainsString('"references":{}', $response->getContent());
|
|
$saved = $response->json();
|
|
$this->assertSame($body['snapshot'], $saved['snapshot']);
|
|
$this->postJson($url, $body)->assertOk()->assertJsonPath('version', 1);
|
|
$query = DB::table('audit_events')->where('source_system', 'portal-education-review')->where('entity_id', $record->id)->where('action', 'review_preparation_saved');
|
|
$this->assertSame(1, $query->count());
|
|
$this->assertStringNotContainsString('SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE', $query->value('after_data'));
|
|
$this->getJson($url)->assertOk()->assertJsonPath('snapshot.values.review_reason', $body['snapshot']['values']['review_reason']);
|
|
$this->assertSame($before, $record->fresh()->getAttributes());
|
|
$this->assertSame(0, DB::table('audit_events')->where('entity_id', $record->id)->where('action', 'review_committed')->count());
|
|
$this->get('/management/education/'.$kind.'/'.$record->id)->assertOk()->assertInertia(fn ($page) => $page->where('preparation.version', 1)->where('history.total', 0));
|
|
$this->manager();
|
|
$this->getJson($url)->assertOk()->assertJsonPath('version', 0)->assertJsonPath('snapshot', null);
|
|
$this->actingAs($this->reviewer);
|
|
$this->postJson($url, [...$body, 'snapshot' => [...$body['snapshot'], 'values' => [...$body['snapshot']['values'], 'review_reason' => 'DIFFERENT NOTE USING THE SAME KEY']]])->assertConflict();
|
|
}
|
|
}
|
|
|
|
public function test_private_preparation_optimistic_version_and_late_replay_never_replace_newer_notes(): void
|
|
{
|
|
$r = $this->record('demands');
|
|
$url = $this->url('demands', $r);
|
|
$first = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)];
|
|
$this->postJson($url, $first)->assertOk();
|
|
$second = [...$first, 'version' => 1, 'operation_key' => (string) Str::uuid()];
|
|
$second['snapshot']['values']['review_reason'] = 'SYNTHETIC deliberate newer note from a second window.';
|
|
$this->postJson($url, $second)->assertOk()->assertJsonPath('version', 2);
|
|
$this->postJson($url, $first)->assertOk()->assertJsonPath('version', 2)->assertJsonPath('snapshot.values.review_reason', $second['snapshot']['values']['review_reason']);
|
|
$this->postJson($url, [...$first, 'operation_key' => (string) Str::uuid()])->assertConflict();
|
|
$this->assertSame(2, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->count());
|
|
$other = $this->manager();
|
|
$this->postJson($url, $first)->assertConflict();
|
|
$this->getJson($url.'?actor_id='.$this->reviewer->id)->assertConflict();
|
|
$this->getJson($url)->assertOk()->assertJsonPath('snapshot', null)->assertJsonPath('actor_id', $other->id);
|
|
}
|
|
|
|
public function test_uncertain_review_is_recovered_from_its_private_intent_and_same_native_command_once(): void
|
|
{
|
|
$r = $this->record('demands');
|
|
$state = $this->state($r);
|
|
$key = (string) Str::uuid();
|
|
$state['pending'] = ['values' => [...$state['values'], 'client_key' => $key], 'reasonGeneration' => 1, 'statusGeneration' => 0, 'base' => $state['base']];
|
|
$state['values']['review_reason'] = 'SYNTHETIC later edit retained after an uncertain response.';
|
|
$state['generation']['review_reason'] = 2;
|
|
$url = $this->url('demands', $r);
|
|
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $state])->assertOk();
|
|
$native = '/management/education/demands/'.$r->id.'/review';
|
|
$this->post($native, $state['pending']['values'])->assertRedirect();
|
|
$recovered = $this->getJson($url)->assertOk()->json('snapshot');
|
|
$this->assertSame($state, $recovered);
|
|
$this->post($native, $recovered['pending']['values'])->assertRedirect();
|
|
$this->assertSame(2, $r->fresh()->version_no);
|
|
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_committed')->count());
|
|
$context = app(EducationReviews::class)->context($this->reviewer, $r->fresh());
|
|
$this->assertSame($key, $context['commands'][0]['key']);
|
|
$page = $this->get('/management/education/demands/'.$r->id)->assertOk()->viewData('page')['props'];
|
|
$directory = base_path('outputs/education-review-preparation-20261111');
|
|
if (! is_dir($directory)) {
|
|
mkdir($directory, 0700, true);
|
|
}
|
|
file_put_contents($directory.'/recovered-page.json', json_encode(['synthetic' => true, 'transactional_QA' => true, 'props' => array_intersect_key($page, array_flip(['declaration', 'kind', 'actorId', 'today', 'references', 'preparation', 'history', 'backUrl']))], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
|
|
|
|
}
|
|
|
|
public function test_unreadable_private_preparation_and_unauthorised_access_preserve_previous_bytes(): void
|
|
{
|
|
$r = $this->record('demands');
|
|
$url = $this->url('demands', $r);
|
|
$bytes = json_encode(['education_preparation' => ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => 'SYNTHETIC unreadable original encrypted bytes']], JSON_THROW_ON_ERROR);
|
|
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', 'after_data' => $bytes]);
|
|
$stored = DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data');
|
|
$this->getJson($url)->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
|
|
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)])->assertConflict();
|
|
$this->assertSame($stored, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data'));
|
|
$this->actingAs(User::factory()->create());
|
|
$this->getJson($url)->assertForbidden();
|
|
$this->postJson($url, [])->assertForbidden();
|
|
}
|
|
|
|
public function test_invalid_decrypted_snapshots_are_unavailable_without_losing_the_encrypted_original(): void
|
|
{
|
|
foreach (['{', json_encode(['unexpected' => 'SYNTHETIC invalid snapshot'], JSON_THROW_ON_ERROR)] as $plain) {
|
|
$r = $this->record('demands');
|
|
$cipher = Crypt::encryptString($plain);
|
|
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted',
|
|
'after_data' => json_encode(['education_preparation' => ['version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => $cipher]], JSON_THROW_ON_ERROR)]);
|
|
$this->getJson($this->url('demands', $r))->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
|
|
$actual = json_decode(DB::table('audit_events')->where('entity_id', $r->id)->value('after_data'), true);
|
|
$this->assertSame($cipher, $actual['education_preparation']['snapshot']);
|
|
}
|
|
}
|
|
}
|