## 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`.
169 lines
5.3 KiB
PHP
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');
|
|
});
|