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:
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user