Files
api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodObjectTreeCanaryTest.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

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');
});