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`.
This commit is contained in:
Jeppe B
2026-08-03 13:03:55 +02:00
committed by GitHub
parent 068f9e254f
commit 0b304a2203
7 changed files with 587 additions and 6 deletions
@@ -0,0 +1,142 @@
<?php
namespace classes;
/**
* Ensures additive audit columns used when invoice collections are superseded.
*/
class invoice_collection_schema_bootstrap
{
private const TABLE = 'collected_order_invoices';
private const LOCK_NAME = 'invoice_collection_schema_v1';
/** @var array<string, string> */
private const REQUIRED_COLUMNS = [
'superseded_by_collection_id' => 'INT NULL',
'superseded_at' => 'DATETIME NULL',
'superseded_by_user_id' => 'INT NULL',
];
public static function hasRequiredColumns(): bool
{
try {
global $db;
if (!self::canInspectSchema($db) || !self::tableExists($db)) {
return false;
}
if (self::allColumnsExist($db)) {
return true;
}
if (!self::acquireLock($db)) {
return false;
}
try {
// Another worker may have completed the additive migration while
// this request waited for the advisory lock.
foreach (self::REQUIRED_COLUMNS as $column => $definition) {
if (self::columnExists($db, $column)) {
continue;
}
$result = $db->query(
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `{$column}` {$definition}"
);
if ($result === false) {
return false;
}
}
if (!self::allColumnsExist($db)) {
return false;
}
return true;
} finally {
self::releaseLock($db);
}
} catch (\Throwable) {
// Schema readiness is a capability gate. It must never break the
// legacy invoicing routes when DDL or metadata access is unavailable.
return false;
}
}
private static function canInspectSchema(mixed $db): bool
{
return is_object($db)
&& method_exists($db, 'query')
&& method_exists($db, 'escape_string')
&& method_exists($db, 'getDatabase');
}
private static function tableExists(object $db): bool
{
return self::informationSchemaCount(
$db,
'information_schema.TABLES',
'TABLE_NAME',
self::TABLE
) > 0;
}
private static function columnExists(object $db, string $column): bool
{
return self::informationSchemaCount(
$db,
'information_schema.COLUMNS',
'COLUMN_NAME',
$column
) > 0;
}
private static function informationSchemaCount(
object $db,
string $informationSchemaTable,
string $nameField,
string $name
): int {
$database = $db->escape_string((string)$db->getDatabase());
$name = $db->escape_string($name);
$result = $db->query(
"SELECT COUNT(*) AS c FROM {$informationSchemaTable} "
. "WHERE TABLE_SCHEMA = '{$database}' AND TABLE_NAME = '" . self::TABLE . "' "
. "AND {$nameField} = '{$name}'"
);
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
throw new \RuntimeException('Invoice collection schema inspection failed');
}
$row = $result->fetch_assoc();
if (!is_array($row) || !array_key_exists('c', $row)) {
throw new \RuntimeException('Invoice collection schema inspection returned an invalid result');
}
return (int)$row['c'];
}
private static function allColumnsExist(object $db): bool
{
foreach (array_keys(self::REQUIRED_COLUMNS) as $column) {
if (!self::columnExists($db, $column)) {
return false;
}
}
return true;
}
private static function acquireLock(object $db): bool
{
$result = $db->query("SELECT GET_LOCK('" . self::LOCK_NAME . "', 5) AS acquired");
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
return false;
}
$row = $result->fetch_assoc();
return is_array($row) && (int)($row['acquired'] ?? 0) === 1;
}
private static function releaseLock(object $db): void
{
try {
$db->query("SELECT RELEASE_LOCK('" . self::LOCK_NAME . "')");
} catch (\Throwable) {
// The connection also releases advisory locks automatically.
}
}
}
@@ -370,7 +370,8 @@ class collected_order_invoices_o extends db
'superseded_at' => isset($row['superseded_at']) ? (string)$row['superseded_at'] : null, 'superseded_at' => isset($row['superseded_at']) ? (string)$row['superseded_at'] : null,
]; ];
} }
return null; // Collections superseded before the additive columns existed keep
// their authoritative metadata in the notes marker.
} }
if (!preg_match(self::SUPERSESSION_MARKER_PATTERN, (string)$this->notes->value(), $matches)) { if (!preg_match(self::SUPERSESSION_MARKER_PATTERN, (string)$this->notes->value(), $matches)) {
return null; return null;
@@ -10,6 +10,7 @@ use classes\economic_v2_versioning_service;
use classes\invoice_collection_bulk_action_conflict; use classes\invoice_collection_bulk_action_conflict;
use classes\invoice_collection_bulk_action_service; use classes\invoice_collection_bulk_action_service;
use classes\invoice_collection_bulk_action_validation; use classes\invoice_collection_bulk_action_validation;
use classes\invoice_collection_schema_bootstrap;
use classes\invoice_period_flag_service; use classes\invoice_period_flag_service;
use classes\invoicing_period_utils; use classes\invoicing_period_utils;
use classes\slack; use classes\slack;
@@ -137,13 +138,13 @@ class InvoicingPeriodRoute
$config[(string)$row['variable']] = $row['value']; $config[(string)$row['variable']] = $row['value'];
} }
if (self::isTruthyObjectTreeConfigValue($config['object_tree_v2_enabled'] ?? null)) { if (self::isTruthyObjectTreeConfigValue($config['object_tree_v2_enabled'] ?? null)) {
return true; return invoice_collection_schema_bootstrap::hasRequiredColumns();
} }
$allowlist = $config['object_tree_v2_superuser_allowlist'] $allowlist = $config['object_tree_v2_superuser_allowlist']
?? $config['object_tree_v2_allowlisted_user_ids'] ?? $config['object_tree_v2_allowlisted_user_ids']
?? null; ?? null;
if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) { if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) {
return true; return invoice_collection_schema_bootstrap::hasRequiredColumns();
} }
if ($config !== []) { if ($config !== []) {
return false; return false;
@@ -153,7 +154,8 @@ class InvoicingPeriodRoute
// Deployment/test fallback below; the default remains disabled. // Deployment/test fallback below; the default remains disabled.
} }
return self::isTruthyObjectTreeConfigValue(getenv('INVOICING_PERIOD_OBJECT_TREE_V2')); return self::isTruthyObjectTreeConfigValue(getenv('INVOICING_PERIOD_OBJECT_TREE_V2'))
&& invoice_collection_schema_bootstrap::hasRequiredColumns();
} }
private static function isTruthyObjectTreeConfigValue(mixed $value): bool private static function isTruthyObjectTreeConfigValue(mixed $value): bool
@@ -175,6 +177,82 @@ class InvoicingPeriodRoute
))); )));
} }
/** @return int[] */
private static function setInvoicePeriodObjectTreeV2CanaryUser(int $actorUserId, bool $enabled): array
{
global $db;
if ($actorUserId < 1) {
throw new \InvalidArgumentException('Invalid canary user');
}
$lockResult = $db->query("SELECT GET_LOCK('invoice_period_object_tree_rollout', 5) AS acquired");
$lockRow = is_object($lockResult) && method_exists($lockResult, 'fetch_assoc')
? $lockResult->fetch_assoc()
: null;
if (!is_array($lockRow) || (int)($lockRow['acquired'] ?? 0) !== 1) {
throw new \RuntimeException('Could not lock invoice period rollout configuration');
}
try {
$result = $db->query(
"SELECT value
FROM module_config
WHERE module = 'InvoicingPeriod'
AND variable = 'object_tree_v2_superuser_allowlist'
LIMIT 1"
);
if ($result === false || !is_object($result) || !method_exists($result, 'fetch_assoc')) {
throw new \RuntimeException('Could not read invoice period rollout configuration');
}
$row = $result->fetch_assoc();
$seedValue = is_array($row) ? ($row['value'] ?? null) : null;
if (!is_array($row)) {
$legacyResult = $db->query(
"SELECT value
FROM module_config
WHERE module = 'InvoicingPeriod'
AND variable = 'object_tree_v2_allowlisted_user_ids'
LIMIT 1"
);
if ($legacyResult === false || !is_object($legacyResult) || !method_exists($legacyResult, 'fetch_assoc')) {
throw new \RuntimeException('Could not read legacy invoice period rollout configuration');
}
$legacyRow = $legacyResult->fetch_assoc();
$seedValue = is_array($legacyRow) ? ($legacyRow['value'] ?? null) : null;
}
$allowlist = self::parseObjectTreeIntegerList($seedValue);
$allowlist = array_values(array_filter(
$allowlist,
static fn(int $userId): bool => $userId !== $actorUserId
));
if ($enabled) {
$allowlist[] = $actorUserId;
}
$allowlist = array_values(array_unique($allowlist));
sort($allowlist, SORT_NUMERIC);
$value = $db->escape_string((string)json_encode($allowlist, JSON_THROW_ON_ERROR));
$query = is_array($row)
? "UPDATE module_config
SET value = '{$value}', type = 'json'
WHERE module = 'InvoicingPeriod'
AND variable = 'object_tree_v2_superuser_allowlist'"
: "INSERT INTO module_config (module, variable, value, type)
VALUES ('InvoicingPeriod', 'object_tree_v2_superuser_allowlist', '{$value}', 'json')";
if ($db->query($query) === false) {
throw new \RuntimeException('Could not update invoice period rollout configuration');
}
return $allowlist;
} finally {
try {
$db->query("SELECT RELEASE_LOCK('invoice_period_object_tree_rollout')");
} catch (\Throwable) {
// The connection also releases advisory locks automatically.
}
}
}
private static function applyInvoicePeriodObjectTreeCapability(array $period, bool $enabled): array private static function applyInvoicePeriodObjectTreeCapability(array $period, bool $enabled): array
{ {
foreach (($period['types'] ?? []) as $type => $customers) { foreach (($period['types'] ?? []) as $type => $customers) {
@@ -1282,6 +1360,57 @@ class InvoicingPeriodRoute
public function run(): void public function run(): void
{ {
$this->post('/superuser/invoicing/period/object-tree/canary', function () {
global $response;
$this->requirePermission('superuser');
self::requireParameters(['enabled']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$enabled = filter_var(
$this->getParameter('enabled'),
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE
);
if ($enabled === null) {
$response->error('Parameter enabled must be a boolean', 400);
}
$allowlist = self::setInvoicePeriodObjectTreeV2CanaryUser((int)$user->id, $enabled);
$effectiveEnabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id);
if ($enabled && !$effectiveEnabled) {
self::setInvoicePeriodObjectTreeV2CanaryUser((int)$user->id, false);
(new logs_o())->add(
'invoicing_period',
'global',
1,
(int)$user->id,
'OBJECT_TREE_V2_CANARY_ENABLE_FAILED',
'Invoice period object-tree canary failed schema readiness verification and was rolled back'
);
$response->error('Invoice period object-tree schema is not ready', 503);
}
(new logs_o())->add(
'invoicing_period',
'global',
1,
(int)$user->id,
$enabled ? 'OBJECT_TREE_V2_CANARY_ENABLED' : 'OBJECT_TREE_V2_CANARY_DISABLED',
$enabled
? 'Enabled invoice period object-tree canary for current superuser'
: 'Disabled invoice period object-tree canary for current superuser'
);
$response->success([
'configured_enabled' => $enabled,
'effective_enabled' => $effectiveEnabled,
'user_id' => (int)$user->id,
'allowlisted_user_ids' => $allowlist,
]);
}, [
'superuser' => 'Enable or disable the invoice period object-tree canary for the current superuser',
]);
$this->get('/superuser/invoicing/period', function () { $this->get('/superuser/invoicing/period', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
@@ -18,8 +18,20 @@ function bulk_action_order_invoice_collection_id(int $orderId): int
function bulk_action_invoice_collection_row(int $invoiceCollectionId): array function bulk_action_invoice_collection_row(int $invoiceCollectionId): array
{ {
$supersessionColumns = api_test_runtime()->queryOne(
"SELECT COUNT(*) AS column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'collected_order_invoices'
AND COLUMN_NAME IN ('superseded_by_collection_id', 'superseded_by_user_id', 'superseded_at')"
);
$select = 'name, notes, closed_at';
if ((int)($supersessionColumns['column_count'] ?? 0) === 3) {
$select .= ', superseded_by_collection_id, superseded_by_user_id, superseded_at';
}
return api_test_runtime()->queryOne( return api_test_runtime()->queryOne(
'SELECT name, notes, closed_at FROM collected_order_invoices WHERE id = ' 'SELECT ' . $select . ' FROM collected_order_invoices WHERE id = '
. $invoiceCollectionId . ' LIMIT 1' . $invoiceCollectionId . ' LIMIT 1'
) ?? []; ) ?? [];
} }
@@ -561,11 +573,19 @@ it('merges selected invoice collections into the explicit target after confirmat
->and(bulk_action_order_invoice_collection_id((int)$targetOutsidePeriodOrder['id']))->toBe((int)$targetCollection['id']) ->and(bulk_action_order_invoice_collection_id((int)$targetOutsidePeriodOrder['id']))->toBe((int)$targetCollection['id'])
->and($targetRow['name'] ?? null)->toBe('Authoritative target') ->and($targetRow['name'] ?? null)->toBe('Authoritative target')
->and($targetRow['notes'] ?? null)->toBe('Keep target metadata') ->and($targetRow['notes'] ?? null)->toBe('Keep target metadata')
->and($sourceRow['notes'] ?? '')->toContain('[[invoice_collection_superseded:')
->and($sourceRow['closed_at'] ?? null)->not->toBeNull() ->and($sourceRow['closed_at'] ?? null)->not->toBeNull()
->and($applied['result']['superseded_invoice_collection_ids'] ?? []) ->and($applied['result']['superseded_invoice_collection_ids'] ?? [])
->toBe([(int)$sourceCollection['id']]); ->toBe([(int)$sourceCollection['id']]);
if (array_key_exists('superseded_by_collection_id', $sourceRow)) {
expect((int)$sourceRow['superseded_by_collection_id'])->toBe((int)$targetCollection['id'])
->and((int)($sourceRow['superseded_by_user_id'] ?? 0))->toBeGreaterThan(0)
->and($sourceRow['superseded_at'] ?? null)->not->toBeNull()
->and($sourceRow['notes'] ?? null)->toBe('Keep source audit note');
} else {
expect($sourceRow['notes'] ?? '')->toContain('[[invoice_collection_superseded:');
}
$exportSession = api_fixtures()->createUserSession(['add_collected_invoice_economic']); $exportSession = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
api_client()->post('/collected-invoices/economic', [ api_client()->post('/collected-invoices/economic', [
'id' => (int)$sourceCollection['id'], 'id' => (int)$sourceCollection['id'],
@@ -46,6 +46,8 @@ it('previews whole-tree cleanup and merge supersession semantics', function ():
->toContain('public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void') ->toContain('public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void')
->toContain('SUPERSESSION_MARKER_PATTERN') ->toContain('SUPERSESSION_MARKER_PATTERN')
->toContain('public function getSupersessionMetadata(): ?array') ->toContain('public function getSupersessionMetadata(): ?array')
->toContain('Collections superseded before the additive columns existed')
->toMatch('/Collections superseded before the additive columns existed.*?}\s*if \(!preg_match\(self::SUPERSESSION_MARKER_PATTERN/s')
->toContain('The source invoice collection has already been superseded.') ->toContain('The source invoice collection has already been superseded.')
->toContain('The target invoice collection has already been superseded.') ->toContain('The target invoice collection has already been superseded.')
->toContain('if ($this->getSupersessionMetadata() !== null)'); ->toContain('if ($this->getSupersessionMetadata() !== null)');
@@ -0,0 +1,168 @@
<?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');
});
@@ -0,0 +1,119 @@
<?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');
});