Files
compras/tests/Feature/CommonStockReadTest.php
T
2026-11-02 20:27:11 +02:00

86 lines
8.5 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
final class CommonStockReadTest extends TestCase
{
public function test_authenticated_native_composition_and_common_reader(): void
{
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',$run);
$identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
$this->assertMatchesRegularExpression('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name);
$tag=substr($identity->name,-12);
$this->assertSame('stock_read_qa_'.$tag,$identity->role);$this->assertSame(55439,(int)$identity->port);$this->assertSame('sgf_modular_regression_'.$run,$identity->cluster);
// Fresh, dedicated QA database only. Never migrate/reset any existing host.
$this->assertFalse(DB::getSchemaBuilder()->hasTable('users'));
DB::transaction(function () {
DB::statement('CREATE TABLE tenants(id bigint PRIMARY KEY,name text,currency text)');
DB::statement("INSERT INTO tenants VALUES(77,'Synthetic native company','MZN'),(88,'Foreign company','MZN')");
DB::statement('CREATE TABLE users(id bigserial PRIMARY KEY,tenant_id bigint,department_id bigint,name text,email text,password text,purchasing_role text,email_verified_at timestamp,remember_token text,created_at timestamp,updated_at timestamp)');
DB::statement('CREATE TABLE organization_access(tenant_id bigint,active boolean)');
DB::statement('CREATE TABLE organization_profiles(id bigint PRIMARY KEY,tenant_id bigint,name text,permissions jsonb,work_contexts jsonb)');
DB::statement('CREATE TABLE organization_memberships(id bigint PRIMARY KEY,tenant_id bigint,user_id bigint,profile_id bigint,expires_at timestamp,maintenance boolean)');
});
$actor=User::forceCreate(['tenant_id'=>77,'purchasing_role'=>'warehouse','name'=>'Synthetic warehouse','email'=>'stock@example.test','password'=>bin2hex(random_bytes(16))]);
$denied=User::forceCreate(['tenant_id'=>77,'purchasing_role'=>'requester','name'=>'Synthetic requester','email'=>'request@example.test','password'=>bin2hex(random_bytes(16))]);
$schema='sgf_stock_read_'.bin2hex(random_bytes(6));
DB::transaction(function () use ($schema) {
DB::statement("CREATE SCHEMA $schema");
DB::statement("CREATE TABLE $schema.stock_items(id bigint PRIMARY KEY,tenant_id bigint,code text,name text,unit text)");
DB::statement("CREATE TABLE $schema.warehouses(id bigint PRIMARY KEY,tenant_id bigint,code text,name text)");
DB::statement("CREATE TABLE $schema.stock_movements(id bigserial PRIMARY KEY,tenant_id bigint,stock_item_id bigint,warehouse_id bigint,quantity_milli bigint,movement_type text,snapshot jsonb,moved_at timestamp)");
DB::statement("INSERT INTO $schema.warehouses VALUES(11,77,'WH-A','Synthetic A'),(12,77,'WH-B','Synthetic B'),(13,77,'HIDDEN','Restricted warehouse'),(21,88,'FOREIGN','Foreign warehouse')");
DB::statement("INSERT INTO $schema.stock_items SELECT i,77,'QA-'||lpad(i::text,6,'0'),'Synthetic item '||i,'un' FROM generate_series(1,100) i");
DB::statement("INSERT INTO $schema.stock_items VALUES(101,77,'ZERO','Without movements','un'),(9999999,88,'SECRET','Foreign item','un')");
DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
SELECT 77,i,11,10000,'receipt',jsonb_build_object('reference','QA-R-'||i,'private_document','DO_NOT_RETURN'),'2026-11-01'::timestamp FROM generate_series(1,100) i");
DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
SELECT 77,i,11,-2000,'issue',jsonb_build_object('reference','QA-I-'||i),'2026-11-02'::timestamp FROM generate_series(1,100) i");
DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at) VALUES(77,1,13,99000,'hidden','{}',now()),(88,9999999,21,88000,'foreign','{}',now())");
DB::statement("CREATE FUNCTION $schema.immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Inventory ledger is append-only''; END'");
DB::statement("CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON $schema.stock_movements FOR EACH ROW EXECUTE FUNCTION $schema.immutable()");
});
$binding=['schema'=>$schema,'source_tenant_id'=>77,'warehouse_ids'=>[11,12],'append_only'=>true];
$key='77:'.$actor->id;config(['stock_read.synthetic_sources.'.$key=>$binding]);
$this->getJson('/api/stock-read')->assertUnauthorized();
$this->actingAs($denied)->getJson('/api/stock-read')->assertForbidden();
$this->actingAs($actor);
$queries=[];foreach(['items','warehouses','balances','movements'] as $view)foreach([25,100] as $size)$queries[]='view='.$view.'&per_page='.$size;
$queries=[...$queries,'view=balances&per_page=25&page=2','view=items&per_page=25&page=5','view=balances&warehouse_id=12','view=items&search=QA-000001'];
$cases=[];
foreach($queries as $query){
$r=$this->getJson('/api/stock-read?'.$query)->assertOk();
$this->assertStringNotContainsString('DO_NOT_RETURN',$r->getContent());$this->assertStringNotContainsString('Foreign',$r->getContent());$this->assertStringNotContainsString('Restricted warehouse',$r->getContent());
$cases[$query]=['sha256'=>hash('sha256',json_encode([$r->json('rows'),$r->json('pagination')],JSON_THROW_ON_ERROR)),'total'=>$r->json('pagination.total')];
}
$snapshot=$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000')->json('snapshot');
$this->getJson('/api/stock-read?warehouse_id=13')->assertForbidden();$this->getJson('/api/stock-read?warehouse_id=21')->assertForbidden();
$this->getJson('/api/stock-read?tenant_id=88')->assertUnprocessable();$this->getJson('/api/stock-read?per_page=101')->assertUnprocessable();
$this->getJson('/api/stock-read?snapshot=invalid')->assertConflict();
DB::table($schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>-1000,'movement_type'=>'issue','snapshot'=>'{}','moved_at'=>now()]);
$this->getJson('/api/stock-read?snapshot='.urlencode($snapshot))->assertConflict();
$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','7000');
// Exercise configured native permissions too, without inventing a new right.
DB::table('organization_access')->insert(['tenant_id'=>77,'active'=>true]);
DB::table('organization_profiles')->insert(['id'=>1,'tenant_id'=>77,'name'=>'Synthetic warehouse profile','permissions'=>'["warehouse"]','work_contexts'=>'[]']);
DB::table('organization_memberships')->insert(['id'=>1,'tenant_id'=>77,'user_id'=>$actor->id,'profile_id'=>1,'maintenance'=>false]);
$this->getJson('/api/stock-read')->assertOk();
DB::table('organization_profiles')->where('id',1)->update(['permissions'=>'["requester"]']);
$this->getJson('/api/stock-read')->assertForbidden();
DB::table('organization_profiles')->where('id',1)->update(['permissions'=>'["warehouse"]']);
$this->getJson('/api/stock-read')->assertOk();
config(['stock_read.synthetic_sources.'.$key=>null]);$this->getJson('/api/stock-read')->assertStatus(503)->assertJsonPath('status','unavailable');
config(['stock_read.synthetic_sources.'.$key=>array_replace($binding,['source_tenant_id'=>88])]);$this->getJson('/api/stock-read')->assertStatus(503);
config(['stock_read.synthetic_sources.'.$key=>$binding]);$this->getJson('/api/stock-read')->assertOk();
$output=getenv('STOCK_READ_COMPOSITION_OUTPUT');$root=realpath((string)getenv('STOCK_READ_COMPOSITION_ROOT'));
if(!$output||!$root||realpath(dirname($output))!==$root)throw new \RuntimeException('Unexpected private proof directory');
file_put_contents($output,json_encode(['identity'=>$identity,'cases'=>$cases,'native_snapshot'=>$snapshot,'native_roles_and_profiles_revalidated'=>true,
'warehouse_isolation'=>true,'stale_snapshot_refused'=>true,'unknown_source_refused'=>true,'package_manifest_sha256'=>config('stock_read.package_manifest_sha256')],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
}