Files
XC_VM/tests/Unit/ClusterOverviewTest.php
T
Divarion_D 6556851d28 test(cluster): ClusterOverviewTest follows the atomic activation-key save
The source patterns pinned file_put_contents($rPath ...) for the save and the
restore; both go through writeKey() now. Also asserts writeKey() replaces the
file whole (tempnam beside it, rename over it).
2026-10-19 21:31:16 +03:00

377 lines
23 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Cluster\ClusterHealth;
use XcVm\Core\Cluster\Crypto\ClusterCryptoFactory;
use XcVm\Core\Cluster\ReplicaSections;
use XcVm\Core\Config\SettingsManager;
use XcVm\Domain\Cluster\ClusterAdmin;
use XcVm\Domain\Cluster\ClusterBus;
use XcVm\Domain\Cluster\ClusterClock;
use XcVm\Domain\Cluster\ClusterOverview;
use XcVm\Domain\Cluster\ClusterRoute;
use XcVm\Domain\Cluster\ClusterSemaphore;
use XcVm\Domain\Cluster\ConnectionDigest;
use XcVm\Domain\Cluster\LivenessService;
use XcVm\Domain\Cluster\NodeRegistry;
use XcVm\Infrastructure\Database\DatabaseFactory;
use XcVm\Tests\Support\BusServer;
use XcVm\Tests\Support\FakeClusterCrypto;
/**
* The Cluster Nodes page's fence windows, banners, figures and "Rotate all
* tokens now" (plan section 11; ADR 0004, Phase 9), and the Servers list's
* cluster row actions that post to it.
*/
final class ClusterOverviewTest extends TestCase {
private const NOW = 1_800_000_000;
private TestDb $rDb;
private FakeClusterCrypto $rCrypto;
protected function setUp(): void {
$this->rDb = new TestDb();
foreach (['029_create_cluster_nodes', '030_create_cluster_commands', '032_create_cluster_audit'] as $rName) {
$this->rDb->exec((string) file_get_contents(MAIN_HOME . 'migrations/database/up/' . $rName . '.sql'));
}
$this->rDb->exec('ALTER TABLE `cluster_nodes` ADD COLUMN `root_ready` tinyint(1) NOT NULL DEFAULT 0');
DatabaseFactory::set($this->rDb);
SettingsManager::set(['cluster_api_enabled' => 1]);
ClusterClock::fix(self::NOW * 1000);
ClusterBus::useSocket(sys_get_temp_dir() . '/no-such-bus-' . bin2hex(random_bytes(4)) . '/cluster.sock');
$this->rCrypto = new FakeClusterCrypto();
ClusterRoute::useCrypto(fn() => $this->rCrypto);
}
protected function tearDown(): void {
ClusterRoute::useCrypto(null);
ClusterBus::useSocket(null);
ClusterClock::fix(null);
ClusterHealth::usePath(null);
DatabaseFactory::reset();
SettingsManager::set([]);
}
private function node(int $rServerID, string $rState, int $rFlows, int $rTokenExp = self::NOW + 3600): void {
$this->rDb->query(
'INSERT INTO `cluster_nodes` (`server_id`, `node_uuid`, `state`, `mode`, `flows`, `gen`, `epoch`, `token_exp`, `created_at`, `updated_at`) VALUES (?, ?, ?, 1, ?, 1, 1, ?, ?, ?);',
$rServerID,
sprintf('00000000-0000-4000-8000-%012d', $rServerID),
$rState,
$rFlows,
$rTokenExp,
self::NOW,
self::NOW
);
}
private function command(int $rServerID, string $rState, int $rCreated, ?int $rDelivered, ?int $rAcked): void {
static $rSeq = 0;
$rSeq++;
$this->rDb->query(
'INSERT INTO `cluster_commands` (`server_id`, `seq`, `cmd_id`, `type`, `payload`, `sig`, `state`, `created_at`, `exp`, `delivered_at`, `acked_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
$rServerID,
$rSeq,
bin2hex(random_bytes(16)),
'node.rpc',
'{}',
str_repeat("\0", 64),
$rState,
$rCreated,
$rCreated + 600,
$rDelivered,
$rAcked
);
}
// ── Banners ─────────────────────────────────────────────────────────
public function testNoBannerWhileLicensedAndTheCertificateIsFar(): void {
$rMain = ['enable_https' => 1, 'certbot_ssl' => json_encode(['expiration' => self::NOW + 30 * 86400])];
$this->assertSame([], ClusterOverview::banners(true, $rMain, ['cluster_api_enabled' => 1, 'cluster_transport' => 'https_required'], [], self::NOW));
$this->assertSame([], ClusterOverview::banners(false, $rMain, ['cluster_api_enabled' => 0], [], self::NOW), 'the API off: nothing to say');
}
public function testALapsedLicenceSaysWhenTheFleetStops(): void {
$rNodes = [
['server_id' => 2, 'state' => 'active', 'token_exp' => self::NOW + 600],
['server_id' => 3, 'state' => 'active', 'token_exp' => self::NOW + 1800],
['server_id' => 4, 'state' => 'revoked', 'token_exp' => self::NOW + 99999],
];
$rSettings = ['cluster_api_enabled' => 1, 'lb_lease_fence' => 1, 'lb_partition_tolerance_h' => 2, 'lb_fence_drain_min' => 10];
$rBanners = ClusterOverview::banners(false, [], $rSettings, $rNodes, self::NOW);
$this->assertCount(1, $rBanners);
$this->assertSame('cluster_banner_licence_stop', $rBanners[0]['key']);
$this->assertSame('danger', $rBanners[0]['type']);
$this->assertSame(gmdate('Y-m-d H:i', self::NOW + 1800 + 7200 + 600) . ' UTC', $rBanners[0]['vars']['{TIME}'], 'the last active node\'s drain; a revoked node counts for nothing');
$this->assertSame('cluster_banner_licence_no_fence', ClusterOverview::banners(false, [], ['lb_lease_fence' => 0] + $rSettings, $rNodes, self::NOW)[0]['key']);
$this->assertSame('cluster_banner_licence', ClusterOverview::banners(false, [], $rSettings, [], self::NOW)[0]['key'], 'no active node');
}
public function testMainsCertificateExpiringWhileNodesDialHttps(): void {
$rMain = ['enable_https' => 1, 'certbot_ssl' => json_encode(['expiration' => self::NOW + 10 * 86400])];
$rSettings = ['cluster_api_enabled' => 1];
$this->assertSame([], ClusterOverview::banners(true, $rMain, ['cluster_transport' => 'http'] + $rSettings, [], self::NOW), 'plain HTTP: the certificate does not matter');
$this->assertSame([], ClusterOverview::banners(true, ['enable_https' => 0] + $rMain, ['cluster_transport' => 'https_required'] + $rSettings, [], self::NOW), 'HTTPS off on MAIN');
$rWarn = ClusterOverview::banners(true, $rMain, ['cluster_transport' => 'https_preferred'] + $rSettings, [], self::NOW);
$this->assertSame([['type' => 'warning', 'key' => 'cluster_banner_cert_expiring', 'vars' => ['{DATE}' => gmdate('Y-m-d H:i', self::NOW + 10 * 86400) . ' UTC', '{DAYS}' => '10', '{TRANSPORT}' => 'https_preferred']]], $rWarn);
$this->assertSame('danger', ClusterOverview::banners(true, $rMain, ['cluster_transport' => 'https_required'] + $rSettings, [], self::NOW)[0]['type'], 'required: an expiry cuts the fleet off');
$this->assertSame('warning', ClusterOverview::banners(true, $rMain, $rSettings, [], self::NOW)[0]['type'], 'auto dials HTTPS when MAIN has it');
$rExpired = ClusterOverview::banners(true, ['certbot_ssl' => json_encode(['expiration' => self::NOW - 60])] + $rMain, ['cluster_transport' => 'https_preferred'] + $rSettings, [], self::NOW);
$this->assertSame(['danger', 'cluster_banner_cert_expired'], [$rExpired[0]['type'], $rExpired[0]['key']]);
}
// ── Figures ─────────────────────────────────────────────────────────
public function testTheFenceWindowOfEachNode(): void {
$rWindows = ClusterOverview::fenceWindows([['server_id' => 2, 'token_exp' => self::NOW], ['server_id' => 3, 'token_exp' => null]], ['lb_partition_tolerance_h' => 1, 'lb_fence_drain_min' => 5]);
$this->assertSame(self::NOW + 3600, $rWindows[2]['lease_until']);
$this->assertSame(self::NOW + 3600 + 300, $rWindows[2]['drain_until']);
$this->assertNull($rWindows[3]['lease_until']);
}
public function testPercentilesAreNearestRank(): void {
$this->assertNull(ClusterOverview::percentile([], 50));
$this->assertSame(3, ClusterOverview::percentile([5, 1, 3, 2, 4], 50));
$this->assertSame(100, ClusterOverview::percentile([...array_fill(0, 99, 1), 100], 99 + 1));
$this->assertSame(1, ClusterOverview::percentile([...array_fill(0, 99, 1), 100], 99));
}
public function testCommandQueueAndLatency(): void {
$this->command(2, 'queued', self::NOW - 30, null, null);
$this->command(2, 'delivered', self::NOW - 10, self::NOW - 9, null);
$this->command(3, 'queued', self::NOW - 5, null, null);
$this->command(3, 'queued', self::NOW - 1000, null, null); // expired (exp = created + 600): not in the queue
foreach ([1, 1, 2, 4] as $rI => $rDelay) {
$this->command(2, 'acked', self::NOW - 100 - $rI, self::NOW - 100 - $rI + $rDelay, self::NOW - 100 - $rI + $rDelay + 1);
}
$this->command(2, 'acked', self::NOW - 7200, self::NOW - 7190, self::NOW - 7100); // outside the hour
$rOut = ClusterOverview::commandMetrics(self::NOW);
$this->assertSame(3, $rOut['depth']);
$this->assertSame([2 => 2, 3 => 1], $rOut['per_node']);
$this->assertSame(self::NOW - 30, $rOut['oldest']);
$this->assertSame(4, $rOut['samples']);
$this->assertSame(1, $rOut['deliver_p50']);
$this->assertSame(4, $rOut['deliver_p99']);
$this->assertSame(2, $rOut['ack_p50']);
$this->assertSame(5, $rOut['ack_p99']);
}
public function testTheRecentAudit(): void {
foreach (['node.flows', 'node.mode', 'node.token_rotate'] as $rI => $rEvent) {
$this->rDb->query('INSERT INTO `cluster_audit` (`time`, `server_id`, `actor`, `event`, `detail`) VALUES (?, ?, ?, ?, ?);', self::NOW + $rI, 2, 'admin:1', $rEvent, '{}');
}
$rRows = ClusterOverview::audit(2);
$this->assertSame(['node.token_rotate', 'node.mode'], array_column($rRows, 'event'), 'newest first, as many as asked');
$this->assertSame(2, $rRows[0]['server_id']);
}
public function testSaturationWithoutTheBus(): void {
$rPath = sys_get_temp_dir() . '/xcvm-health-' . bin2hex(random_bytes(4)) . '.json';
ClusterHealth::usePath($rPath);
$rNowMs = self::NOW * 1000;
ClusterHealth::write([], true, [], [ClusterHealth::GUARD_CTL_QUEUE], ['since' => $rNowMs - 7000, 'at' => $rNowMs - 1000]);
$rOut = ClusterOverview::saturation([], $rNowMs);
$this->assertNull($rOut['ingest'], 'no bus: nothing is limited');
$this->assertSame(6000, $rOut['ctl_queue_ms']);
$this->assertNull(ClusterOverview::saturation([], $rNowMs + 60000)['ctl_queue_ms'], 'a queue last seen a minute ago is over');
@unlink($rPath);
}
public function testIngestPermitsInUseOnTheBus(): void {
$rBus = BusServer::start('overview');
if ($rBus === null) {
$this->markTestSkipped('no redis-server or phpredis');
}
try {
ClusterBus::useSocket($rBus->socket());
$this->assertSame(['p0' => 0, 'bulk' => 0, 'permits' => ['p0' => 3, 'bulk' => 3, 'total' => 6]], ClusterSemaphore::ingestInUse(null));
$rP0 = ClusterSemaphore::acquireIngest(ClusterSemaphore::LANE_P0, null);
$this->assertIsString($rP0);
$this->assertIsString(ClusterSemaphore::acquireIngest(ClusterSemaphore::LANE_BULK, null));
$this->assertIsString(ClusterSemaphore::acquireIngest(ClusterSemaphore::LANE_BULK, null));
$rUse = ClusterSemaphore::ingestInUse(4);
$this->assertSame([1, 2, ['p0' => 2, 'bulk' => 2, 'total' => 4]], [$rUse['p0'], $rUse['bulk'], $rUse['permits']]);
ClusterSemaphore::releaseIngest(ClusterSemaphore::LANE_P0, $rP0);
$this->assertSame(0, ClusterSemaphore::ingestInUse(4)['p0']);
} finally {
ClusterBus::useSocket(null);
$rBus->stop();
}
}
// ── Rotate all tokens now ───────────────────────────────────────────
public function testRotateAllQueuesForEveryActiveNodeThatTakesCommands(): void {
$this->node(2, 'active', NodeRegistry::FLOW_COMMANDS);
$this->node(3, 'active', NodeRegistry::FLOW_COMMANDS | NodeRegistry::FLOW_TELEMETRY);
$this->node(4, 'active', NodeRegistry::FLOW_TELEMETRY); // no commands: skipped
$this->node(5, 'revoked', NodeRegistry::FLOW_COMMANDS); // not active: not asked
$this->assertSame(['queued' => 2, 'no_commands' => 1, 'failed' => 0], ClusterOverview::rotateAll(7));
$this->rDb->query("SELECT `server_id`, `type`, `dedupe_key` FROM `cluster_commands` ORDER BY `server_id`;");
$this->assertSame([[2, 'token.rotate_now'], [3, 'token.rotate_now']], array_map(static fn(array $rRow): array => [(int) $rRow['server_id'], $rRow['type']], $this->rDb->get_rows()));
$this->rDb->query("SELECT `server_id`, `actor`, `detail` FROM `cluster_audit` WHERE `event` = 'node.token_rotate' ORDER BY `server_id`;");
$rAudit = $this->rDb->get_rows();
$this->assertSame(['2', '3'], array_map(static fn(array $rRow): string => (string) $rRow['server_id'], $rAudit));
$this->assertSame('admin:7', $rAudit[0]['actor']);
$this->assertSame(['queued' => true, 'all' => true], json_decode($rAudit[0]['detail'], true));
ClusterOverview::rotateAll(7);
$this->rDb->query('SELECT COUNT(*) AS `n` FROM `cluster_commands`;');
$this->assertSame(2, (int) $this->rDb->get_row()['n'], 'a second click replaces the rotation still waiting (its dedupe key), rather than adding one');
}
public function testTheDashboardShowsThePagesBannersFromTheNodesTable(): void {
ClusterCryptoFactory::useProbe(static fn(): array => ['api' => 1]);
LivenessService::useLicence(static fn(): bool => false);
try {
$this->node(2, 'active', NodeRegistry::FLOW_COMMANDS, self::NOW + 600);
$this->node(3, 'revoked', NodeRegistry::FLOW_COMMANDS, self::NOW + 99999);
$rSettings = ['cluster_api_enabled' => 1, 'lb_lease_fence' => 1, 'lb_partition_tolerance_h' => 2, 'lb_fence_drain_min' => 10];
$rBanners = ClusterOverview::dashboardBanners([], $rSettings, self::NOW);
$this->assertSame(ClusterOverview::banners(false, [], $rSettings, [['state' => 'active', 'token_exp' => self::NOW + 600]], self::NOW), $rBanners, 'the page\'s, from each node\'s state and token expiry');
$this->assertSame('cluster_banner_licence_stop', $rBanners[0]['key']);
LivenessService::useLicence(static fn(): bool => true);
$this->assertSame([], ClusterOverview::dashboardBanners([], $rSettings, self::NOW), 'licensed');
LivenessService::useLicence(static fn(): bool => false);
$this->assertSame([], ClusterOverview::dashboardBanners([], ['cluster_api_enabled' => 0] + $rSettings, self::NOW), 'the API off');
ClusterCryptoFactory::useProbe(static fn(): ?array => null);
$this->assertSame([], ClusterOverview::dashboardBanners([], $rSettings, self::NOW), 'no extension');
} finally {
ClusterCryptoFactory::useProbe(null);
LivenessService::useLicence(null);
}
}
public function testAClockOffTheMainsIsBadgedNeverFenced(): void {
$this->assertNull(ClusterOverview::clockBadge(null), 'not reported');
$this->assertNull(ClusterOverview::clockBadge(-30000), 'within 30 s');
$this->assertSame(['tone' => 'warning', 'key' => 'cluster_clock_off', 'vars' => ['{OFFSET}' => '+45s']], ClusterOverview::clockBadge('45000'));
$this->assertSame(['tone' => 'danger', 'key' => 'cluster_clock_degraded', 'vars' => ['{OFFSET}' => '-600s']], ClusterOverview::clockBadge(-600000));
$this->assertNull(ClusterOverview::clockBadge('bogus'), 'not a number');
}
public function testAConnectionMismatchOrResyncIsBadged(): void {
$rNow = self::NOW * 1000;
$this->assertNull(ClusterOverview::divergenceBadge(null, $rNow), 'no digest, no check');
$this->assertNull(ClusterOverview::divergenceBadge(['at' => $rNow - 4000, 'miss' => 0, 'asked' => 0], $rNow), 'agreeing');
$this->assertSame('cluster_conn_differ', ClusterOverview::divergenceBadge(['at' => $rNow - 4000, 'miss' => 1, 'asked' => 0], $rNow)['key'] ?? null);
$this->assertNull(ClusterOverview::divergenceBadge(['at' => $rNow - 61000, 'miss' => 1, 'asked' => 0], $rNow), 'a check over a minute old: the node sends no digest now');
$this->assertSame(['tone' => 'warning', 'key' => 'cluster_conn_resynced', 'vars' => ['{AGO}' => '42s']], ClusterOverview::divergenceBadge(['at' => $rNow, 'miss' => 0, 'asked' => $rNow - 42000], $rNow));
$this->assertNull(ClusterOverview::divergenceBadge(['at' => $rNow, 'miss' => 0, 'asked' => $rNow - ClusterOverview::RESYNC_SHOWN_MS], $rNow), 'resynced long ago');
// The Servers list's badges read the digest's own state file.
$rDir = sys_get_temp_dir() . '/xcvm-digest-' . bin2hex(random_bytes(4)) . '/';
mkdir($rDir);
ConnectionDigest::useState($rDir);
try {
file_put_contents($rDir . '5.json', json_encode(['at' => $rNow, 'miss' => 0, 'asked' => $rNow - 1000]));
$rBadges = ClusterOverview::nodeBadges(['server_id' => 5, 'clock_offset_ms' => 45000], $rNow);
$this->assertSame(['cluster_clock_off', 'cluster_conn_resynced'], array_column($rBadges, 'key'));
$this->assertSame(['cluster_clock_help', 'cluster_conn_help'], array_column($rBadges, 'help'));
$this->assertSame([], ClusterOverview::nodeBadges(['server_id' => 6, 'clock_offset_ms' => 0], $rNow));
} finally {
ConnectionDigest::useState(null);
exec('rm -rf ' . escapeshellarg($rDir));
}
}
public function testTheInfoTabShowsTheGateTheKeysAndTheNextRefresh(): void {
$this->node(2, 'active', NodeRegistry::FLOW_COMMANDS);
$this->node(3, 'active', NodeRegistry::FLOW_COMMANDS);
$this->node(4, 'enrolling', 0);
foreach ([[2, self::NOW + 900], [3, self::NOW + 300], [4, self::NOW + 60]] as [$rSid, $rRefresh]) {
$this->rDb->query('INSERT INTO `cluster_node_epochs` (`server_id`, `epoch`, `record`, `nbf`, `exp`, `refresh_at`, `created_at`) VALUES (?, 1, ?, ?, ?, ?, ?)', $rSid, '', self::NOW, self::NOW + 3600, $rRefresh, self::NOW);
}
ClusterCryptoFactory::useProbe(static fn(): array => ['api' => 1, 'ext_version' => '2.4.0', 'licensed' => true, 'kid' => 'a1b2c3d4', 'clock_ok' => true, 'initialised' => true, 'panel_fp' => str_repeat("\xab", 32)]);
try {
$rRows = array_column((array) ClusterOverview::infoRows(['cluster_api_enabled' => 1, 'live_streaming_pass' => 'pass']), 1, 0);
$this->assertSame(['On', 'Open', 'a1b2c3d4'], [$rRows['Cluster API'], $rRows['Licence gate'], $rRows['Licence kid']]);
$this->assertSame(str_repeat('ab', 32), $rRows['Panel key'], 'what cluster:pin-root takes');
$this->assertSame('2 active, 1 enrolling', $rRows['Nodes']);
$this->assertSame(gmdate('Y-m-d H:i', self::NOW + 300) . ' UTC', $rRows['Next token refresh'], 'the soonest active node\'s; an enrolling one does not count');
$this->assertSame('2.4.0 (API 1; the panel takes ' . ClusterCryptoFactory::status()['range'] . ')', $rRows['Extension']);
$this->assertSame('OK', $rRows['Extension clock']);
$this->assertSame('kid ' . ReplicaSections::kid('live_streaming_pass', 'pass') . ', never rotated', $rRows['Stream secret']);
ClusterCryptoFactory::useProbe(static fn(): array => ['api' => 1, 'ext_version' => '2.4.0', 'licensed' => false, 'clock_ok' => false, 'initialised' => false, 'root_error' => 'NO_ROOT']);
$rRows = array_column((array) ClusterOverview::infoRows([]), 1, 0);
$this->assertSame(['Off', 'Closed: no token is issued', '—', 'Not initialised: NO_ROOT', 'Rolled back: cluster calls are refused'], [$rRows['Cluster API'], $rRows['Licence gate'], $rRows['Licence kid'], $rRows['Panel key'], $rRows['Extension clock']]);
ClusterCryptoFactory::useProbe(static fn(): ?array => null);
$this->assertNull(ClusterOverview::infoRows([]), 'no extension, no block');
} finally {
ClusterCryptoFactory::useProbe(null);
}
}
public function testANewLicenceKeyRotatesEveryToken(): void {
// The ajax action ends the request, so its source: a key other than the
// one on disk, once the extension accepts it, sends every active node
// token.rotate_now (another key gives the chain another base).
$rSrc = (string) file_get_contents(MAIN_HOME . 'Public/Controllers/Admin/Ajax/MiscAjaxController.php');
$rBody = substr($rSrc, (int) strpos($rSrc, 'function saveActivationKey'), 3000);
$this->assertMatchesRegularExpression('/\$rOld = .*self::writeKey\(\$rPath, \$rKey\).*self::keyAccepted\(\$rAskServer\).*\$rOld !== \$rKey \? ClusterOverview::rotateAll\(null\) : null;/s', $rBody);
// A key the licence server rejects on MAIN puts back the key it replaced,
// so a working panel does not end up unlicensed over a bad paste.
$this->assertMatchesRegularExpression('/if \(!self::keyAccepted\(\$rAskServer\)\) \{\s*\$rRestored = \$rAskServer && \$rOld !== \'\' && \$rOld !== \$rKey\s*&& self::writeKey\(\$rPath, \$rOld\);/', $rBody);
// The key is replaced whole: a temp file beside it, renamed over it.
$rWrite = substr($rSrc, (int) strpos($rSrc, 'function writeKey'), 800);
$this->assertMatchesRegularExpression('/tempnam\(dirname\(\$rPath\).*rename\(\$rTmp, \$rPath\)/s', $rWrite);
// Acceptance on MAIN asks the licence server now (XC_VM::license_check);
// a node, an unknown role or an older extension keep LicenseGate.
$rAsks = substr($rSrc, (int) strpos($rSrc, 'function asksTheServer'), 300);
$this->assertMatchesRegularExpression('/return NodeRole::isMain\(\) && class_exists\(\'XC_VM\'\) && method_exists\(\'XC_VM\', \'license_check\'\);/', $rAsks);
$rCheck = substr($rSrc, (int) strpos($rSrc, 'function keyAccepted'), 300);
$this->assertMatchesRegularExpression('/return \$rAskServer \? \(bool\) \\\\XC_VM::license_check\(\) : LicenseGate::licensed\(\);/', $rCheck);
}
public function testThePagesActionNeedsNoServer(): void {
$this->node(2, 'active', NodeRegistry::FLOW_COMMANDS);
$rFlash = ClusterAdmin::act($this->rCrypto, ['cluster_action' => 'rotate_all'], [], 1, [], 7);
$this->assertSame('success', $rFlash['type']);
$this->assertSame('cluster_rotate_all_done', $rFlash['message']);
$this->assertSame(['{QUEUED}' => '1', '{SKIPPED}' => '0', '{FAILED}' => '0'], $rFlash['vars']);
$this->assertSame('success', ClusterAdmin::act($this->rCrypto, ['cluster_action' => 'rotate_all'], [], 1, [], 7)['type'], 'queued again: it replaces the one still waiting');
$this->rDb->query('SELECT COUNT(*) AS `n` FROM `cluster_commands`;');
$this->assertSame(1, (int) $this->rDb->get_row()['n']);
$this->node(3, 'active', NodeRegistry::FLOW_TELEMETRY);
$this->rDb->query("UPDATE `cluster_nodes` SET `state` = 'revoked' WHERE `server_id` = 2;");
$this->assertSame(['info', ['{QUEUED}' => '0', '{SKIPPED}' => '1', '{FAILED}' => '0']], array_values(array_intersect_key(ClusterAdmin::act($this->rCrypto, ['cluster_action' => 'rotate_all'], [], 1, [], 7), ['type' => 1, 'vars' => 1])), 'none took it');
}
// ── The pages ───────────────────────────────────────────────────────
public function testThePagesCarryTheActionsAndEveryStringExists(): void {
$rRoot = MAIN_HOME;
$rView = (string) file_get_contents($rRoot . 'Public/Views/admin/partials/cluster_section.php');
$rServers = (string) file_get_contents($rRoot . 'Public/Views/admin/servers.php');
$this->assertStringContainsString('value="rotate_all"', $rView);
$this->assertStringContainsString('id="node-<?= (int) $rNode[\'server_id\']; ?>"', $rView, 'the servers list links to a node\'s row');
foreach (['mode_up', 'mode_down', 'code', 'rotate_now'] as $rAction) {
$this->assertMatchesRegularExpression('#action="cluster_nodes">.*value="' . $rAction . '"#', $rServers, $rAction . ' posts to the Cluster Nodes page');
}
$this->assertMatchesRegularExpression('/href="#node-/', $rServers, 'the servers list links to the on-page node anchor');
$this->assertStringNotContainsString('cluster_nodes#node-', $rServers, 'the row links use the on-page anchor, not the retired cluster_nodes route');
$this->assertStringContainsString('cluster:reenrol <?= (int) $rServer[\'id\']; ?>', $rServers, 're-enrol shows the SSH command');
$rEn = (string) file_get_contents($rRoot . 'Core/Localization/lang/en.ini');
preg_match_all("/language::get\('(cluster_[a-z0-9_]+)'/", $rView . $rServers, $rKeys);
preg_match_all("/'key' => '(cluster_[a-z0-9_]+)'|'message' => '(cluster_rotate_all_done)'/", (string) file_get_contents($rRoot . 'Domain/Cluster/ClusterOverview.php') . (string) file_get_contents($rRoot . 'Domain/Cluster/ClusterAdmin.php'), $rMore);
foreach (array_unique(array_filter([...$rKeys[1], ...$rMore[1], ...$rMore[2]])) as $rKey) {
$this->assertMatchesRegularExpression('/^' . preg_quote($rKey, '/') . ' = /m', $rEn, $rKey . ' is in en.ini');
}
}
}