From 0b304a22031b39fd3259f29eb3910cee1ffe9425 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 3 Aug 2026 13:03:55 +0200 Subject: [PATCH] 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`. --- .../invoice_collection_schema_bootstrap.php | 142 +++++++++++++++ .../objects/collected_order_invoices_o.php | 3 +- .../nginx/app/routes/InvoicingPeriodRoute.php | 135 +++++++++++++- .../CollectedInvoiceBulkActionsApiTest.php | 24 ++- .../InvoiceCollectionBulkActionSafetyTest.php | 2 + .../InvoiceCollectionSchemaBootstrapTest.php | 168 ++++++++++++++++++ .../InvoicingPeriodObjectTreeCanaryTest.php | 119 +++++++++++++ 7 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 services/nginx/app/classes/invoice_collection_schema_bootstrap.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionSchemaBootstrapTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodObjectTreeCanaryTest.php diff --git a/services/nginx/app/classes/invoice_collection_schema_bootstrap.php b/services/nginx/app/classes/invoice_collection_schema_bootstrap.php new file mode 100644 index 00000000..81ddb063 --- /dev/null +++ b/services/nginx/app/classes/invoice_collection_schema_bootstrap.php @@ -0,0 +1,142 @@ + */ + 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. + } + } +} diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index f7a81ca3..479a7057 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -370,7 +370,8 @@ class collected_order_invoices_o extends db '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)) { return null; diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 5edc237b..c094b83f 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -10,6 +10,7 @@ use classes\economic_v2_versioning_service; use classes\invoice_collection_bulk_action_conflict; use classes\invoice_collection_bulk_action_service; use classes\invoice_collection_bulk_action_validation; +use classes\invoice_collection_schema_bootstrap; use classes\invoice_period_flag_service; use classes\invoicing_period_utils; use classes\slack; @@ -137,13 +138,13 @@ class InvoicingPeriodRoute $config[(string)$row['variable']] = $row['value']; } if (self::isTruthyObjectTreeConfigValue($config['object_tree_v2_enabled'] ?? null)) { - return true; + return invoice_collection_schema_bootstrap::hasRequiredColumns(); } $allowlist = $config['object_tree_v2_superuser_allowlist'] ?? $config['object_tree_v2_allowlisted_user_ids'] ?? null; if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) { - return true; + return invoice_collection_schema_bootstrap::hasRequiredColumns(); } if ($config !== []) { return false; @@ -153,7 +154,8 @@ class InvoicingPeriodRoute // 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 @@ -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 { foreach (($period['types'] ?? []) as $type => $customers) { @@ -1282,6 +1360,57 @@ class InvoicingPeriodRoute 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 () { // Require the user to be logged in global $response; diff --git a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php index 29feedc8..76de5304 100644 --- a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php +++ b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php @@ -18,8 +18,20 @@ function bulk_action_order_invoice_collection_id(int $orderId): int 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( - 'SELECT name, notes, closed_at FROM collected_order_invoices WHERE id = ' + 'SELECT ' . $select . ' FROM collected_order_invoices WHERE id = ' . $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($targetRow['name'] ?? null)->toBe('Authoritative target') ->and($targetRow['notes'] ?? null)->toBe('Keep target metadata') - ->and($sourceRow['notes'] ?? '')->toContain('[[invoice_collection_superseded:') ->and($sourceRow['closed_at'] ?? null)->not->toBeNull() ->and($applied['result']['superseded_invoice_collection_ids'] ?? []) ->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']); api_client()->post('/collected-invoices/economic', [ 'id' => (int)$sourceCollection['id'], diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php index d7f5f530..60caea62 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php @@ -46,6 +46,8 @@ it('previews whole-tree cleanup and merge supersession semantics', function (): ->toContain('public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void') ->toContain('SUPERSESSION_MARKER_PATTERN') ->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 target invoice collection has already been superseded.') ->toContain('if ($this->getSupersessionMetadata() !== null)'); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionSchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionSchemaBootstrapTest.php new file mode 100644 index 00000000..da886458 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionSchemaBootstrapTest.php @@ -0,0 +1,168 @@ + $row */ + public function __construct(private array $row) + { + } + + /** @return array */ + public function fetch_assoc(): array + { + return $this->row; + } +} + +final class InvoiceCollectionSchemaDbStub +{ + /** @var string[] */ + public array $queries = []; + + /** @var array */ + 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'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodObjectTreeCanaryTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodObjectTreeCanaryTest.php new file mode 100644 index 00000000..4cdbdcd7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodObjectTreeCanaryTest.php @@ -0,0 +1,119 @@ +|null $row */ + public function __construct(private ?array $row) + { + } + + /** @return array|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'); +});