2599 lines
95 KiB
TypeScript
2599 lines
95 KiB
TypeScript
|
|
import { afterEach, describe, it } from 'node:test';
|
||
|
|
import { strict as assert } from 'node:assert';
|
||
|
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||
|
|
import * as fs from 'node:fs';
|
||
|
|
import * as http from 'node:http';
|
||
|
|
import * as net from 'node:net';
|
||
|
|
import * as os from 'node:os';
|
||
|
|
import * as path from 'node:path';
|
||
|
|
import { connect as natsConnect, type NatsConnection } from 'nats';
|
||
|
|
import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js';
|
||
|
|
import { hiveCastLoginCommand, hiveCastLogoutCommand } from '../../src/commands/auth.js';
|
||
|
|
import { readHiveCastLinkStatus, removeHiveCastHostLink, saveHiveCastHostLink } from '../../src/utils/hivecast-link-store.js';
|
||
|
|
|
||
|
|
describe('FP-30 mx auth session commands', () => {
|
||
|
|
const tempDirs: string[] = [];
|
||
|
|
const restoreEnvFns: Array<() => void> = [];
|
||
|
|
const TEST_LOCAL_OWNER_ROOT = 'local-test-owner-root';
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
while (restoreEnvFns.length > 0) {
|
||
|
|
const restore = restoreEnvFns.pop();
|
||
|
|
if (restore) {
|
||
|
|
restore();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
while (tempDirs.length > 0) {
|
||
|
|
const dir = tempDirs.pop();
|
||
|
|
if (dir) {
|
||
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
function useMatrixHome(matrixHome: string): void {
|
||
|
|
const previousMatrixHome = process.env.MATRIX_HOME;
|
||
|
|
const previousHostHome = process.env.MATRIX_HOST_HOME;
|
||
|
|
const previousCliConfig = process.env.MATRIX_CLI_CONFIG;
|
||
|
|
const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY;
|
||
|
|
const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL;
|
||
|
|
const previousHivecastRegistry = process.env.HIVECAST_PACKAGE_REGISTRY;
|
||
|
|
const previousNpmUserConfig = process.env.NPM_CONFIG_USERCONFIG;
|
||
|
|
const previousNodeAuthToken = process.env.NODE_AUTH_TOKEN;
|
||
|
|
const previousNpmToken = process.env.NPM_TOKEN;
|
||
|
|
process.env.MATRIX_HOME = matrixHome;
|
||
|
|
delete process.env.MATRIX_HOST_HOME;
|
||
|
|
delete process.env.MATRIX_CLI_CONFIG;
|
||
|
|
delete process.env.MATRIX_PACKAGE_REGISTRY;
|
||
|
|
delete process.env.MATRIX_REGISTRY_URL;
|
||
|
|
delete process.env.HIVECAST_PACKAGE_REGISTRY;
|
||
|
|
delete process.env.NPM_CONFIG_USERCONFIG;
|
||
|
|
delete process.env.NODE_AUTH_TOKEN;
|
||
|
|
delete process.env.NPM_TOKEN;
|
||
|
|
restoreEnvFns.push(() => {
|
||
|
|
restoreEnv('MATRIX_HOME', previousMatrixHome);
|
||
|
|
restoreEnv('MATRIX_HOST_HOME', previousHostHome);
|
||
|
|
restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig);
|
||
|
|
restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry);
|
||
|
|
restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl);
|
||
|
|
restoreEnv('HIVECAST_PACKAGE_REGISTRY', previousHivecastRegistry);
|
||
|
|
restoreEnv('NPM_CONFIG_USERCONFIG', previousNpmUserConfig);
|
||
|
|
restoreEnv('NODE_AUTH_TOKEN', previousNodeAuthToken);
|
||
|
|
restoreEnv('NPM_TOKEN', previousNpmToken);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function restoreEnv(key: string, value: string | undefined): void {
|
||
|
|
if (value === undefined) {
|
||
|
|
delete process.env[key];
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
process.env[key] = value;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readRequestBody(req: http.IncomingMessage): Promise<string> {
|
||
|
|
const chunks: Buffer[] = [];
|
||
|
|
for await (const chunk of req) {
|
||
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
|
|
}
|
||
|
|
return Buffer.concat(chunks).toString('utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||
|
|
? value as Record<string, unknown>
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function optionalString(value: unknown): string | undefined {
|
||
|
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function installToken(installId: string): string {
|
||
|
|
return installId
|
||
|
|
.toUpperCase()
|
||
|
|
.replace(/[^A-Z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function installRootToken(installId: string): string {
|
||
|
|
return installId
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function localRootForInstallId(installId: string): string {
|
||
|
|
return `local-${installRootToken(installId)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function installScopeForInstallId(installId: string): string {
|
||
|
|
return installToken(installId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeInstallIdentity(matrixHome: string, installId: string): void {
|
||
|
|
const credentialsDir = path.join(matrixHome, 'credentials');
|
||
|
|
fs.mkdirSync(credentialsDir, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(credentialsDir, 'hivecast-install.json'), `${JSON.stringify({
|
||
|
|
version: 1,
|
||
|
|
installId,
|
||
|
|
createdAt: '2026-05-27T00:00:00.000Z',
|
||
|
|
updatedAt: '2026-05-27T00:00:00.000Z',
|
||
|
|
}, null, 2)}\n`, 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function readInstallIdentityId(matrixHome: string): string {
|
||
|
|
const identity = readJsonRecord(path.join(matrixHome, 'credentials', 'hivecast-install.json'));
|
||
|
|
const installId = optionalString(identity.installId);
|
||
|
|
assert.ok(installId);
|
||
|
|
return installId;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getAvailablePort(): Promise<number> {
|
||
|
|
return await new Promise<number>((resolve, reject) => {
|
||
|
|
const server = net.createServer();
|
||
|
|
server.once('error', reject);
|
||
|
|
server.listen(0, '127.0.0.1', () => {
|
||
|
|
const address = server.address();
|
||
|
|
if (!address || typeof address === 'string') {
|
||
|
|
server.close(() => reject(new Error('Failed to allocate TCP port')));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const port = address.port;
|
||
|
|
server.close((err) => (err ? reject(err) : resolve(port)));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForTcpPort(port: number): Promise<void> {
|
||
|
|
const deadline = Date.now() + 10_000;
|
||
|
|
while (Date.now() < deadline) {
|
||
|
|
const open = await new Promise<boolean>((resolve) => {
|
||
|
|
const socket = net.connect({ host: '127.0.0.1', port });
|
||
|
|
socket.once('connect', () => {
|
||
|
|
socket.destroy();
|
||
|
|
resolve(true);
|
||
|
|
});
|
||
|
|
socket.once('error', () => {
|
||
|
|
socket.destroy();
|
||
|
|
resolve(false);
|
||
|
|
});
|
||
|
|
socket.setTimeout(500, () => {
|
||
|
|
socket.destroy();
|
||
|
|
resolve(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (open) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
|
|
}
|
||
|
|
throw new Error(`Timed out waiting for nats-server on port ${port}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveNatsServerBinary(): string {
|
||
|
|
const binaryName = process.platform === 'win32' ? 'nats-server.exe' : 'nats-server';
|
||
|
|
const candidates = [
|
||
|
|
process.env.NATS_SERVER_BINARY,
|
||
|
|
path.resolve(process.cwd(), 'projects', 'nats-server', binaryName),
|
||
|
|
path.resolve(process.cwd(), '..', '..', '..', 'nats-server', binaryName),
|
||
|
|
path.resolve(process.cwd(), '..', 'hivecast', 'dist', 'bin', binaryName),
|
||
|
|
...((process.env.PATH ?? '').split(path.delimiter).map((entry) => path.join(entry, binaryName))),
|
||
|
|
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||
|
|
const found = candidates.find((candidate) => fs.existsSync(candidate));
|
||
|
|
if (!found) {
|
||
|
|
throw new Error('nats-server binary is required for HiveCast running-Host reload proof');
|
||
|
|
}
|
||
|
|
return found;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function stopProcess(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||
|
|
if (child.exitCode !== null) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
const timeout = setTimeout(() => {
|
||
|
|
if (child.exitCode === null) {
|
||
|
|
child.kill('SIGKILL');
|
||
|
|
}
|
||
|
|
}, 2_000);
|
||
|
|
child.once('exit', () => {
|
||
|
|
clearTimeout(timeout);
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
child.kill('SIGTERM');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withNatsServer<T>(run: (natsUrl: string) => Promise<T>): Promise<T> {
|
||
|
|
const port = await getAvailablePort();
|
||
|
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-nats-'));
|
||
|
|
tempDirs.push(dataDir);
|
||
|
|
const configPath = path.join(dataDir, 'nats-server.conf');
|
||
|
|
fs.writeFileSync(configPath, [
|
||
|
|
'host: "127.0.0.1"',
|
||
|
|
`port: ${port}`,
|
||
|
|
'',
|
||
|
|
].join('\n'), 'utf8');
|
||
|
|
const child = spawn(resolveNatsServerBinary(), ['-c', configPath], {
|
||
|
|
stdio: 'pipe',
|
||
|
|
env: process.env,
|
||
|
|
});
|
||
|
|
let stderr = '';
|
||
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
||
|
|
stderr += chunk.toString('utf8');
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
await Promise.race([
|
||
|
|
waitForTcpPort(port),
|
||
|
|
new Promise<never>((_resolve, reject) => {
|
||
|
|
child.once('exit', (code) => {
|
||
|
|
reject(new Error(`nats-server exited early (${code}): ${stderr}`));
|
||
|
|
});
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
return await run(`nats://127.0.0.1:${port}`);
|
||
|
|
} finally {
|
||
|
|
await stopProcess(child);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeHostStatusFromConfig(matrixHome: string, natsUrl: string, previousPid = process.pid): void {
|
||
|
|
const hostConfig = asRecord(JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')));
|
||
|
|
assert.ok(hostConfig);
|
||
|
|
const transport = asRecord(hostConfig.transport);
|
||
|
|
assert.ok(transport);
|
||
|
|
const nats = asRecord(transport.nats);
|
||
|
|
const root = optionalString(transport.root);
|
||
|
|
assert.ok(root);
|
||
|
|
const authorityRoot = optionalString(transport.authorityRoot) ?? root;
|
||
|
|
const addressRoot = optionalString(transport.addressRoot) ?? authorityRoot;
|
||
|
|
const wsUrl = optionalString(nats?.wsUrl);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.status.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostStatus',
|
||
|
|
version: 1,
|
||
|
|
status: 'running',
|
||
|
|
root,
|
||
|
|
authorityRoot,
|
||
|
|
addressRoot,
|
||
|
|
pid: previousPid,
|
||
|
|
home: matrixHome,
|
||
|
|
supervisorMount: 'host.supervisor.PID-TEST',
|
||
|
|
startedAt: '2026-04-28T00:00:00.000Z',
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
http: {
|
||
|
|
host: '127.0.0.1',
|
||
|
|
port: 3100,
|
||
|
|
origin: 'http://127.0.0.1:3100',
|
||
|
|
},
|
||
|
|
transport: {
|
||
|
|
root,
|
||
|
|
authorityRoot,
|
||
|
|
addressRoot,
|
||
|
|
...(optionalString(transport.spaceId) ? { spaceId: optionalString(transport.spaceId) } : {}),
|
||
|
|
...(optionalString(transport.routeKey) ? { routeKey: optionalString(transport.routeKey) } : {}),
|
||
|
|
...(optionalString(transport.publicNamespace)
|
||
|
|
? { publicNamespace: optionalString(transport.publicNamespace) }
|
||
|
|
: {}),
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: natsUrl,
|
||
|
|
...(wsUrl ? { wsUrl } : {}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`, 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function testWsUrlForNatsUrl(natsUrl: string): string {
|
||
|
|
const parsed = new URL(natsUrl);
|
||
|
|
const port = Number.parseInt(parsed.port, 10);
|
||
|
|
assert.equal(Number.isInteger(port), true);
|
||
|
|
return `ws://127.0.0.1:${port + 1}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeLocalOwnerHostStatus(
|
||
|
|
matrixHome: string,
|
||
|
|
natsUrl: string,
|
||
|
|
root = TEST_LOCAL_OWNER_ROOT,
|
||
|
|
): void {
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
const wsUrl = testWsUrlForNatsUrl(natsUrl);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.status.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostStatus',
|
||
|
|
version: 1,
|
||
|
|
status: 'running',
|
||
|
|
root,
|
||
|
|
authorityRoot: root,
|
||
|
|
addressRoot: root,
|
||
|
|
pid: process.pid,
|
||
|
|
home: matrixHome,
|
||
|
|
supervisorMount: 'host.supervisor.PID-TEST',
|
||
|
|
startedAt: '2026-04-28T00:00:00.000Z',
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
http: {
|
||
|
|
host: '127.0.0.1',
|
||
|
|
port: 3100,
|
||
|
|
origin: 'http://127.0.0.1:3100',
|
||
|
|
},
|
||
|
|
transport: {
|
||
|
|
root,
|
||
|
|
authorityRoot: root,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: natsUrl,
|
||
|
|
wsUrl,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`, 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function readJsonRecord(filePath: string): Record<string, unknown> {
|
||
|
|
const parsed = asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
||
|
|
assert.ok(parsed, `Expected JSON object at ${filePath}`);
|
||
|
|
return parsed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertHostConfigRoot(
|
||
|
|
matrixHome: string,
|
||
|
|
expectedRoot: string,
|
||
|
|
expected: {
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly spaceId?: string;
|
||
|
|
} = {},
|
||
|
|
): void {
|
||
|
|
const hostConfig = readJsonRecord(path.join(matrixHome, 'host.json'));
|
||
|
|
const transport = asRecord(hostConfig.transport);
|
||
|
|
assert.ok(transport);
|
||
|
|
const root = optionalString(transport.root);
|
||
|
|
const authorityRoot = optionalString(transport.authorityRoot) ?? root;
|
||
|
|
const addressRoot = optionalString(transport.addressRoot) ?? authorityRoot;
|
||
|
|
assert.equal(root, expectedRoot);
|
||
|
|
assert.equal(authorityRoot, expectedRoot);
|
||
|
|
assert.equal(addressRoot, expectedRoot);
|
||
|
|
assert.equal(transport.routeKey, expected.routeKey);
|
||
|
|
assert.equal(transport.publicNamespace, expected.publicNamespace);
|
||
|
|
assert.equal(transport.spaceId, expected.spaceId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertHostStatusRoot(
|
||
|
|
matrixHome: string,
|
||
|
|
expectedRoot: string,
|
||
|
|
expected: {
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly spaceId?: string;
|
||
|
|
} = {},
|
||
|
|
): void {
|
||
|
|
const status = readJsonRecord(path.join(matrixHome, 'host.status.json'));
|
||
|
|
const transport = asRecord(status.transport);
|
||
|
|
assert.ok(transport);
|
||
|
|
assert.equal(status.root, expectedRoot);
|
||
|
|
assert.equal(status.authorityRoot, expectedRoot);
|
||
|
|
assert.equal(status.addressRoot, expectedRoot);
|
||
|
|
assert.equal(transport.root, expectedRoot);
|
||
|
|
assert.equal(transport.authorityRoot, expectedRoot);
|
||
|
|
assert.equal(transport.addressRoot, expectedRoot);
|
||
|
|
assert.equal(transport.routeKey, expected.routeKey);
|
||
|
|
assert.equal(transport.publicNamespace, expected.publicNamespace);
|
||
|
|
assert.equal(transport.spaceId, expected.spaceId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withReloadSupervisor<T>(
|
||
|
|
natsUrl: string,
|
||
|
|
matrixHome: string,
|
||
|
|
run: (reloadCount: () => number) => Promise<T>,
|
||
|
|
onReload?: (reloadCount: number) => void,
|
||
|
|
): Promise<T> {
|
||
|
|
const nc: NatsConnection = await natsConnect({ servers: [natsUrl], name: 'mx-cli-reload-proof' });
|
||
|
|
const sub = nc.subscribe('>');
|
||
|
|
let reloads = 0;
|
||
|
|
const loop = (async () => {
|
||
|
|
for await (const msg of sub) {
|
||
|
|
const decoded = asRecord(JSON.parse(new TextDecoder().decode(msg.data) || '{}'));
|
||
|
|
if (decoded?.op !== 'config.reload') {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
reloads += 1;
|
||
|
|
onReload?.(reloads);
|
||
|
|
writeHostStatusFromConfig(matrixHome, natsUrl);
|
||
|
|
const replyTo = optionalString(decoded.replyTo) ?? msg.reply;
|
||
|
|
if (replyTo) {
|
||
|
|
nc.publish(replyTo, new TextEncoder().encode(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
correlationId: optionalString(decoded.correlationId),
|
||
|
|
result: { ok: true, status: 'reloading' },
|
||
|
|
})));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
try {
|
||
|
|
return await run(() => reloads);
|
||
|
|
} finally {
|
||
|
|
sub.unsubscribe();
|
||
|
|
await nc.drain();
|
||
|
|
await loop.catch(() => undefined);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withTokenExchangeServer<T>(
|
||
|
|
handler: (body: Record<string, unknown>) => Record<string, unknown>,
|
||
|
|
run: (cloudUrl: string) => Promise<T>,
|
||
|
|
): Promise<T> {
|
||
|
|
const server = http.createServer(async (req, res) => {
|
||
|
|
if (req.method !== 'POST' || req.url !== '/api/token-exchange') {
|
||
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const body = JSON.parse(await readRequestBody(req)) as Record<string, unknown>;
|
||
|
|
const payload = handler(body);
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify(payload));
|
||
|
|
});
|
||
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
|
|
const address = server.address();
|
||
|
|
assert(address && typeof address === 'object');
|
||
|
|
try {
|
||
|
|
return await run(`http://127.0.0.1:${address.port}`);
|
||
|
|
} finally {
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
server.close((err) => {
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withPairFlowServer<T>(
|
||
|
|
config: {
|
||
|
|
readonly pairRequestId: string;
|
||
|
|
readonly approvalCode: string;
|
||
|
|
readonly exchange: Record<string, unknown>;
|
||
|
|
readonly expectedRouteKey?: string;
|
||
|
|
readonly autoApprove?: boolean;
|
||
|
|
},
|
||
|
|
run: (
|
||
|
|
cloudUrl: string,
|
||
|
|
controls: {
|
||
|
|
readonly getLastLocalReturnUrl: () => string | null;
|
||
|
|
readonly getStartBody: () => Record<string, unknown> | null;
|
||
|
|
},
|
||
|
|
) => Promise<T>,
|
||
|
|
): Promise<T> {
|
||
|
|
let origin = '';
|
||
|
|
let lastLocalReturnUrl: string | null = null;
|
||
|
|
let startBody: Record<string, unknown> | null = null;
|
||
|
|
const server = http.createServer(async (req, res) => {
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/pair/start') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req) || '{}') as Record<string, unknown>;
|
||
|
|
startBody = body;
|
||
|
|
if (config.expectedRouteKey !== undefined) {
|
||
|
|
assert.equal(body.routeKey, config.expectedRouteKey);
|
||
|
|
}
|
||
|
|
if (typeof body.localReturnUrl === 'string') {
|
||
|
|
lastLocalReturnUrl = body.localReturnUrl;
|
||
|
|
}
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
pairRequestId: config.pairRequestId,
|
||
|
|
approvalUrl: `${origin}/_auth/pair/${config.pairRequestId}`,
|
||
|
|
expiresIn: 600,
|
||
|
|
}));
|
||
|
|
if (config.autoApprove && lastLocalReturnUrl) {
|
||
|
|
const nonce = typeof body.nonce === 'string' ? body.nonce : '';
|
||
|
|
setImmediate(() => {
|
||
|
|
void fetch(`${lastLocalReturnUrl}?pairRequestId=${config.pairRequestId}&approvalCode=${config.approvalCode}&nonce=${encodeURIComponent(nonce)}`);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/pair/exchange') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req)) as Record<string, unknown>;
|
||
|
|
assert.equal(body.pairRequestId, config.pairRequestId);
|
||
|
|
assert.equal(body.approvalCode, config.approvalCode);
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
...config.exchange,
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
||
|
|
});
|
||
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
|
|
const address = server.address();
|
||
|
|
assert(address && typeof address === 'object');
|
||
|
|
origin = `http://127.0.0.1:${address.port}`;
|
||
|
|
try {
|
||
|
|
return await run(origin, {
|
||
|
|
getLastLocalReturnUrl: () => lastLocalReturnUrl,
|
||
|
|
getStartBody: () => startBody,
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
server.close((err) => {
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withDeviceFlowServer<T>(
|
||
|
|
run: (cloudUrl: string) => Promise<T>,
|
||
|
|
config: { readonly authorityCoordinator?: boolean } = {},
|
||
|
|
): Promise<T> {
|
||
|
|
const server = http.createServer(async (req, res) => {
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/device/start') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req) || '{}') as Record<string, unknown>;
|
||
|
|
assert.equal(body.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(body.hostName, 'test-host');
|
||
|
|
assert.equal(body.authorityCoordinator, false);
|
||
|
|
assert.equal(Object.prototype.hasOwnProperty.call(body, 'name'), false);
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
deviceCode: 'DEVICE-123',
|
||
|
|
userCode: 'K7P9-TQ2M',
|
||
|
|
verificationUri: 'https://hivecast.example.test/activate',
|
||
|
|
verificationUriComplete: 'https://hivecast.example.test/activate?user_code=K7P9-TQ2M',
|
||
|
|
expiresIn: 600,
|
||
|
|
interval: 1,
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/device/poll') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req)) as Record<string, unknown>;
|
||
|
|
assert.equal(body.deviceCode, 'DEVICE-123');
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ ok: true, status: 'approved', interval: 1 }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/device/exchange') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req)) as Record<string, unknown>;
|
||
|
|
assert.equal(body.deviceCode, 'DEVICE-123');
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01DEVICE',
|
||
|
|
principalId: 'p_device',
|
||
|
|
hostName: 'test-host',
|
||
|
|
deviceSlug: 'test-host',
|
||
|
|
hostLink: {
|
||
|
|
id: 'host-link-device-01',
|
||
|
|
hostId: 'DEVICE-123',
|
||
|
|
hostName: 'test-host',
|
||
|
|
deviceSlug: 'test-host',
|
||
|
|
...(typeof config.authorityCoordinator === 'boolean' ? { authorityCoordinator: config.authorityCoordinator } : {}),
|
||
|
|
heartbeatToken: 'hlink_test_device_token',
|
||
|
|
status: 'active',
|
||
|
|
},
|
||
|
|
...(typeof config.authorityCoordinator === 'boolean' ? { authorityCoordinator: config.authorityCoordinator } : {}),
|
||
|
|
nats: {
|
||
|
|
url: 'nats://hivecast.example.test:4222',
|
||
|
|
wsUrl: 'wss://hivecast.example.test/nats-ws',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'device.jwt.token',
|
||
|
|
seed: 'SUDEVICESEED',
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/host-link/revoke') {
|
||
|
|
assert.equal(req.headers.authorization, 'Bearer hlink_test_device_token');
|
||
|
|
const body = JSON.parse(await readRequestBody(req)) as Record<string, unknown>;
|
||
|
|
assert.equal(body.hostLinkId, 'host-link-device-01');
|
||
|
|
assert.equal(body.hostId, 'DEVICE-123');
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
hostLink: {
|
||
|
|
id: 'host-link-device-01',
|
||
|
|
hostId: 'DEVICE-123',
|
||
|
|
status: 'revoked',
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
||
|
|
});
|
||
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
|
|
const address = server.address();
|
||
|
|
assert(address && typeof address === 'object');
|
||
|
|
try {
|
||
|
|
return await run(`http://127.0.0.1:${address.port}`);
|
||
|
|
} finally {
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
server.close((err) => {
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withDeviceNamespaceRequiredServer<T>(
|
||
|
|
run: (cloudUrl: string) => Promise<T>,
|
||
|
|
): Promise<T> {
|
||
|
|
const server = http.createServer(async (req, res) => {
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/device/start') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req) || '{}') as Record<string, unknown>;
|
||
|
|
assert.equal(body.routeKey, 'owned-key');
|
||
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: true,
|
||
|
|
deviceCode: 'DEVICE-OWNED',
|
||
|
|
userCode: 'K7P9-OWND',
|
||
|
|
verificationUri: 'https://hivecast.example.test/activate',
|
||
|
|
verificationUriComplete: 'https://hivecast.example.test/activate?user_code=K7P9-OWND',
|
||
|
|
expiresIn: 600,
|
||
|
|
interval: 1,
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/device/poll') {
|
||
|
|
const body = JSON.parse(await readRequestBody(req)) as Record<string, unknown>;
|
||
|
|
assert.equal(body.deviceCode, 'DEVICE-OWNED');
|
||
|
|
res.writeHead(409, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
ok: false,
|
||
|
|
error: 'namespace_required',
|
||
|
|
status: 'namespace_required',
|
||
|
|
setupUrl: '/apps/web/#setup?redirect=%2Factivate%3Fuser_code%3DK7P9-OWND',
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && req.url === '/_auth/device/exchange') {
|
||
|
|
res.writeHead(500, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: 'exchange should not be reached' }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
||
|
|
});
|
||
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
|
|
const address = server.address();
|
||
|
|
assert(address && typeof address === 'object');
|
||
|
|
try {
|
||
|
|
return await run(`http://127.0.0.1:${address.port}`);
|
||
|
|
} finally {
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
server.close((err) => {
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
it('supports login, whoami, and logout lifecycle', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-auth-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const credentialsFile = path.join(root, '.matrix', 'credentials.json');
|
||
|
|
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--username',
|
||
|
|
'alice',
|
||
|
|
'--token',
|
||
|
|
'token-123',
|
||
|
|
'--registry',
|
||
|
|
'https://pkg.matrix.dev',
|
||
|
|
'--credentials-file',
|
||
|
|
credentialsFile,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
|
||
|
|
const whoamiAfterLogin = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--registry',
|
||
|
|
'https://pkg.matrix.dev',
|
||
|
|
'--credentials-file',
|
||
|
|
credentialsFile,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiAfterLogin, 0);
|
||
|
|
const whoamiPayload = parseLastJson(whoamiAfterLogin.logs) as { authenticated: boolean; username?: string };
|
||
|
|
assert.equal(whoamiPayload.authenticated, true);
|
||
|
|
assert.equal(whoamiPayload.username, 'alice');
|
||
|
|
|
||
|
|
const logoutResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'logout',
|
||
|
|
'--registry',
|
||
|
|
'https://pkg.matrix.dev',
|
||
|
|
'--credentials-file',
|
||
|
|
credentialsFile,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(logoutResult, 0);
|
||
|
|
|
||
|
|
const whoamiAfterLogout = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--registry',
|
||
|
|
'https://pkg.matrix.dev',
|
||
|
|
'--credentials-file',
|
||
|
|
credentialsFile,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiAfterLogout, 0);
|
||
|
|
const afterLogoutPayload = parseLastJson(whoamiAfterLogout.logs) as { authenticated: boolean };
|
||
|
|
assert.equal(afterLogoutPayload.authenticated, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reads npm auth token from configured .npmrc for whoami', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-npmrc-auth-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
useMatrixHome(path.join(root, '.matrix'));
|
||
|
|
const registry = 'https://registry.example.test/api/packages/open-matrix/npm/';
|
||
|
|
const npmrcPath = path.join(root, '.npmrc');
|
||
|
|
fs.writeFileSync(
|
||
|
|
npmrcPath,
|
||
|
|
'//registry.example.test/api/packages/open-matrix/npm/:_authToken=test-token-from-npmrc\n',
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
process.env.NPM_CONFIG_USERCONFIG = npmrcPath;
|
||
|
|
|
||
|
|
const whoamiResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--registry',
|
||
|
|
registry,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiResult, 0);
|
||
|
|
const payload = parseLastJson(whoamiResult.logs) as {
|
||
|
|
authenticated: boolean;
|
||
|
|
username?: string;
|
||
|
|
registry: string;
|
||
|
|
};
|
||
|
|
assert.equal(payload.authenticated, true);
|
||
|
|
assert.equal(payload.username, 'npm-token');
|
||
|
|
assert.equal(payload.registry, registry);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not read home .npmrc when NPM_CONFIG_USERCONFIG points at an explicit file', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-explicit-npmrc-auth-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const homeDir = path.join(root, 'home');
|
||
|
|
fs.mkdirSync(homeDir, { recursive: true });
|
||
|
|
useMatrixHome(path.join(root, '.matrix'));
|
||
|
|
const previousHome = process.env.HOME;
|
||
|
|
const previousUserConfig = process.env.NPM_CONFIG_USERCONFIG;
|
||
|
|
restoreEnvFns.push(() => {
|
||
|
|
restoreEnv('HOME', previousHome);
|
||
|
|
restoreEnv('NPM_CONFIG_USERCONFIG', previousUserConfig);
|
||
|
|
});
|
||
|
|
process.env.HOME = homeDir;
|
||
|
|
const registry = 'https://registry.example.test/api/packages/open-matrix/npm/';
|
||
|
|
fs.writeFileSync(
|
||
|
|
path.join(homeDir, '.npmrc'),
|
||
|
|
'//registry.example.test/api/packages/open-matrix/npm/:_authToken=poisoned-home-token\n',
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
const explicitNpmrcPath = path.join(root, 'empty.npmrc');
|
||
|
|
fs.writeFileSync(explicitNpmrcPath, '', 'utf8');
|
||
|
|
process.env.NPM_CONFIG_USERCONFIG = explicitNpmrcPath;
|
||
|
|
|
||
|
|
const whoamiResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--registry',
|
||
|
|
registry,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiResult, 0);
|
||
|
|
const payload = parseLastJson(whoamiResult.logs) as {
|
||
|
|
authenticated: boolean;
|
||
|
|
username?: string;
|
||
|
|
registry: string;
|
||
|
|
};
|
||
|
|
assert.equal(payload.authenticated, false);
|
||
|
|
assert.equal(payload.username, undefined);
|
||
|
|
assert.equal(payload.registry, registry);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses Matrix CLI config for the default registry', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-config-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
useMatrixHome(path.join(root, '.matrix'));
|
||
|
|
const configuredRegistry = 'http://127.0.0.1:4567/api/packages/open-matrix/npm/';
|
||
|
|
|
||
|
|
const defaultConfig = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'config',
|
||
|
|
'get',
|
||
|
|
'registry',
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(defaultConfig, 0);
|
||
|
|
const defaultPayload = parseLastJson(defaultConfig.logs) as { value: string; source: string };
|
||
|
|
assert.equal(defaultPayload.value, 'https://registry.hivecast.ai/api/packages/open-matrix/npm/');
|
||
|
|
assert.equal(defaultPayload.source, 'default');
|
||
|
|
|
||
|
|
const setConfig = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'config',
|
||
|
|
'set',
|
||
|
|
'registry',
|
||
|
|
configuredRegistry,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(setConfig, 0);
|
||
|
|
|
||
|
|
const listConfig = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'config',
|
||
|
|
'list',
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(listConfig, 0);
|
||
|
|
const listPayload = parseLastJson(listConfig.logs) as {
|
||
|
|
values: { registry?: string };
|
||
|
|
effective: { registry: string; registrySource: string };
|
||
|
|
};
|
||
|
|
assert.equal(listPayload.values.registry, configuredRegistry);
|
||
|
|
assert.equal(listPayload.effective.registry, configuredRegistry);
|
||
|
|
assert.equal(listPayload.effective.registrySource, 'config');
|
||
|
|
|
||
|
|
const credentialsFile = path.join(root, '.matrix', 'credentials.json');
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--username',
|
||
|
|
'alice',
|
||
|
|
'--token',
|
||
|
|
'token-123',
|
||
|
|
'--credentials-file',
|
||
|
|
credentialsFile,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const loginPayload = parseLastJson(loginResult.logs) as { registry: string };
|
||
|
|
assert.equal(loginPayload.registry, configuredRegistry);
|
||
|
|
|
||
|
|
const whoamiResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--credentials-file',
|
||
|
|
credentialsFile,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiResult, 0);
|
||
|
|
const whoamiPayload = parseLastJson(whoamiResult.logs) as { authenticated: boolean; registry: string };
|
||
|
|
assert.equal(whoamiPayload.authenticated, true);
|
||
|
|
assert.equal(whoamiPayload.registry, configuredRegistry);
|
||
|
|
|
||
|
|
const resetConfig = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'config',
|
||
|
|
'reset',
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(resetConfig, 0);
|
||
|
|
|
||
|
|
const afterReset = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'config',
|
||
|
|
'get',
|
||
|
|
'registry',
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(afterReset, 0);
|
||
|
|
const resetPayload = parseLastJson(afterReset.logs) as { value: string; source: string };
|
||
|
|
assert.equal(resetPayload.value, 'https://registry.hivecast.ai/api/packages/open-matrix/npm/');
|
||
|
|
assert.equal(resetPayload.source, 'default');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('prints a HiveCast pair approval URL before approval is available', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-url-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
|
||
|
|
await withPairFlowServer(
|
||
|
|
{
|
||
|
|
pairRequestId: 'PAIR-URL-123',
|
||
|
|
approvalCode: 'PAIR-URL-APPROVAL',
|
||
|
|
expectedRouteKey: 'hivecast-lab',
|
||
|
|
exchange: {
|
||
|
|
root: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
nats: {
|
||
|
|
url: 'wss://hivecast.example.test/nats-ws',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'unused.jwt.token',
|
||
|
|
seed: 'SUUNUSED',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async (cloudUrl, controls) => {
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--route-key',
|
||
|
|
'hivecast-lab',
|
||
|
|
'--no-wait',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const payload = parseLastJson(loginResult.logs) as {
|
||
|
|
mode: string;
|
||
|
|
linked: boolean;
|
||
|
|
setupUrl: string;
|
||
|
|
};
|
||
|
|
assert.equal(payload.mode, 'approval-required');
|
||
|
|
assert.equal(payload.linked, false);
|
||
|
|
const setupUrl = new URL(payload.setupUrl);
|
||
|
|
assert.equal(setupUrl.origin, cloudUrl);
|
||
|
|
assert.equal(setupUrl.pathname, '/_auth/pair/PAIR-URL-123');
|
||
|
|
const startBody = controls.getStartBody();
|
||
|
|
assert.ok(startBody);
|
||
|
|
assert.equal(startBody.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(startBody.authorityCoordinator, true);
|
||
|
|
assert.equal(controls.getLastLocalReturnUrl(), null);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('completes HiveCast setup exchange and stores local Host link state', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-link-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'embedded',
|
||
|
|
port: 45222,
|
||
|
|
wsPort: 45223,
|
||
|
|
dataDir: 'nats/test-local',
|
||
|
|
pidFile: 'nats/test-local/nats-server.pid',
|
||
|
|
binaryPath: 'bin/nats-server',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
await withTokenExchangeServer(
|
||
|
|
(body) => {
|
||
|
|
assert.equal(body.code, 'SETUP-123');
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01TEST',
|
||
|
|
principalId: 'p_test',
|
||
|
|
displayName: 'HiveCast Lab',
|
||
|
|
nats: {
|
||
|
|
url: 'wss://hivecast.example.test/nats-ws',
|
||
|
|
wsUrl: 'wss://hivecast.example.test/nats-ws',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'setup.jwt.token',
|
||
|
|
seed: 'SUFAKESEED',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async (cloudUrl) => {
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--setup-code',
|
||
|
|
'SETUP-123',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const loginPayload = parseLastJson(loginResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
authorityRoot: string;
|
||
|
|
routeKey: string;
|
||
|
|
matrixHome: string;
|
||
|
|
};
|
||
|
|
assert.equal(loginPayload.linked, true);
|
||
|
|
assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(loginPayload.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(loginPayload.matrixHome, matrixHome);
|
||
|
|
|
||
|
|
const linkPath = path.join(matrixHome, 'credentials', 'hivecast-link.json');
|
||
|
|
const natsPath = path.join(matrixHome, 'credentials', 'hivecast-nats.json');
|
||
|
|
const hostConfigPath = path.join(matrixHome, 'host.json');
|
||
|
|
assert.equal(fs.existsSync(linkPath), true);
|
||
|
|
assert.equal(fs.existsSync(natsPath), true);
|
||
|
|
assert.equal(fs.existsSync(hostConfigPath), true);
|
||
|
|
const natsCredentials = JSON.parse(fs.readFileSync(natsPath, 'utf8')) as {
|
||
|
|
jwt?: string;
|
||
|
|
seed?: string;
|
||
|
|
accountSeed?: string;
|
||
|
|
};
|
||
|
|
assert.equal(natsCredentials.jwt, 'setup.jwt.token');
|
||
|
|
assert.equal(natsCredentials.seed, 'SUFAKESEED');
|
||
|
|
assert.equal(Object.prototype.hasOwnProperty.call(natsCredentials, 'accountSeed'), false);
|
||
|
|
|
||
|
|
const hostConfig = JSON.parse(fs.readFileSync(hostConfigPath, 'utf8')) as {
|
||
|
|
transport: {
|
||
|
|
root: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
nats: {
|
||
|
|
mode: string;
|
||
|
|
url: string;
|
||
|
|
credentialsRef?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
hivecastLocalOwnerTransport?: {
|
||
|
|
root?: string;
|
||
|
|
nats?: {
|
||
|
|
mode?: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(hostConfig.transport.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(hostConfig.transport.authorityRoot, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(hostConfig.transport.nats.mode, 'embedded');
|
||
|
|
assert.equal(hostConfig.transport.nats.credentialsRef, undefined);
|
||
|
|
assert.equal(hostConfig.hivecastLocalOwnerTransport?.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(hostConfig.hivecastLocalOwnerTransport?.nats?.mode, 'embedded');
|
||
|
|
assert.equal(hostConfig.hivecastLocalOwnerTransport?.nats?.port, 45222);
|
||
|
|
assert.equal(hostConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 45223);
|
||
|
|
|
||
|
|
const whoamiResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--hivecast',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiResult, 0);
|
||
|
|
const whoamiPayload = parseLastJson(whoamiResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
authorityRoot: string;
|
||
|
|
routeKey: string;
|
||
|
|
publicNamespace: string;
|
||
|
|
principalId: string;
|
||
|
|
};
|
||
|
|
assert.equal(whoamiPayload.linked, true);
|
||
|
|
assert.equal(whoamiPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(whoamiPayload.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(whoamiPayload.publicNamespace, 'space.hivecast-lab');
|
||
|
|
assert.equal(whoamiPayload.principalId, 'p_test');
|
||
|
|
|
||
|
|
const statusResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'link-status',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(statusResult, 0);
|
||
|
|
const statusPayload = parseLastJson(statusResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
authorityRoot: string;
|
||
|
|
spaceId: string;
|
||
|
|
natsCredentialsPresent: boolean;
|
||
|
|
hostConfigPresent: boolean;
|
||
|
|
};
|
||
|
|
assert.equal(statusPayload.linked, true);
|
||
|
|
assert.equal(statusPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(statusPayload.spaceId, 'space_01TEST');
|
||
|
|
assert.equal(statusPayload.natsCredentialsPresent, true);
|
||
|
|
assert.equal(statusPayload.hostConfigPresent, true);
|
||
|
|
|
||
|
|
const logoutResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'logout',
|
||
|
|
'--hivecast',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(logoutResult, 0);
|
||
|
|
const logoutPayload = parseLastJson(logoutResult.logs) as { removed: boolean; hostConfigReset: boolean };
|
||
|
|
assert.equal(logoutPayload.removed, true);
|
||
|
|
assert.equal(logoutPayload.hostConfigReset, true);
|
||
|
|
assert.equal(fs.existsSync(linkPath), false);
|
||
|
|
assert.equal(fs.existsSync(natsPath), false);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials.json')), false);
|
||
|
|
const localOnlyHostConfig = JSON.parse(fs.readFileSync(hostConfigPath, 'utf8')) as {
|
||
|
|
transport: {
|
||
|
|
root: string;
|
||
|
|
authorityRoot?: string;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
nats: {
|
||
|
|
mode: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
dataDir?: string;
|
||
|
|
credentialsRef?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(localOnlyHostConfig.transport.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.authorityRoot, undefined);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.routeKey, undefined);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.publicNamespace, undefined);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.spaceId, undefined);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.nats.mode, 'embedded');
|
||
|
|
assert.equal(localOnlyHostConfig.transport.nats.port, 45222);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.nats.wsPort, 45223);
|
||
|
|
assert.equal(localOnlyHostConfig.transport.nats.dataDir, 'nats/test-local');
|
||
|
|
assert.equal(localOnlyHostConfig.transport.nats.credentialsRef, undefined);
|
||
|
|
|
||
|
|
const afterLogoutStatusResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'link-status',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(afterLogoutStatusResult, 0);
|
||
|
|
const afterLogoutStatus = parseLastJson(afterLogoutStatusResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
natsCredentialsPresent: boolean;
|
||
|
|
hostConfigLinked: boolean;
|
||
|
|
};
|
||
|
|
assert.equal(afterLogoutStatus.linked, false);
|
||
|
|
assert.equal(afterLogoutStatus.natsCredentialsPresent, false);
|
||
|
|
assert.equal(afterLogoutStatus.hostConfigLinked, false);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves the running local owner NATS ports when linking from a started Host', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-running-ports-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
const installId = 'install_running_ports';
|
||
|
|
writeInstallIdentity(matrixHome, installId);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'embedded',
|
||
|
|
port: 4222,
|
||
|
|
wsPort: 4223,
|
||
|
|
dataDir: 'nats/host-default',
|
||
|
|
pidFile: 'nats/host-default/nats-server.pid',
|
||
|
|
binaryPath: 'bin/nats-server',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.status.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostStatus',
|
||
|
|
version: 1,
|
||
|
|
status: 'running',
|
||
|
|
pid: process.pid,
|
||
|
|
home: matrixHome,
|
||
|
|
supervisorMount: `host.supervisor.PID-${process.pid}`,
|
||
|
|
startedAt: new Date().toISOString(),
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
http: {
|
||
|
|
host: '127.0.0.1',
|
||
|
|
port: 45100,
|
||
|
|
origin: 'http://127.0.0.1:45100',
|
||
|
|
},
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
authorityRoot: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:45222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:45223',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
const localScope = installScopeForInstallId(installId);
|
||
|
|
const localEdgeRuntimeId = `RUNTIME-HOST-${localScope}-EDGE`;
|
||
|
|
const staleLinkedRuntimeId = 'RUNTIME-HOST-STALE-LINKED-EDGE';
|
||
|
|
const staleLinkedChatRuntimeId = 'RUNTIME-HOST-STALE-LINKED-CHAT';
|
||
|
|
for (const runtimeId of [localEdgeRuntimeId, staleLinkedRuntimeId, staleLinkedChatRuntimeId]) {
|
||
|
|
const recordDir = path.join(matrixHome, 'runtimes', runtimeId);
|
||
|
|
fs.mkdirSync(recordDir, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(recordDir, 'runtime.json'), `${JSON.stringify({
|
||
|
|
runtimeId,
|
||
|
|
packageDir: path.join(matrixHome, 'packages', 'global', 'node_modules', '@open-matrix', 'matrix-edge'),
|
||
|
|
status: 'stopped',
|
||
|
|
startup: 'auto',
|
||
|
|
restart: 'always',
|
||
|
|
startedAt: new Date().toISOString(),
|
||
|
|
stoppedAt: new Date().toISOString(),
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
localMounts: ['edge'],
|
||
|
|
metadata: {},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'host_running_ports',
|
||
|
|
authorityRoot: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01RUNNINGPORTS',
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
natsCredentials: { accountSeed: 'SARUNNINGPORTS' },
|
||
|
|
});
|
||
|
|
const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
transport?: {
|
||
|
|
root?: string;
|
||
|
|
authorityRoot?: string;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
nats?: {
|
||
|
|
credentialsRef?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
hivecastLocalOwnerTransport?: {
|
||
|
|
nats?: {
|
||
|
|
mode?: string;
|
||
|
|
url?: string;
|
||
|
|
wsUrl?: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(linkedConfig.transport?.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(linkedConfig.transport?.authorityRoot, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(linkedConfig.transport?.routeKey, undefined);
|
||
|
|
assert.equal(linkedConfig.transport?.publicNamespace, undefined);
|
||
|
|
assert.equal(linkedConfig.transport?.spaceId, undefined);
|
||
|
|
assert.equal(linkedConfig.transport?.nats?.credentialsRef, undefined);
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.mode, 'external');
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.url, 'nats://127.0.0.1:45222');
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsUrl, 'ws://127.0.0.1:45223');
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.port, 45222);
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 45223);
|
||
|
|
const localEdgeAfterLink = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', localEdgeRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string };
|
||
|
|
assert.equal(localEdgeAfterLink.startup, 'auto');
|
||
|
|
assert.equal(localEdgeAfterLink.restart, 'always');
|
||
|
|
|
||
|
|
const removed = removeHiveCastHostLink(root, matrixHome);
|
||
|
|
assert.equal(removed.hostConfigReset, true);
|
||
|
|
const localConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
transport: {
|
||
|
|
authorityRoot?: string;
|
||
|
|
nats: {
|
||
|
|
mode?: string;
|
||
|
|
url?: string;
|
||
|
|
wsUrl?: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(localConfig.transport.authorityRoot, undefined);
|
||
|
|
assert.equal(localConfig.transport.nats.mode, 'external');
|
||
|
|
assert.equal(localConfig.transport.nats.url, 'nats://127.0.0.1:45222');
|
||
|
|
assert.equal(localConfig.transport.nats.wsUrl, 'ws://127.0.0.1:45223');
|
||
|
|
assert.equal(localConfig.transport.nats.port, 45222);
|
||
|
|
assert.equal(localConfig.transport.nats.wsPort, 45223);
|
||
|
|
const localEdgeAfterLogout = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', localEdgeRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string };
|
||
|
|
const staleLinkedAfterLogout = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', staleLinkedRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string };
|
||
|
|
const staleLinkedChatAfterLogout = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', staleLinkedChatRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string };
|
||
|
|
assert.equal(localEdgeAfterLogout.startup, 'auto');
|
||
|
|
assert.equal(localEdgeAfterLogout.restart, 'always');
|
||
|
|
assert.equal(staleLinkedAfterLogout.startup, 'manual');
|
||
|
|
assert.equal(staleLinkedAfterLogout.restart, 'never');
|
||
|
|
assert.equal(staleLinkedChatAfterLogout.startup, 'manual');
|
||
|
|
assert.equal(staleLinkedChatAfterLogout.restart, 'never');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('derives a fresh unlinked Device root from the stable install identity', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-fresh-local-root-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
const installId = 'install_fresh_device_root';
|
||
|
|
writeInstallIdentity(matrixHome, installId);
|
||
|
|
|
||
|
|
saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'host_fresh_local_root',
|
||
|
|
authorityRoot: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01FRESHLOCALROOT',
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
natsCredentials: { accountSeed: 'SAFRESHLOCALROOT' },
|
||
|
|
});
|
||
|
|
|
||
|
|
const hostConfig = readJsonRecord(path.join(matrixHome, 'host.json'));
|
||
|
|
const transport = asRecord(hostConfig.transport);
|
||
|
|
assert.ok(transport);
|
||
|
|
assert.equal(transport.root, localRootForInstallId(installId));
|
||
|
|
assert.equal(transport.authorityRoot, localRootForInstallId(installId));
|
||
|
|
assert.equal(transport.addressRoot, localRootForInstallId(installId));
|
||
|
|
assert.match(String(transport.root), /^local-install-fresh-device-root$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves local owner NATS ports from runtime records when device login links a stopped Host', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-stopped-ports-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
const installId = 'install_stopped_ports';
|
||
|
|
writeInstallIdentity(matrixHome, installId);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'embedded',
|
||
|
|
port: 4222,
|
||
|
|
wsPort: 4223,
|
||
|
|
dataDir: 'nats/host-default',
|
||
|
|
pidFile: 'nats/host-default/nats-server.pid',
|
||
|
|
binaryPath: 'bin/nats-server',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
const localScope = installScopeForInstallId(installId);
|
||
|
|
const localGatewayRuntimeId = `RUNTIME-HOST-${localScope}-GATEWAY`;
|
||
|
|
const recordDir = path.join(matrixHome, 'runtimes', localGatewayRuntimeId);
|
||
|
|
fs.mkdirSync(recordDir, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(recordDir, 'runtime.json'), `${JSON.stringify({
|
||
|
|
runtimeId: localGatewayRuntimeId,
|
||
|
|
packageDir: path.join(matrixHome, 'packages', 'system', '@open-matrix', 'system-gateway-http'),
|
||
|
|
status: 'stopped',
|
||
|
|
startup: 'auto',
|
||
|
|
restart: 'always',
|
||
|
|
startedAt: new Date().toISOString(),
|
||
|
|
stoppedAt: new Date().toISOString(),
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
localMounts: ['system.gateway.http'],
|
||
|
|
metadata: {
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
authorityRoot: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:46222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:46223',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
|
||
|
|
saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'host_stopped_ports',
|
||
|
|
authorityRoot: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01STOPPEDPORTS',
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
natsCredentials: { accountSeed: 'SASTOPPEDPORTS' },
|
||
|
|
});
|
||
|
|
const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
hivecastLocalOwnerTransport?: {
|
||
|
|
nats?: {
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.port, 46222);
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 46223);
|
||
|
|
|
||
|
|
const removed = removeHiveCastHostLink(root, matrixHome);
|
||
|
|
assert.equal(removed.hostConfigReset, true);
|
||
|
|
const localConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
transport: {
|
||
|
|
root?: string;
|
||
|
|
authorityRoot?: string;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
nats: {
|
||
|
|
mode?: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(localConfig.transport.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(localConfig.transport.authorityRoot, undefined);
|
||
|
|
assert.equal(localConfig.transport.routeKey, undefined);
|
||
|
|
assert.equal(localConfig.transport.publicNamespace, undefined);
|
||
|
|
assert.equal(localConfig.transport.spaceId, undefined);
|
||
|
|
assert.equal(localConfig.transport.nats.mode, 'external');
|
||
|
|
assert.equal(localConfig.transport.nats.url, 'nats://127.0.0.1:46222');
|
||
|
|
assert.equal(localConfig.transport.nats.wsUrl, 'ws://127.0.0.1:46223');
|
||
|
|
assert.equal(localConfig.transport.nats.port, 46222);
|
||
|
|
assert.equal(localConfig.transport.nats.wsPort, 46223);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves sibling local owner NATS ports from host config when linking a stopped Host', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-stopped-config-ports-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
authorityRoot: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:47222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:47223',
|
||
|
|
port: 47222,
|
||
|
|
wsPort: 47223,
|
||
|
|
dataDir: 'nats/host-default',
|
||
|
|
pidFile: 'nats/host-default/nats-server.pid',
|
||
|
|
binaryPath: 'bin/nats-server',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
|
||
|
|
saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'host_stopped_config_ports',
|
||
|
|
authorityRoot: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01STOPPEDCONFIGPORTS',
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
natsCredentials: { accountSeed: 'SASTOPPEDCONFIGPORTS' },
|
||
|
|
});
|
||
|
|
const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
hivecastLocalOwnerTransport?: {
|
||
|
|
nats?: {
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.port, 47222);
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 47223);
|
||
|
|
|
||
|
|
const removed = removeHiveCastHostLink(root, matrixHome);
|
||
|
|
assert.equal(removed.hostConfigReset, true);
|
||
|
|
const localConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
transport: {
|
||
|
|
root?: string;
|
||
|
|
authorityRoot?: string;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
nats: {
|
||
|
|
mode?: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
credentialsRef?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(localConfig.transport.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(localConfig.transport.authorityRoot, undefined);
|
||
|
|
assert.equal(localConfig.transport.routeKey, undefined);
|
||
|
|
assert.equal(localConfig.transport.publicNamespace, undefined);
|
||
|
|
assert.equal(localConfig.transport.spaceId, undefined);
|
||
|
|
assert.equal(localConfig.transport.nats.mode, 'external');
|
||
|
|
assert.equal(localConfig.transport.nats.url, 'nats://127.0.0.1:47222');
|
||
|
|
assert.equal(localConfig.transport.nats.wsUrl, 'ws://127.0.0.1:47223');
|
||
|
|
assert.equal(localConfig.transport.nats.port, 47222);
|
||
|
|
assert.equal(localConfig.transport.nats.wsPort, 47223);
|
||
|
|
assert.equal(localConfig.transport.nats.credentialsRef, undefined);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('moves authority-coordinator Host config onto the linked user authority root', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-linked-coordinator-root-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
const installId = 'install_42fab8eeedac46d6e348';
|
||
|
|
writeInstallIdentity(matrixHome, installId);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
authorityRoot: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:48222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:48223',
|
||
|
|
port: 48222,
|
||
|
|
wsPort: 48223,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
|
||
|
|
const installScope = installScopeForInstallId(installId);
|
||
|
|
const installSystemRuntimeId = `RUNTIME-HOST-${installScope}-SYSTEM`;
|
||
|
|
const installHostOpsRuntimeId = `RUNTIME-HOST-${installScope}-HOST-OPS`;
|
||
|
|
const localScope = 'LOCAL-STALE-HASH-SCOPE';
|
||
|
|
const localSystemRuntimeId = `RUNTIME-HOST-${localScope}-SYSTEM`;
|
||
|
|
const localHostOpsRuntimeId = `RUNTIME-HOST-${localScope}-HOST-OPS`;
|
||
|
|
for (const runtimeId of [
|
||
|
|
installSystemRuntimeId,
|
||
|
|
installHostOpsRuntimeId,
|
||
|
|
localSystemRuntimeId,
|
||
|
|
localHostOpsRuntimeId,
|
||
|
|
]) {
|
||
|
|
const recordDir = path.join(matrixHome, 'runtimes', runtimeId);
|
||
|
|
fs.mkdirSync(recordDir, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(recordDir, 'runtime.json'), `${JSON.stringify({
|
||
|
|
runtimeId,
|
||
|
|
packageDir: path.join(matrixHome, 'packages', 'global', 'node_modules', '@open-matrix', 'system'),
|
||
|
|
status: 'stopped',
|
||
|
|
startup: 'auto',
|
||
|
|
restart: 'always',
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
localMounts: ['system', 'system.registry', 'system.devices', 'system.runtimes'],
|
||
|
|
metadata: {},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'install_42fab8eeedac46d6e348',
|
||
|
|
authorityRoot: 'test-user-1',
|
||
|
|
routeKey: 'test-user-1',
|
||
|
|
publicNamespace: 'test-user-1',
|
||
|
|
spaceId: 'space_test_user_1',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: {
|
||
|
|
url: 'nats://dev-platform:4222',
|
||
|
|
wsUrl: 'wss://dev-platform/nats-ws',
|
||
|
|
},
|
||
|
|
natsCredentials: { accountSeed: 'SATESTUSER1' },
|
||
|
|
});
|
||
|
|
|
||
|
|
const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
transport?: {
|
||
|
|
root?: string;
|
||
|
|
authorityRoot?: string;
|
||
|
|
addressRoot?: string;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
nats?: {
|
||
|
|
mode?: string;
|
||
|
|
url?: string;
|
||
|
|
wsUrl?: string;
|
||
|
|
port?: number;
|
||
|
|
wsPort?: number;
|
||
|
|
credentialsRef?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
hivecastLocalOwnerTransport?: {
|
||
|
|
root?: string;
|
||
|
|
nats?: {
|
||
|
|
url?: string;
|
||
|
|
wsUrl?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
assert.equal(linkedConfig.transport?.root, 'test-user-1');
|
||
|
|
assert.equal(linkedConfig.transport?.authorityRoot, 'test-user-1');
|
||
|
|
assert.equal(linkedConfig.transport?.addressRoot, 'test-user-1');
|
||
|
|
assert.equal(linkedConfig.transport?.routeKey, 'test-user-1');
|
||
|
|
assert.equal(linkedConfig.transport?.publicNamespace, 'test-user-1');
|
||
|
|
assert.equal(linkedConfig.transport?.spaceId, 'space_test_user_1');
|
||
|
|
assert.equal(linkedConfig.transport?.nats?.mode, 'external');
|
||
|
|
assert.equal(linkedConfig.transport?.nats?.url, 'nats://dev-platform:4222');
|
||
|
|
assert.equal(linkedConfig.transport?.nats?.wsUrl, 'wss://dev-platform/nats-ws');
|
||
|
|
assert.equal(linkedConfig.transport?.nats?.port, undefined);
|
||
|
|
assert.equal(linkedConfig.transport?.nats?.wsPort, undefined);
|
||
|
|
assert.equal(
|
||
|
|
linkedConfig.transport?.nats?.credentialsRef,
|
||
|
|
`file:${path.join(matrixHome, 'credentials', 'hivecast-nats.json')}`,
|
||
|
|
);
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.root, TEST_LOCAL_OWNER_ROOT);
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.url, 'nats://127.0.0.1:48222');
|
||
|
|
assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsUrl, 'ws://127.0.0.1:48223');
|
||
|
|
|
||
|
|
const installSystemAfterLink = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', installSystemRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } };
|
||
|
|
assert.equal(installSystemAfterLink.startup, 'auto');
|
||
|
|
assert.equal(installSystemAfterLink.restart, 'always');
|
||
|
|
assert.equal(installSystemAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'active');
|
||
|
|
|
||
|
|
const localSystemAfterLink = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', localSystemRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } };
|
||
|
|
assert.equal(localSystemAfterLink.startup, 'manual');
|
||
|
|
assert.equal(localSystemAfterLink.restart, 'never');
|
||
|
|
assert.equal(localSystemAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'inactive');
|
||
|
|
|
||
|
|
const installHostOpsAfterLink = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', installHostOpsRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } };
|
||
|
|
assert.equal(installHostOpsAfterLink.startup, 'auto');
|
||
|
|
assert.equal(installHostOpsAfterLink.restart, 'always');
|
||
|
|
assert.equal(installHostOpsAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'active');
|
||
|
|
|
||
|
|
const localHostOpsAfterLink = JSON.parse(fs.readFileSync(
|
||
|
|
path.join(matrixHome, 'runtimes', localHostOpsRuntimeId, 'runtime.json'),
|
||
|
|
'utf8',
|
||
|
|
)) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } };
|
||
|
|
assert.equal(localHostOpsAfterLink.startup, 'manual');
|
||
|
|
assert.equal(localHostOpsAfterLink.restart, 'never');
|
||
|
|
assert.equal(localHostOpsAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'inactive');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses authority-coordinator links without an installed local Host transport', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-linked-coordinator-no-local-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'install_ws_only',
|
||
|
|
authorityRoot: 'test-user-1',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
natsCredentials: { jwt: 'device.jwt.token', seed: 'SUDEVICESEED' },
|
||
|
|
}),
|
||
|
|
/HIVECAST_LINK_LOCAL_TRANSPORT_MISSING/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not classify local owner authorityRoot as a HiveCast link', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-local-authority-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
authorityRoot: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'embedded',
|
||
|
|
port: 46222,
|
||
|
|
wsPort: 46223,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
|
||
|
|
const status = readHiveCastLinkStatus(root, matrixHome);
|
||
|
|
assert.equal(status.linked, false);
|
||
|
|
assert.equal(status.hostConfigLinked, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not classify sibling local owner NATS as a HiveCast link', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-local-sibling-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
fs.mkdirSync(matrixHome, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
authorityRoot: TEST_LOCAL_OWNER_ROOT,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:47222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:47223',
|
||
|
|
port: 47222,
|
||
|
|
wsPort: 47223,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`);
|
||
|
|
|
||
|
|
const status = readHiveCastLinkStatus(root, matrixHome);
|
||
|
|
assert.equal(status.linked, false);
|
||
|
|
assert.equal(status.hostConfigLinked, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('completes HiveCast device login and stores local Host link state', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-device-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withDeviceFlowServer(async (cloudUrl) => {
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--device',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--route-key',
|
||
|
|
'hivecast-lab',
|
||
|
|
'--name',
|
||
|
|
'test-host',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const loginPayload = parseLastJson(loginResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
hostLinkId: string;
|
||
|
|
hostId: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
routeKey: string;
|
||
|
|
publicNamespace: string;
|
||
|
|
spaceId: string;
|
||
|
|
hostName?: string;
|
||
|
|
deviceSlug?: string;
|
||
|
|
};
|
||
|
|
assert.equal(loginPayload.linked, true);
|
||
|
|
assert.equal(loginPayload.hostLinkId, 'host-link-device-01');
|
||
|
|
assert.equal(loginPayload.hostId, 'DEVICE-123');
|
||
|
|
assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(loginPayload.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(loginPayload.publicNamespace, 'space.hivecast-lab');
|
||
|
|
assert.equal(loginPayload.spaceId, 'space_01DEVICE');
|
||
|
|
assert.equal(loginPayload.hostName, 'test-host');
|
||
|
|
assert.equal(loginPayload.deviceSlug, 'test-host');
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), true);
|
||
|
|
const natsCredentials = JSON.parse(fs.readFileSync(path.join(matrixHome, 'credentials', 'hivecast-nats.json'), 'utf8')) as {
|
||
|
|
jwt?: string;
|
||
|
|
seed?: string;
|
||
|
|
accountSeed?: string;
|
||
|
|
};
|
||
|
|
assert.equal(natsCredentials.jwt, 'device.jwt.token');
|
||
|
|
assert.equal(natsCredentials.seed, 'SUDEVICESEED');
|
||
|
|
assert.equal(Object.prototype.hasOwnProperty.call(natsCredentials, 'accountSeed'), false);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'host.json')), true);
|
||
|
|
const hostConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as {
|
||
|
|
transport: {
|
||
|
|
root: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
nats?: {
|
||
|
|
credentialsRef?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const expectedLocalRoot = localRootForInstallId(readInstallIdentityId(matrixHome));
|
||
|
|
assert.equal(hostConfig.transport.root, expectedLocalRoot);
|
||
|
|
assert.equal(hostConfig.transport.authorityRoot, expectedLocalRoot);
|
||
|
|
assert.equal(hostConfig.transport.routeKey, undefined);
|
||
|
|
assert.equal(hostConfig.transport.publicNamespace, undefined);
|
||
|
|
assert.equal(hostConfig.transport.spaceId, undefined);
|
||
|
|
assert.equal(hostConfig.transport.nats?.credentialsRef, undefined);
|
||
|
|
|
||
|
|
const whoamiResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'whoami',
|
||
|
|
'--hivecast',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(whoamiResult, 0);
|
||
|
|
const whoamiPayload = parseLastJson(whoamiResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
hostLinkId: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
routeKey: string;
|
||
|
|
publicNamespace: string;
|
||
|
|
principalId: string;
|
||
|
|
hostName?: string;
|
||
|
|
deviceSlug?: string;
|
||
|
|
};
|
||
|
|
assert.equal(whoamiPayload.linked, true);
|
||
|
|
assert.equal(whoamiPayload.hostLinkId, 'host-link-device-01');
|
||
|
|
assert.equal(whoamiPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(whoamiPayload.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(whoamiPayload.publicNamespace, 'space.hivecast-lab');
|
||
|
|
assert.equal(whoamiPayload.principalId, 'p_device');
|
||
|
|
assert.equal(whoamiPayload.hostName, 'test-host');
|
||
|
|
assert.equal(whoamiPayload.deviceSlug, 'test-host');
|
||
|
|
|
||
|
|
const statusResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'link-status',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(statusResult, 0);
|
||
|
|
const statusPayload = parseLastJson(statusResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
hostLinkId: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
routeKey: string;
|
||
|
|
publicNamespace: string;
|
||
|
|
spaceId: string;
|
||
|
|
natsCredentialsPresent: boolean;
|
||
|
|
hostConfigLinked: boolean;
|
||
|
|
};
|
||
|
|
assert.equal(statusPayload.linked, true);
|
||
|
|
assert.equal(statusPayload.hostLinkId, 'host-link-device-01');
|
||
|
|
assert.equal(statusPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(statusPayload.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(statusPayload.publicNamespace, 'space.hivecast-lab');
|
||
|
|
assert.equal(statusPayload.spaceId, 'space_01DEVICE');
|
||
|
|
assert.equal(statusPayload.natsCredentialsPresent, true);
|
||
|
|
assert.equal(statusPayload.hostConfigLinked, true);
|
||
|
|
|
||
|
|
const logoutResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'logout',
|
||
|
|
'--hivecast',
|
||
|
|
'--revoke-cloud-link',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(logoutResult, 0);
|
||
|
|
const logoutPayload = parseLastJson(logoutResult.logs) as {
|
||
|
|
removed: boolean;
|
||
|
|
cloudRevoke?: {
|
||
|
|
attempted: boolean;
|
||
|
|
revoked: boolean;
|
||
|
|
};
|
||
|
|
hostConfigReset: boolean;
|
||
|
|
};
|
||
|
|
assert.equal(logoutPayload.removed, true);
|
||
|
|
assert.equal(logoutPayload.cloudRevoke?.attempted, true);
|
||
|
|
assert.equal(logoutPayload.cloudRevoke?.revoked, true);
|
||
|
|
assert.equal(logoutPayload.hostConfigReset, true);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('prints HiveCast device-code challenge without polling when no-wait is requested', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-device-no-wait-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withDeviceFlowServer(async (cloudUrl) => {
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--device',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--route-key',
|
||
|
|
'hivecast-lab',
|
||
|
|
'--name',
|
||
|
|
'test-host',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
'--no-wait',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const loginPayload = parseLastJson(loginResult.logs) as {
|
||
|
|
mode: string;
|
||
|
|
linked: boolean;
|
||
|
|
deviceCode: string;
|
||
|
|
userCode: string;
|
||
|
|
verificationUri: string;
|
||
|
|
verificationUriComplete: string;
|
||
|
|
setupUrl: string;
|
||
|
|
};
|
||
|
|
assert.equal(loginPayload.mode, 'approval-required');
|
||
|
|
assert.equal(loginPayload.linked, false);
|
||
|
|
assert.equal(loginPayload.deviceCode, 'DEVICE-123');
|
||
|
|
assert.equal(loginPayload.userCode, 'K7P9-TQ2M');
|
||
|
|
assert.equal(loginPayload.verificationUri, 'https://hivecast.example.test/activate');
|
||
|
|
assert.equal(loginPayload.verificationUriComplete, 'https://hivecast.example.test/activate?user_code=K7P9-TQ2M');
|
||
|
|
assert.equal(loginPayload.setupUrl, loginPayload.verificationUriComplete);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), false);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-nats.json')), false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('treats --headless as the product alias for HiveCast device-code login', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-headless-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withDeviceFlowServer(async (cloudUrl) => {
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--headless',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--route-key',
|
||
|
|
'hivecast-lab',
|
||
|
|
'--name',
|
||
|
|
'test-host',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const loginPayload = parseLastJson(loginResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
hostLinkId: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
};
|
||
|
|
assert.equal(loginPayload.linked, true);
|
||
|
|
assert.equal(loginPayload.hostLinkId, 'host-link-device-01');
|
||
|
|
assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps headless HiveCast device login on the linked authority root for an installed Host', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-headless-root-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withNatsServer(async (natsUrl) => {
|
||
|
|
writeLocalOwnerHostStatus(matrixHome, natsUrl, 'hivecast-headless-local-owner-root');
|
||
|
|
await withReloadSupervisor(natsUrl, matrixHome, async (reloadCount) => {
|
||
|
|
await withDeviceFlowServer(async (cloudUrl) => {
|
||
|
|
const loginResult = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--headless',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--route-key',
|
||
|
|
'hivecast-lab',
|
||
|
|
'--name',
|
||
|
|
'test-host',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(loginResult, 0);
|
||
|
|
const loginPayload = parseLastJson(loginResult.logs) as {
|
||
|
|
linked: boolean;
|
||
|
|
hostLinkId: string;
|
||
|
|
authorityRoot: string;
|
||
|
|
authorityCoordinator: boolean;
|
||
|
|
};
|
||
|
|
assert.equal(loginPayload.linked, true);
|
||
|
|
assert.equal(loginPayload.hostLinkId, 'host-link-device-01');
|
||
|
|
assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB');
|
||
|
|
assert.equal(loginPayload.authorityCoordinator, false);
|
||
|
|
assert.equal(reloadCount(), 1);
|
||
|
|
assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-LAB', {
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01DEVICE',
|
||
|
|
});
|
||
|
|
assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-LAB', {
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01DEVICE',
|
||
|
|
});
|
||
|
|
}, { authorityCoordinator: true });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps polling HiveCast device login while approval waits for namespace setup', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-device-owned-key-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withDeviceNamespaceRequiredServer(async (cloudUrl) => {
|
||
|
|
const result = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--device',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--route-key',
|
||
|
|
'owned-key',
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--json',
|
||
|
|
'--poll-timeout-ms',
|
||
|
|
'50',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(result, 1);
|
||
|
|
assert.match(result.errors.join('\n'), /HIVECAST_DEVICE_TIMEOUT/);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), false);
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-nats.json')), false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps HiveCast login idempotent and refuses accidental relink to a different route key', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-idempotent-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
let exchangeCalls = 0;
|
||
|
|
await withTokenExchangeServer(
|
||
|
|
(body) => {
|
||
|
|
exchangeCalls += 1;
|
||
|
|
const code = String(body.code ?? '');
|
||
|
|
if (code === 'FIRST') {
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-HIVECAST-LAB',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
publicNamespace: 'space.hivecast-lab',
|
||
|
|
spaceId: 'space_01IDEMPOTENT',
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
credentials: { jwt: 'first.jwt.token', seed: 'SUFIRSTIDEMPOTENT' },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (code === 'SECOND') {
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-OTHER-LAB',
|
||
|
|
routeKey: 'other-lab',
|
||
|
|
publicNamespace: 'space.other-lab',
|
||
|
|
spaceId: 'space_01RELINK',
|
||
|
|
nats: { url: 'wss://hivecast.example.test/nats-ws' },
|
||
|
|
credentials: { jwt: 'second.jwt.token', seed: 'SUSECONDRELINK' },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return { ok: false, error: `unexpected code ${code}` };
|
||
|
|
},
|
||
|
|
async (cloudUrl) => {
|
||
|
|
const first = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
setupCode: 'FIRST',
|
||
|
|
});
|
||
|
|
assert.equal(first.linked, true);
|
||
|
|
assert.equal(first.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(exchangeCalls, 1);
|
||
|
|
|
||
|
|
const alreadyLinked = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
});
|
||
|
|
assert.equal(alreadyLinked.linked, true);
|
||
|
|
assert.equal(alreadyLinked.alreadyLinked, true);
|
||
|
|
assert.equal(alreadyLinked.routeKey, 'hivecast-lab');
|
||
|
|
assert.equal(exchangeCalls, 1);
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
routeKey: 'other-lab',
|
||
|
|
}),
|
||
|
|
/HIVECAST_LINK_ROUTE_MISMATCH/,
|
||
|
|
);
|
||
|
|
await assert.rejects(
|
||
|
|
() => hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl: 'https://other-hivecast.example.test',
|
||
|
|
routeKey: 'hivecast-lab',
|
||
|
|
}),
|
||
|
|
/HIVECAST_LINK_CLOUD_MISMATCH/,
|
||
|
|
);
|
||
|
|
assert.equal(exchangeCalls, 1);
|
||
|
|
|
||
|
|
const relinked = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
setupCode: 'SECOND',
|
||
|
|
forceRelink: true,
|
||
|
|
});
|
||
|
|
assert.equal(relinked.linked, true);
|
||
|
|
assert.equal(relinked.alreadyLinked, undefined);
|
||
|
|
assert.equal(relinked.routeKey, 'other-lab');
|
||
|
|
assert.equal(relinked.authorityRoot, 'SPACE-OTHER-LAB');
|
||
|
|
assert.equal(exchangeCalls, 2);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps running Host roots converged across HiveCast login, already-linked, relink, and logout', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-running-host-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
const localOwnerRoot = 'hivecast-proof-local-owner-root';
|
||
|
|
const hostCredentialsPath = path.join(matrixHome, 'credentials.json');
|
||
|
|
const credentialPresenceAtReload: boolean[] = [];
|
||
|
|
await withNatsServer(async (natsUrl) => {
|
||
|
|
writeLocalOwnerHostStatus(matrixHome, natsUrl, localOwnerRoot);
|
||
|
|
await withReloadSupervisor(natsUrl, matrixHome, async (reloadCount) => {
|
||
|
|
await withTokenExchangeServer(
|
||
|
|
(body) => {
|
||
|
|
const code = String(body.code ?? '');
|
||
|
|
if (code === 'RUNNING-HOST') {
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-HIVECAST-RUNNING',
|
||
|
|
routeKey: 'hivecast-running',
|
||
|
|
publicNamespace: 'space.hivecast-running',
|
||
|
|
spaceId: 'space_01RUNNING',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: {
|
||
|
|
url: 'nats://hivecast.example.test:4222',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'running.jwt.token',
|
||
|
|
seed: 'SURUNNINGHOST',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (code === 'RELINK-HOST') {
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-HIVECAST-RELINK',
|
||
|
|
routeKey: 'hivecast-relink',
|
||
|
|
publicNamespace: 'space.hivecast-relink',
|
||
|
|
spaceId: 'space_01RELINKHOST',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: {
|
||
|
|
url: 'nats://hivecast.example.test:4222',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'relink.jwt.token',
|
||
|
|
seed: 'SURELINKHOST',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
ok: false,
|
||
|
|
error: `unexpected code ${code}`,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async (cloudUrl) => {
|
||
|
|
const result = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
setupCode: 'RUNNING-HOST',
|
||
|
|
});
|
||
|
|
assert.equal(result.linked, true);
|
||
|
|
assert.equal(result.authorityCoordinator, true);
|
||
|
|
const loginReload = result.hostConfigReload;
|
||
|
|
assert.ok(loginReload);
|
||
|
|
assert.equal(loginReload.attempted, true);
|
||
|
|
assert.equal(loginReload.applied, true);
|
||
|
|
assert.equal(reloadCount(), 1);
|
||
|
|
assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', {
|
||
|
|
routeKey: 'hivecast-running',
|
||
|
|
publicNamespace: 'space.hivecast-running',
|
||
|
|
spaceId: 'space_01RUNNING',
|
||
|
|
});
|
||
|
|
assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', {
|
||
|
|
routeKey: 'hivecast-running',
|
||
|
|
publicNamespace: 'space.hivecast-running',
|
||
|
|
spaceId: 'space_01RUNNING',
|
||
|
|
});
|
||
|
|
|
||
|
|
const alreadyLinked = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
routeKey: 'hivecast-running',
|
||
|
|
});
|
||
|
|
assert.equal(alreadyLinked.linked, true);
|
||
|
|
assert.equal(alreadyLinked.alreadyLinked, true);
|
||
|
|
assert.equal(alreadyLinked.authorityRoot, 'SPACE-HIVECAST-RUNNING');
|
||
|
|
assert.equal(alreadyLinked.authorityCoordinator, true);
|
||
|
|
assert.equal(reloadCount(), 2);
|
||
|
|
assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', {
|
||
|
|
routeKey: 'hivecast-running',
|
||
|
|
publicNamespace: 'space.hivecast-running',
|
||
|
|
spaceId: 'space_01RUNNING',
|
||
|
|
});
|
||
|
|
assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', {
|
||
|
|
routeKey: 'hivecast-running',
|
||
|
|
publicNamespace: 'space.hivecast-running',
|
||
|
|
spaceId: 'space_01RUNNING',
|
||
|
|
});
|
||
|
|
|
||
|
|
const relinked = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
setupCode: 'RELINK-HOST',
|
||
|
|
forceRelink: true,
|
||
|
|
});
|
||
|
|
assert.equal(relinked.linked, true);
|
||
|
|
assert.equal(relinked.alreadyLinked, undefined);
|
||
|
|
assert.equal(relinked.authorityRoot, 'SPACE-HIVECAST-RELINK');
|
||
|
|
assert.equal(relinked.authorityCoordinator, true);
|
||
|
|
assert.equal(reloadCount(), 3);
|
||
|
|
assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-RELINK', {
|
||
|
|
routeKey: 'hivecast-relink',
|
||
|
|
publicNamespace: 'space.hivecast-relink',
|
||
|
|
spaceId: 'space_01RELINKHOST',
|
||
|
|
});
|
||
|
|
assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-RELINK', {
|
||
|
|
routeKey: 'hivecast-relink',
|
||
|
|
publicNamespace: 'space.hivecast-relink',
|
||
|
|
spaceId: 'space_01RELINKHOST',
|
||
|
|
});
|
||
|
|
|
||
|
|
const logout = await hiveCastLogoutCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
});
|
||
|
|
assert.equal(logout.removed, true);
|
||
|
|
const logoutReload = logout.hostConfigReload;
|
||
|
|
assert.ok(logoutReload);
|
||
|
|
assert.equal(logoutReload.attempted, true);
|
||
|
|
assert.equal(logoutReload.applied, true);
|
||
|
|
assert.equal(reloadCount(), 4);
|
||
|
|
|
||
|
|
assertHostConfigRoot(matrixHome, localOwnerRoot);
|
||
|
|
assertHostStatusRoot(matrixHome, localOwnerRoot);
|
||
|
|
assert.deepEqual(credentialPresenceAtReload, [true, true, true, true]);
|
||
|
|
assert.equal(fs.existsSync(hostCredentialsPath), false);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}, () => {
|
||
|
|
credentialPresenceAtReload.push(fs.existsSync(hostCredentialsPath));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rewrites invalid shared bootstrap root to the install-local root before saving linked Host config', () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-root-repair-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
const installId = 'install_root_repair_01';
|
||
|
|
const expectedLocalRoot = localRootForInstallId(installId);
|
||
|
|
const invalidSharedRoot = 'hivecast-local-development-platform-topic';
|
||
|
|
writeInstallIdentity(matrixHome, installId);
|
||
|
|
fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({
|
||
|
|
kind: 'MatrixHostConfig',
|
||
|
|
version: 1,
|
||
|
|
home: matrixHome,
|
||
|
|
transport: {
|
||
|
|
root: invalidSharedRoot,
|
||
|
|
authorityRoot: invalidSharedRoot,
|
||
|
|
addressRoot: invalidSharedRoot,
|
||
|
|
nats: {
|
||
|
|
mode: 'external',
|
||
|
|
url: 'nats://127.0.0.1:48222',
|
||
|
|
wsUrl: 'ws://127.0.0.1:48223',
|
||
|
|
port: 48222,
|
||
|
|
wsPort: 48223,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}, null, 2)}\n`, 'utf8');
|
||
|
|
|
||
|
|
saveHiveCastHostLink({
|
||
|
|
cwd: root,
|
||
|
|
matrixHome,
|
||
|
|
cloudUrl: 'https://hivecast.example.test',
|
||
|
|
hostId: 'install_root_repair_01',
|
||
|
|
authorityRoot: 'richard-santomauro-nimbletec-com',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: {
|
||
|
|
url: 'nats://hivecast.example.test:4222',
|
||
|
|
},
|
||
|
|
natsCredentials: {
|
||
|
|
jwt: 'root.repair.jwt',
|
||
|
|
seed: 'SUROOTREPAIR',
|
||
|
|
},
|
||
|
|
hostLinkToken: 'hostlink-root-repair-token',
|
||
|
|
hostLinkId: 'hostlink_root_repair',
|
||
|
|
principalId: 'principal_root_repair',
|
||
|
|
hostName: 'root-repair-device',
|
||
|
|
});
|
||
|
|
|
||
|
|
const linkedConfig = readJsonRecord(path.join(matrixHome, 'host.json'));
|
||
|
|
const localOwnerTransport = asRecord(linkedConfig.hivecastLocalOwnerTransport);
|
||
|
|
assert.ok(localOwnerTransport);
|
||
|
|
assert.equal(localOwnerTransport.root, expectedLocalRoot);
|
||
|
|
assertHostConfigRoot(matrixHome, 'richard-santomauro-nimbletec-com');
|
||
|
|
|
||
|
|
const removed = removeHiveCastHostLink(root, matrixHome);
|
||
|
|
assert.equal(removed.hostConfigReset, true);
|
||
|
|
assertHostConfigRoot(matrixHome, expectedLocalRoot);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses linked success when a running Host does not apply linked config', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-reload-failure-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withNatsServer(async (natsUrl) => {
|
||
|
|
writeLocalOwnerHostStatus(matrixHome, natsUrl, 'hivecast-reload-failure-local-root');
|
||
|
|
await withTokenExchangeServer(
|
||
|
|
() => ({
|
||
|
|
ok: true,
|
||
|
|
root: 'SPACE-HIVECAST-RELOAD-FAILED',
|
||
|
|
routeKey: 'hivecast-reload-failed',
|
||
|
|
publicNamespace: 'space.hivecast-reload-failed',
|
||
|
|
spaceId: 'space_01RELOADFAILED',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: {
|
||
|
|
url: 'nats://hivecast.example.test:4222',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'reload-failed.jwt.token',
|
||
|
|
seed: 'SURELOADFAILED',
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
async (cloudUrl) => {
|
||
|
|
await assert.rejects(
|
||
|
|
() => hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
setupCode: 'RELOAD-FAILED',
|
||
|
|
}),
|
||
|
|
/HIVECAST_LINK_HOST_CONFIG_RELOAD_FAILED: running Host did not apply linked config[\s\S]*expectedRoot=SPACE-HIVECAST-RELOAD-FAILED/,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('waits for loopback HiveCast pair approval before exchanging the approval code', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-wait-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
const openedApprovalUrls: string[] = [];
|
||
|
|
await withNatsServer(async (natsUrl) => {
|
||
|
|
writeLocalOwnerHostStatus(matrixHome, natsUrl, 'hivecast-loopback-local-owner-root');
|
||
|
|
await withReloadSupervisor(natsUrl, matrixHome, async (reloadCount) => {
|
||
|
|
await withPairFlowServer(
|
||
|
|
{
|
||
|
|
pairRequestId: 'PAIR-WAIT',
|
||
|
|
approvalCode: 'APPROVAL-WAIT',
|
||
|
|
exchange: {
|
||
|
|
root: 'SPACE-HIVECAST-WAIT',
|
||
|
|
routeKey: 'hivecast-wait',
|
||
|
|
publicNamespace: 'space.hivecast-wait',
|
||
|
|
spaceId: 'space_01WAIT',
|
||
|
|
authorityCoordinator: true,
|
||
|
|
nats: {
|
||
|
|
url: 'nats://hivecast.example.test:4222',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'wait.jwt.token',
|
||
|
|
seed: 'SUFAKEWAIT',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async (cloudUrl, controls) => {
|
||
|
|
const result = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
waitForCallback: true,
|
||
|
|
noOpen: true,
|
||
|
|
callbackTimeoutMs: 5_000,
|
||
|
|
openApprovalUrl: (approvalUrl) => {
|
||
|
|
openedApprovalUrls.push(approvalUrl);
|
||
|
|
},
|
||
|
|
onSetupUrl: (setupUrl) => {
|
||
|
|
const parsed = new URL(setupUrl);
|
||
|
|
assert.equal(parsed.origin, cloudUrl);
|
||
|
|
assert.equal(parsed.pathname, '/_auth/pair/PAIR-WAIT');
|
||
|
|
const localReturnUrl = controls.getLastLocalReturnUrl();
|
||
|
|
assert.ok(localReturnUrl);
|
||
|
|
const startBody = controls.getStartBody();
|
||
|
|
assert.equal(typeof startBody?.nonce, 'string');
|
||
|
|
setImmediate(() => {
|
||
|
|
void fetch(`${localReturnUrl}?pairRequestId=PAIR-WAIT&approvalCode=APPROVAL-WAIT&nonce=${encodeURIComponent(String(startBody?.nonce))}`);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.equal(result.linked, true);
|
||
|
|
assert.equal(result.authorityRoot, 'SPACE-HIVECAST-WAIT');
|
||
|
|
assert.equal(result.authorityCoordinator, true);
|
||
|
|
assert.equal(result.routeKey, 'hivecast-wait');
|
||
|
|
assert.equal(reloadCount(), 1);
|
||
|
|
assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-WAIT', {
|
||
|
|
routeKey: 'hivecast-wait',
|
||
|
|
publicNamespace: 'space.hivecast-wait',
|
||
|
|
spaceId: 'space_01WAIT',
|
||
|
|
});
|
||
|
|
assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-WAIT', {
|
||
|
|
routeKey: 'hivecast-wait',
|
||
|
|
publicNamespace: 'space.hivecast-wait',
|
||
|
|
spaceId: 'space_01WAIT',
|
||
|
|
});
|
||
|
|
assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), true);
|
||
|
|
assert.deepEqual(openedApprovalUrls, []);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('opens the HiveCast pair approval URL by default during loopback login', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-open-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
const openedApprovalUrls: string[] = [];
|
||
|
|
await withPairFlowServer(
|
||
|
|
{
|
||
|
|
pairRequestId: 'PAIR-OPEN',
|
||
|
|
approvalCode: 'APPROVAL-OPEN',
|
||
|
|
exchange: {
|
||
|
|
root: 'SPACE-HIVECAST-OPEN',
|
||
|
|
routeKey: 'hivecast-open',
|
||
|
|
publicNamespace: 'space.hivecast-open',
|
||
|
|
nats: {
|
||
|
|
url: 'wss://hivecast.example.test/nats-ws',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'open.jwt.token',
|
||
|
|
seed: 'SUFAKEOPEN',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async (cloudUrl, controls) => {
|
||
|
|
let announcedSetupUrl = '';
|
||
|
|
const result = await hiveCastLoginCommand({
|
||
|
|
cwd: root,
|
||
|
|
home: matrixHome,
|
||
|
|
cloudUrl,
|
||
|
|
waitForCallback: true,
|
||
|
|
callbackTimeoutMs: 5_000,
|
||
|
|
openApprovalUrl: (approvalUrl) => {
|
||
|
|
openedApprovalUrls.push(approvalUrl);
|
||
|
|
},
|
||
|
|
onSetupUrl: (setupUrl) => {
|
||
|
|
announcedSetupUrl = setupUrl;
|
||
|
|
const localReturnUrl = controls.getLastLocalReturnUrl();
|
||
|
|
assert.ok(localReturnUrl);
|
||
|
|
const startBody = controls.getStartBody();
|
||
|
|
assert.equal(typeof startBody?.nonce, 'string');
|
||
|
|
setImmediate(() => {
|
||
|
|
void fetch(`${localReturnUrl}?pairRequestId=PAIR-OPEN&approvalCode=APPROVAL-OPEN&nonce=${encodeURIComponent(String(startBody?.nonce))}`);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.equal(result.linked, true);
|
||
|
|
assert.equal(result.authorityRoot, 'SPACE-HIVECAST-OPEN');
|
||
|
|
assert.equal(result.routeKey, 'hivecast-open');
|
||
|
|
assert.deepEqual(openedApprovalUrls, [announcedSetupUrl]);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('prints the HiveCast pair approval URL without opening a browser when --print-url is used', async () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-print-url-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
const matrixHome = path.join(root, '.matrix');
|
||
|
|
await withPairFlowServer(
|
||
|
|
{
|
||
|
|
pairRequestId: 'PAIR-PRINT',
|
||
|
|
approvalCode: 'APPROVAL-PRINT',
|
||
|
|
autoApprove: true,
|
||
|
|
exchange: {
|
||
|
|
root: 'SPACE-HIVECAST-PRINT',
|
||
|
|
routeKey: 'hivecast-print',
|
||
|
|
publicNamespace: 'space.hivecast-print',
|
||
|
|
nats: {
|
||
|
|
url: 'wss://hivecast.example.test/nats-ws',
|
||
|
|
},
|
||
|
|
credentials: {
|
||
|
|
jwt: 'print.jwt.token',
|
||
|
|
seed: 'SUFAKEPRINT',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async (cloudUrl) => {
|
||
|
|
const result = await runMxCli([
|
||
|
|
'node',
|
||
|
|
'mx',
|
||
|
|
'login',
|
||
|
|
'--hivecast',
|
||
|
|
'--cloud',
|
||
|
|
cloudUrl,
|
||
|
|
'--home',
|
||
|
|
matrixHome,
|
||
|
|
'--print-url',
|
||
|
|
], { cwd: root });
|
||
|
|
assertExitCode(result, 0);
|
||
|
|
assert.equal(result.logs.includes('Open this URL to approve connecting this device to HiveCast:'), true);
|
||
|
|
assert.equal(result.logs.includes(`${cloudUrl}/_auth/pair/PAIR-PRINT`), true);
|
||
|
|
assert.equal(result.logs.some((line) => line.includes('HiveCast device connected: SPACE-HIVECAST-PRINT')), true);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|