Files
api/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionSchemaBootstrapTest.php
T
Jeppe B 0b304a2203 Gate invoice tree activation on audit schema (#339)
## Summary
- gate object-tree v2 on exact, schema-backed supersession audit columns
- serialize checked additive DDL and fail closed without disrupting
legacy invoicing
- preserve pre-schema supersession markers when structured columns are
still null
- add a superuser-only, self-scoped canary endpoint with locked
legacy-to-canonical allowlist migration
- verify effective activation, roll back failed readiness, and audit
enable/disable/failure

## Verification
- focused invoicing safety: 16 tests passed (99 assertions)
- full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing
warnings only
- PHP syntax and `git diff --check` clean
- independent architecture, security, and reviewer gates: GO

## Activation
Deploy with global database/environment enablement off. POST the
self-canary endpoint for one authenticated superuser, require
`configured_enabled=true` and `effective_enabled=true`, then verify the
exact period and tree GET routes. Roll back with the same endpoint using
`enabled=false`.
2026-08-03 13:03:55 +02:00

169 lines
5.3 KiB
PHP

<?php
app_require('classes/invoice_collection_schema_bootstrap.php');
use classes\invoice_collection_schema_bootstrap;
final class InvoiceCollectionSchemaResultStub
{
/** @param array<string,mixed> $row */
public function __construct(private array $row)
{
}
/** @return array<string,mixed> */
public function fetch_assoc(): array
{
return $this->row;
}
}
final class InvoiceCollectionSchemaDbStub
{
/** @var string[] */
public array $queries = [];
/** @var array<string, bool> */
public array $columns = [];
public bool $failAlter = false;
public bool $failColumnInspection = false;
public function escape_string(string $value): string
{
return addslashes($value);
}
public function getDatabase(): string
{
return 'test_db';
}
public function query(string $sql): InvoiceCollectionSchemaResultStub|bool
{
$this->queries[] = $sql;
if (str_contains($sql, 'information_schema.TABLES')) {
return new InvoiceCollectionSchemaResultStub(['c' => 1]);
}
if (str_contains($sql, 'information_schema.COLUMNS')) {
if ($this->failColumnInspection) {
return false;
}
preg_match("/COLUMN_NAME = '([^']+)'/", $sql, $matches);
return new InvoiceCollectionSchemaResultStub([
'c' => ($this->columns[$matches[1] ?? ''] ?? false) ? 1 : 0,
]);
}
if (str_contains($sql, 'GET_LOCK')) {
return new InvoiceCollectionSchemaResultStub(['acquired' => 1]);
}
if (preg_match('/ADD COLUMN `([^`]+)`/', $sql, $matches) === 1) {
if ($this->failAlter) {
return false;
}
$this->columns[$matches[1]] = true;
}
return true;
}
}
it('adds and verifies the invoice collection supersession audit columns under a lock', function (): void {
$db = new InvoiceCollectionSchemaDbStub();
$previousDb = $GLOBALS['db'] ?? null;
$hadDb = array_key_exists('db', $GLOBALS);
$GLOBALS['db'] = $db;
try {
expect(invoice_collection_schema_bootstrap::hasRequiredColumns())->toBeTrue();
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
$queries = implode("\n", $db->queries);
expect(array_filter($db->queries, static fn(string $query): bool => str_starts_with($query, 'ALTER TABLE')))
->toHaveCount(3)
->and($queries)->toContain("GET_LOCK('invoice_collection_schema_v1', 5)")
->and($queries)->toContain("RELEASE_LOCK('invoice_collection_schema_v1')")
->and($queries)->toContain('`superseded_by_collection_id` INT NULL')
->and($queries)->toContain('`superseded_at` DATETIME NULL')
->and($queries)->toContain('`superseded_by_user_id` INT NULL');
});
it('uses exact information schema equality checks instead of wildcard matching', function (): void {
$db = new InvoiceCollectionSchemaDbStub();
$db->columns = [
'superseded_by_collection_id' => true,
'superseded_at' => true,
'superseded_by_user_id' => true,
];
$previousDb = $GLOBALS['db'] ?? null;
$hadDb = array_key_exists('db', $GLOBALS);
$GLOBALS['db'] = $db;
try {
expect(invoice_collection_schema_bootstrap::hasRequiredColumns())->toBeTrue();
$queries = implode("\n", $db->queries);
expect($queries)->toContain("COLUMN_NAME = 'superseded_by_collection_id'")
->and($queries)->not->toContain('SHOW COLUMNS')
->and($queries)->not->toContain(' LIKE ');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('keeps object-tree activation fail closed until the audit columns are verified', function (): void {
$route = (string)file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
expect($route)->toContain('invoice_collection_schema_bootstrap::hasRequiredColumns()');
});
it('returns disabled and retries after schema ddl is denied', function (): void {
$db = new InvoiceCollectionSchemaDbStub();
$db->failAlter = true;
$previousDb = $GLOBALS['db'] ?? null;
$hadDb = array_key_exists('db', $GLOBALS);
$GLOBALS['db'] = $db;
try {
expect(invoice_collection_schema_bootstrap::hasRequiredColumns())->toBeFalse();
$db->failAlter = false;
expect(invoice_collection_schema_bootstrap::hasRequiredColumns())->toBeTrue();
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('does not lock or alter when column inspection fails', function (): void {
$db = new InvoiceCollectionSchemaDbStub();
$db->failColumnInspection = true;
$previousDb = $GLOBALS['db'] ?? null;
$hadDb = array_key_exists('db', $GLOBALS);
$GLOBALS['db'] = $db;
try {
expect(invoice_collection_schema_bootstrap::hasRequiredColumns())->toBeFalse();
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
$queries = implode("\n", $db->queries);
expect($queries)->not->toContain('GET_LOCK')
->and($queries)->not->toContain('ALTER TABLE');
});