590 lines
15 KiB
JavaScript
590 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { once } from 'node:events';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
createSpacetimeWebIdentity,
|
|
encodeSpacetimeCliOption,
|
|
} from './spacetime-migration-common.mjs';
|
|
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
const database = 'admin-account-smoke';
|
|
const expectedSpacetimeVersion = '2.7.0';
|
|
const commandTimeoutMs = 5 * 60 * 1000;
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function appendOutput(current, chunk) {
|
|
const next = `${current}${chunk}`;
|
|
return next.length <= 24_000 ? next : next.slice(-24_000);
|
|
}
|
|
|
|
function runCommand(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd ?? repoRoot,
|
|
env: options.env ?? process.env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let output = '';
|
|
const timeout = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
reject(new Error(`${command} timed out after ${commandTimeoutMs}ms`));
|
|
}, options.timeoutMs ?? commandTimeoutMs);
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
output = appendOutput(output, chunk.toString());
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
output = appendOutput(output, chunk.toString());
|
|
});
|
|
child.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(timeout);
|
|
if (signal) {
|
|
reject(new Error(`${command} exited via ${signal}: ${output.trim()}`));
|
|
} else if (code !== 0) {
|
|
reject(new Error(`${command} exited with ${code}: ${output.trim()}`));
|
|
} else {
|
|
resolve(output);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function reservePort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
assert(
|
|
address && typeof address === 'object',
|
|
'Failed to reserve a local port.',
|
|
);
|
|
const port = address.port;
|
|
await new Promise((resolve, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolve())),
|
|
);
|
|
return port;
|
|
}
|
|
|
|
function startStandalone(dataDir, port) {
|
|
const child = spawn(
|
|
'spacetime',
|
|
[
|
|
'start',
|
|
'--data-dir',
|
|
dataDir,
|
|
'--listen-addr',
|
|
`127.0.0.1:${port}`,
|
|
'--non-interactive',
|
|
],
|
|
{
|
|
cwd: repoRoot,
|
|
env: process.env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
let output = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
output = appendOutput(output, chunk.toString());
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
output = appendOutput(output, chunk.toString());
|
|
});
|
|
return { child, output: () => output };
|
|
}
|
|
|
|
async function waitForStandalone(serverUrl, processState) {
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
if (processState.child.exitCode !== null) {
|
|
throw new Error(
|
|
`SpacetimeDB exited during startup: ${processState.output().trim()}`,
|
|
);
|
|
}
|
|
try {
|
|
const response = await fetch(`${serverUrl}/v1/ping`);
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Startup is still in progress.
|
|
}
|
|
await delay(200);
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for SpacetimeDB: ${processState.output().trim()}`,
|
|
);
|
|
}
|
|
|
|
async function stopStandalone(child) {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
child.kill('SIGTERM');
|
|
const exited = await Promise.race([
|
|
once(child, 'exit').then(() => true),
|
|
delay(5_000).then(() => false),
|
|
]);
|
|
if (!exited && child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await once(child, 'exit');
|
|
}
|
|
}
|
|
|
|
async function callProcedure(serverUrl, token, procedureName, input) {
|
|
const response = await fetch(
|
|
`${serverUrl}/v1/database/${database}/call/${procedureName}`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(input === undefined ? [] : [input]),
|
|
},
|
|
);
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`${procedureName} returned HTTP ${response.status}: ${text}`,
|
|
);
|
|
}
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
function decodeOption(value) {
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
if (Array.isArray(value) && value[0] === 0) {
|
|
return value[1];
|
|
}
|
|
if (Array.isArray(value) && value[0] === 1) {
|
|
return null;
|
|
}
|
|
throw new Error(`Unexpected SATS option: ${JSON.stringify(value)}`);
|
|
}
|
|
|
|
function mapAccountSnapshot(value) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
assert(
|
|
Array.isArray(value) && value.length === 11,
|
|
'Invalid admin account snapshot.',
|
|
);
|
|
return {
|
|
account_id: value[0],
|
|
username: value[1],
|
|
display_name: value[2],
|
|
tab_permissions_json: value[3],
|
|
enabled: value[4],
|
|
token_version: value[5],
|
|
created_by: value[6],
|
|
updated_by: value[7],
|
|
created_at_micros: value[8],
|
|
updated_at_micros: value[9],
|
|
action_permissions_json: value[10],
|
|
};
|
|
}
|
|
|
|
function parseAccountResult(value) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
assert(
|
|
Array.isArray(value) && value.length === 4,
|
|
'Invalid admin account result.',
|
|
);
|
|
const account = decodeOption(value[1]);
|
|
return {
|
|
ok: value[0],
|
|
account: account === null ? null : mapAccountSnapshot(account),
|
|
accounts: value[2].map(mapAccountSnapshot),
|
|
error_message: decodeOption(value[3]),
|
|
};
|
|
}
|
|
|
|
function parseCredentialResult(value) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
assert(
|
|
Array.isArray(value) && value.length === 3,
|
|
'Invalid admin credential result.',
|
|
);
|
|
const credential = decodeOption(value[1]);
|
|
return {
|
|
ok: value[0],
|
|
account:
|
|
credential === null
|
|
? null
|
|
: {
|
|
account: mapAccountSnapshot(credential[0]),
|
|
password_hash: credential[1],
|
|
},
|
|
error_message: decodeOption(value[2]),
|
|
};
|
|
}
|
|
|
|
function parsePricingResult(value) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
assert(Array.isArray(value) && value.length === 3, 'Invalid pricing result.');
|
|
return {
|
|
ok: value[0],
|
|
record: decodeOption(value[1]),
|
|
error_message: decodeOption(value[2]),
|
|
};
|
|
}
|
|
|
|
function assertDenied(result, procedureName) {
|
|
assert(
|
|
!result.ok,
|
|
`${procedureName} unexpectedly allowed an ordinary identity.`,
|
|
);
|
|
assert(
|
|
result.error_message === '当前 identity 无权调用模型生成运行时服务',
|
|
`${procedureName} returned an unexpected denial: ${result.error_message}`,
|
|
);
|
|
}
|
|
|
|
async function loadPricingModels() {
|
|
const filePath = path.join(
|
|
repoRoot,
|
|
'server-rs/crates/api-server/config/editor-generation-pricing.default.json',
|
|
);
|
|
const config = JSON.parse(await readFile(filePath, 'utf8'));
|
|
return Object.entries(config.models).map(([model, pricing]) => ({
|
|
model,
|
|
unit: pricing.unit,
|
|
price: encodeSpacetimeCliOption(pricing.price),
|
|
prices: Object.entries(pricing.prices ?? {}).map(([key, price]) => ({
|
|
key,
|
|
price,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
async function runSmoke(serverUrl, bootstrapSecret) {
|
|
const identityOptions = { database, serverUrl };
|
|
const runtime = await createSpacetimeWebIdentity(identityOptions);
|
|
const ordinary = await createSpacetimeWebIdentity(identityOptions);
|
|
const models = await loadPricingModels();
|
|
const pricing = parsePricingResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'initialize_editor_generation_pricing_config_if_missing_and_return',
|
|
{
|
|
admin_user_id: 'admin-account-smoke',
|
|
models,
|
|
updated_at_micros: 1,
|
|
bootstrap_secret: bootstrapSecret,
|
|
},
|
|
),
|
|
);
|
|
assert(
|
|
pricing.ok,
|
|
`Failed to initialize runtime identity: ${pricing.error_message}`,
|
|
);
|
|
|
|
const createInput = {
|
|
account_id: 'member-1',
|
|
username: 'operator',
|
|
display_name: 'Operator',
|
|
password_hash: '$argon2id$admin-account-smoke',
|
|
tab_permissions_json: '["dashboard"]',
|
|
enabled: true,
|
|
created_by: 'admin-account-smoke',
|
|
action_permissions_json: '[]',
|
|
};
|
|
const updateInput = {
|
|
account_id: 'member-1',
|
|
display_name: 'Operator Updated',
|
|
password_hash: null,
|
|
tab_permissions_json: '["dashboard","tracking"]',
|
|
enabled: false,
|
|
updated_by: 'admin-account-smoke',
|
|
action_permissions_json: '["profile-wallet-consumption-reconcile"]',
|
|
};
|
|
|
|
assertDenied(
|
|
parseCredentialResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
ordinary.token,
|
|
'get_admin_account_by_username_and_return',
|
|
{
|
|
username: 'operator',
|
|
},
|
|
),
|
|
),
|
|
'get_admin_account_by_username_and_return',
|
|
);
|
|
assertDenied(
|
|
parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
ordinary.token,
|
|
'get_admin_account_by_id_and_return',
|
|
{
|
|
account_id: 'member-1',
|
|
},
|
|
),
|
|
),
|
|
'get_admin_account_by_id_and_return',
|
|
);
|
|
assertDenied(
|
|
parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
ordinary.token,
|
|
'list_admin_accounts_and_return',
|
|
),
|
|
),
|
|
'list_admin_accounts_and_return',
|
|
);
|
|
assertDenied(
|
|
parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
ordinary.token,
|
|
'create_admin_account_and_return',
|
|
createInput,
|
|
),
|
|
),
|
|
'create_admin_account_and_return',
|
|
);
|
|
assertDenied(
|
|
parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
ordinary.token,
|
|
'update_admin_account_and_return',
|
|
updateInput,
|
|
),
|
|
),
|
|
'update_admin_account_and_return',
|
|
);
|
|
|
|
const created = parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'create_admin_account_and_return',
|
|
createInput,
|
|
),
|
|
);
|
|
assert(
|
|
created.ok && created.account?.token_version === 1,
|
|
'Runtime create failed.',
|
|
);
|
|
|
|
const duplicate = parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'create_admin_account_and_return',
|
|
{
|
|
...createInput,
|
|
account_id: 'member-duplicate',
|
|
username: ' Operator ',
|
|
},
|
|
),
|
|
);
|
|
assert(
|
|
!duplicate.ok,
|
|
'Duplicate normalized username unexpectedly succeeded.',
|
|
);
|
|
assert(
|
|
duplicate.error_message === '后台账号用户名已存在',
|
|
`Duplicate username error changed: ${duplicate.error_message}`,
|
|
);
|
|
|
|
const credential = parseCredentialResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'get_admin_account_by_username_and_return',
|
|
{
|
|
username: ' OPERATOR ',
|
|
},
|
|
),
|
|
);
|
|
assert(
|
|
credential.ok,
|
|
`Runtime credential lookup failed: ${credential.error_message}`,
|
|
);
|
|
assert(
|
|
credential.account?.password_hash === createInput.password_hash,
|
|
'Credential lookup lost the password hash.',
|
|
);
|
|
|
|
const byId = parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'get_admin_account_by_id_and_return',
|
|
{
|
|
account_id: 'member-1',
|
|
},
|
|
),
|
|
);
|
|
assert(
|
|
byId.ok && byId.account?.username === 'operator',
|
|
'Runtime ID lookup failed.',
|
|
);
|
|
assert(
|
|
!('password_hash' in byId.account),
|
|
'Public account snapshot exposed password_hash.',
|
|
);
|
|
|
|
const updated = parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'update_admin_account_and_return',
|
|
updateInput,
|
|
),
|
|
);
|
|
assert(
|
|
updated.ok && updated.account?.token_version === 2,
|
|
'State update did not increment once.',
|
|
);
|
|
assert(
|
|
updated.account?.action_permissions_json ===
|
|
'["profile-wallet-consumption-reconcile"]',
|
|
'Action permission was not persisted.',
|
|
);
|
|
|
|
const displayOnly = parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'update_admin_account_and_return',
|
|
{
|
|
...updateInput,
|
|
display_name: 'Operator Display Only',
|
|
},
|
|
),
|
|
);
|
|
assert(
|
|
displayOnly.ok && displayOnly.account?.token_version === 2,
|
|
'Display-only update changed token_version.',
|
|
);
|
|
|
|
const listed = parseAccountResult(
|
|
await callProcedure(
|
|
serverUrl,
|
|
runtime.token,
|
|
'list_admin_accounts_and_return',
|
|
),
|
|
);
|
|
assert(
|
|
listed.ok && listed.accounts.length === 1,
|
|
'Failed create mutated the account table.',
|
|
);
|
|
assert(
|
|
listed.accounts[0].account_id === 'member-1',
|
|
'Unexpected account survived the smoke.',
|
|
);
|
|
}
|
|
|
|
let tempDir;
|
|
let standalone;
|
|
try {
|
|
const versionOutput = await runCommand('spacetime', ['--version'], {
|
|
timeoutMs: 30_000,
|
|
});
|
|
assert(
|
|
versionOutput.includes(
|
|
`spacetimedb tool version ${expectedSpacetimeVersion}`,
|
|
),
|
|
`Expected SpacetimeDB ${expectedSpacetimeVersion}, got: ${versionOutput.trim()}`,
|
|
);
|
|
|
|
tempDir = await mkdtemp(
|
|
path.join(os.tmpdir(), 'genarrative-admin-account-smoke-'),
|
|
);
|
|
const port = await reservePort();
|
|
const serverUrl = `http://127.0.0.1:${port}`;
|
|
standalone = startStandalone(path.join(tempDir, 'data'), port);
|
|
await waitForStandalone(serverUrl, standalone);
|
|
|
|
const bootstrapSecret = createHash('sha256')
|
|
.update('genarrative-admin-account-procedure-smoke')
|
|
.digest('hex');
|
|
const bootstrapSecretHash = createHash('sha256')
|
|
.update(bootstrapSecret)
|
|
.digest('hex');
|
|
await runCommand(
|
|
'spacetime',
|
|
[
|
|
'publish',
|
|
database,
|
|
'--server',
|
|
serverUrl,
|
|
'--module-path',
|
|
'server-rs/crates/spacetime-module',
|
|
'--anonymous',
|
|
'--yes=all',
|
|
'--no-config',
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:
|
|
bootstrapSecretHash,
|
|
},
|
|
},
|
|
);
|
|
|
|
await runSmoke(serverUrl, bootstrapSecret);
|
|
console.log(
|
|
'[admin-account-procedure-smoke] Passed identity guards, credential isolation, uniqueness, and token-version transactions.',
|
|
);
|
|
} catch (error) {
|
|
const standaloneOutput = standalone?.output().trim();
|
|
console.error(
|
|
`[admin-account-procedure-smoke] Failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
if (standaloneOutput) {
|
|
console.error(standaloneOutput);
|
|
}
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (standalone) {
|
|
await stopStandalone(standalone.child);
|
|
}
|
|
if (tempDir) {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|