## 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`.
120 lines
4.0 KiB
PHP
120 lines
4.0 KiB
PHP
<?php
|
|
|
|
app_require('routes/InvoicingPeriodRoute.php');
|
|
|
|
use routes\InvoicingPeriodRoute;
|
|
|
|
final class InvoicingPeriodCanaryResultStub
|
|
{
|
|
/** @param array<string,mixed>|null $row */
|
|
public function __construct(private ?array $row)
|
|
{
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function fetch_assoc(): ?array
|
|
{
|
|
return $this->row;
|
|
}
|
|
}
|
|
|
|
final class InvoicingPeriodCanaryDbStub
|
|
{
|
|
/** @var string[] */
|
|
public array $queries = [];
|
|
public ?string $value = '[7,43]';
|
|
public ?string $legacyValue = null;
|
|
|
|
public function escape_string(string $value): string
|
|
{
|
|
return addslashes($value);
|
|
}
|
|
|
|
public function query(string $sql): InvoicingPeriodCanaryResultStub|bool
|
|
{
|
|
$this->queries[] = $sql;
|
|
if (str_contains($sql, 'GET_LOCK')) {
|
|
return new InvoicingPeriodCanaryResultStub(['acquired' => 1]);
|
|
}
|
|
if (str_starts_with(ltrim($sql), 'SELECT value')) {
|
|
$selectedValue = str_contains($sql, 'object_tree_v2_allowlisted_user_ids')
|
|
? $this->legacyValue
|
|
: $this->value;
|
|
return new InvoicingPeriodCanaryResultStub(
|
|
$selectedValue === null ? null : ['value' => $selectedValue]
|
|
);
|
|
}
|
|
if (preg_match("/SET value = '([^']+)'/", $sql, $matches) === 1) {
|
|
$this->value = stripslashes($matches[1]);
|
|
}
|
|
if (preg_match("/VALUES \('InvoicingPeriod', 'object_tree_v2_superuser_allowlist', '([^']+)'/", $sql, $matches) === 1) {
|
|
$this->value = stripslashes($matches[1]);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** @return int[] */
|
|
function setInvoicePeriodCanaryUserForTest(int $userId, bool $enabled): array
|
|
{
|
|
$method = new ReflectionMethod(InvoicingPeriodRoute::class, 'setInvoicePeriodObjectTreeV2CanaryUser');
|
|
/** @var int[] $result */
|
|
$result = $method->invoke(null, $userId, $enabled);
|
|
return $result;
|
|
}
|
|
|
|
it('serializes self-canary updates and preserves other allowlisted users', function (): void {
|
|
$db = new InvoicingPeriodCanaryDbStub();
|
|
$previousDb = $GLOBALS['db'] ?? null;
|
|
$hadDb = array_key_exists('db', $GLOBALS);
|
|
$GLOBALS['db'] = $db;
|
|
|
|
try {
|
|
expect(setInvoicePeriodCanaryUserForTest(99, true))->toBe([7, 43, 99]);
|
|
expect(setInvoicePeriodCanaryUserForTest(43, false))->toBe([7, 99]);
|
|
} finally {
|
|
if ($hadDb) {
|
|
$GLOBALS['db'] = $previousDb;
|
|
} else {
|
|
unset($GLOBALS['db']);
|
|
}
|
|
}
|
|
|
|
$queries = implode("\n", $db->queries);
|
|
expect($queries)->toContain("GET_LOCK('invoice_period_object_tree_rollout', 5)")
|
|
->and($queries)->toContain("RELEASE_LOCK('invoice_period_object_tree_rollout')")
|
|
->and($db->value)->toBe('[7,99]');
|
|
});
|
|
|
|
it('migrates and preserves the legacy allowlist when the canonical row is absent', function (): void {
|
|
$db = new InvoicingPeriodCanaryDbStub();
|
|
$db->value = null;
|
|
$db->legacyValue = '[7,43]';
|
|
$previousDb = $GLOBALS['db'] ?? null;
|
|
$hadDb = array_key_exists('db', $GLOBALS);
|
|
$GLOBALS['db'] = $db;
|
|
|
|
try {
|
|
expect(setInvoicePeriodCanaryUserForTest(99, true))->toBe([7, 43, 99]);
|
|
} finally {
|
|
if ($hadDb) {
|
|
$GLOBALS['db'] = $previousDb;
|
|
} else {
|
|
unset($GLOBALS['db']);
|
|
}
|
|
}
|
|
|
|
expect($db->value)->toBe('[7,43,99]');
|
|
});
|
|
|
|
it('exposes only a self-canary endpoint guarded by the superuser permission', function (): void {
|
|
$route = (string)file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
|
|
expect($route)->toContain("'/superuser/invoicing/period/object-tree/canary'")
|
|
->and($route)->toContain("requirePermission('superuser')")
|
|
->and($route)->toContain("'configured_enabled' => \$enabled")
|
|
->and($route)->toContain("'effective_enabled' => \$effectiveEnabled")
|
|
->and($route)->toContain('OBJECT_TREE_V2_CANARY_ENABLE_FAILED')
|
|
->and($route)->toContain('OBJECT_TREE_V2_CANARY_ENABLED')
|
|
->and($route)->toContain('OBJECT_TREE_V2_CANARY_DISABLED');
|
|
});
|