From 5a2f3c7aba769e102d734d4bd06e78d40c5c19b8 Mon Sep 17 00:00:00 2001 From: Agent Zuse Date: Sun, 28 Jun 2026 17:45:02 +0200 Subject: [PATCH] feat: add admin account supertest blocks Register reusable admin account create/delete, Playwright web-login, and authoritative DNS validation blocks. Add generated admin credential reporting and block coverage. --- README.md | 8 + devenv.nix | 4 + package-lock.json | 14 ++ package.json | 1 + src/architecture.ts | 12 + src/context.ts | 1 + src/runner.ts | 43 ++++ src/scenarios.ts | 4 + src/test-blocks/admin-accounts.ts | 367 +++++++++++++++++++++++++++++ src/test-blocks/dns.ts | 254 ++++++++++++++++++++ tests/admin-accounts-block.test.ts | 107 +++++++++ tests/cli.test.ts | 4 + tests/runner.test.ts | 77 +++++- 13 files changed, 895 insertions(+), 1 deletion(-) create mode 100644 src/test-blocks/admin-accounts.ts create mode 100644 src/test-blocks/dns.ts create mode 100644 tests/admin-accounts-block.test.ts diff --git a/README.md b/README.md index 0eaacea..e256ac7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Optional but commonly useful: - `SUPERTEST_KEXEC_IMAGE=/abs/path/to/guix-kexec-installer.tar.gz` or `SUPERTEST_KEXEC_IMAGE=https://mirror.example/tribes-1/guix-kexec-installer-x86_64-linux-latest.tar.gz` - `SUPERTEST_CERT_MODE=self-signed` (test-only mode to skip ACME and keep self-signed edge certs) - `hcloud` and `scw` CLI tooling in the shell for manual inspection or intervention +- Chromium available in the shell for Playwright-backed web-login blocks; the devenv shell provides this via `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` ## Install @@ -117,6 +118,13 @@ devenv shell supertest topology list devenv shell supertest block list ``` +Reusable account/DNS blocks are registered for future scenarios: + +- `admin-account-create` creates a hosted admin account through Legion CLI, generating a password when omitted and printing created credentials in the final run report. +- `admin-account-delete` deletes an admin account by `id` or `username` and verifies it no longer appears in Legion CLI output. +- `admin-web-login` uses Playwright/Chromium to log into `https://`. +- `dns-validation` resolves A/AAAA records by querying authoritative nameservers directly. + ## Dev Branch Helper For rapid `guix-tribes` dev-channel iteration, use: diff --git a/devenv.nix b/devenv.nix index fdd29cc..0edaaa8 100644 --- a/devenv.nix +++ b/devenv.nix @@ -25,6 +25,7 @@ in { NODE_ENV = "development"; # Delay npm dependency resolution to reduce rushed supply-chain updates. NPM_CONFIG_MIN_RELEASE_AGE = "7"; + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = "${pkgs.chromium}/bin/chromium"; } // lib.optionalAttrs hasGlibcLocales { LOCALE_ARCHIVE = "${devLocales}/lib/locale/locale-archive"; @@ -35,6 +36,9 @@ in { prettier alejandra + # Browser automation + chromium + # Cloud providers curl hcloud diff --git a/package-lock.json b/package-lock.json index 84f1ca0..ce13e41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@types/node": "^22.19.1", + "playwright-core": "^1.61.0", "prettier": "^3.7.4", "tsx": "^4.21.0", "typescript": "^5.9.3" @@ -540,6 +541,19 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", diff --git a/package.json b/package.json index bf62731..7ef1897 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@types/node": "^22.19.1", + "playwright-core": "^1.61.0", "prettier": "^3.7.4", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/src/architecture.ts b/src/architecture.ts index 779e989..b5ac08c 100644 --- a/src/architecture.ts +++ b/src/architecture.ts @@ -10,6 +10,18 @@ export interface BlockRequires { roles?: Role[] } +export const ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY = "admin-account-credentials" + +export interface RecordedAdminAccountCredential { + username: string + password: string + id?: string + displayName?: string + publicKeyHex?: string + npub?: string + source: string +} + export interface RunState { set(key: string, value: unknown): void get(key: string): T | undefined diff --git a/src/context.ts b/src/context.ts index 5408012..d2a5f7c 100644 --- a/src/context.ts +++ b/src/context.ts @@ -140,6 +140,7 @@ export interface TestContext { args: string[], timeoutMs?: number ): Promise + runLegionCli(label: string, args: string[], timeoutMs?: number): Promise ensureDevGuixTribesChannel(nodeRef: string): Promise systemTargetWithChannel( diff --git a/src/runner.ts b/src/runner.ts index 97e9335..faa4769 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -6,11 +6,13 @@ import { createWriteStream } from "node:fs" import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process" import { + ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY, InMemoryRunState, type BlockBinding, type BlockParams, type HookDecl, type HookPayload, + type RecordedAdminAccountCredential, type TestBlock } from "./architecture.js" import type { ProviderKind, RuntimeConfig } from "./config.js" @@ -533,10 +535,39 @@ export class ScenarioExecution implements TopologyContext, TestContext { } } + this.printAdminAccountCredentials() await this.printResourceSummary(this.shouldKeepNodes(error)) console.log(`runtime: ${formatDuration(runtimeMs)}`) } + private printAdminAccountCredentials(): void { + const credentials = this.runState.get( + ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY + ) + if (!credentials || credentials.length === 0) { + return + } + + console.log("admin accounts:") + for (const credential of credentials) { + console.log(` - username: ${credential.username}`) + console.log(` password: ${credential.password}`) + if (credential.id) { + console.log(` id: ${credential.id}`) + } + if (credential.displayName) { + console.log(` display name: ${credential.displayName}`) + } + if (credential.npub) { + console.log(` npub: ${credential.npub}`) + } + if (credential.publicKeyHex) { + console.log(` public key: ${credential.publicKeyHex}`) + } + console.log(` source: ${credential.source}`) + } + } + private async printResourceSummary(nodesKept: boolean): Promise { const state = await this.loadCurrentStateForMetadata() const trackedServers = state.trackedServers ?? [] @@ -1913,6 +1944,14 @@ export class ScenarioExecution implements TopologyContext, TestContext { return await this.runSupportCommand(label, command, args, timeoutMs) } + async runLegionCli( + label: string, + args: string[], + timeoutMs = DEFAULT_LEGION_TIMEOUT_MS + ): Promise { + return await this.legion.runCli(label, args, timeoutMs) + } + async ensureDevGuixTribesChannel(nodeRef: string): Promise { const devChannel = this.config.devGuixTribesChannel if (!devChannel) { @@ -2547,6 +2586,10 @@ class LegionAdapter { ) } + async runCli(label: string, args: string[], timeoutMs: number): Promise { + return await this.expectLegionSuccess(label, args, timeoutMs) + } + async configureHetznerProvider(): Promise { await this.expectLegionSuccess( "providers-configure-hetzner", diff --git a/src/scenarios.ts b/src/scenarios.ts index 845eb8c..1ab588b 100644 --- a/src/scenarios.ts +++ b/src/scenarios.ts @@ -25,6 +25,8 @@ import { singleNodePluginRolloutRollbackScenario } from "./scenarios/single-node-plugin-rollout-rollback.js" import { singleNodeSenderBlock, singleNodeSenderScenario } from "./scenarios/single-node-sender.js" +import { adminAccountBlocks } from "./test-blocks/admin-accounts.js" +import { dnsBlocks } from "./test-blocks/dns.js" import { singleNodeInitBlocks } from "./test-blocks/single-node-init.js" import { clusterLifecycleTopology } from "./topologies/cluster-lifecycle.js" import { singleNodeManualTopology } from "./topologies/single-node-manual.js" @@ -46,6 +48,8 @@ topologyRegistry.registerAll([ ]) blockRegistry.registerAll([ ...singleNodeInitBlocks, + ...adminAccountBlocks, + ...dnsBlocks, singleNodePluginRolloutRollbackBlock, singleNodeSenderBlock, clusterPluginIntegratedRolloutPrepareBlock, diff --git a/src/test-blocks/admin-accounts.ts b/src/test-blocks/admin-accounts.ts new file mode 100644 index 0000000..0ba01f3 --- /dev/null +++ b/src/test-blocks/admin-accounts.ts @@ -0,0 +1,367 @@ +import { randomBytes } from "node:crypto" +import { existsSync } from "node:fs" +import { delimiter, join } from "node:path" + +import { chromium } from "playwright-core" + +import { + ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY, + type BlockParams, + type RecordedAdminAccountCredential, + type RunState, + type TestBlock +} from "../architecture.js" +import type { LegionTrackedServer } from "../legion-state.js" +import { requireHookNode } from "../topologies/common.js" + +const DEFAULT_ADMIN_TIMEOUT_MS = 5 * 60_000 +const DEFAULT_WEB_LOGIN_TIMEOUT_MS = 60_000 + +interface AdminAccount { + id?: string + username?: string + displayName?: string | null + publicKeyHex?: string + npub?: string + status?: string +} + +interface AdminAccountListResult { + accounts?: AdminAccount[] +} + +export const createAdminAccountBlock: TestBlock = { + name: "admin-account-create", + description: "Create a hosted admin account through the Legion CLI and verify it is listed.", + requires: { minNodes: 1 }, + async run(ctx, _hook, state, params) { + const username = requireStringParam(params, "username") + const displayName = optionalStringParam(params, "displayName") + const password = optionalStringParam(params, "password") ?? generateAdminPassword() + const privateKey = optionalStringParam(params, "privateKey") + const timeoutMs = optionalIntegerParam(params, "timeoutMs") ?? DEFAULT_ADMIN_TIMEOUT_MS + + const createArgs = ["admin-accounts", "create", username, "--password", password, "--json"] + if (displayName) { + createArgs.push("--display-name", displayName) + } + if (privateKey) { + createArgs.push("--private-key", privateKey) + } + + await ctx.runLegionCli(`admin-account-create-${username}`, createArgs, timeoutMs) + + const listResult = parseAdminAccountListResult( + ( + await ctx.runLegionCli( + `admin-account-list-after-create-${username}`, + ["admin-accounts", "list", "--json"], + timeoutMs + ) + ).stdout, + username + ) + const account = listResult.accounts?.find((entry) => entry.username === username) + if (!account) { + throw new Error(`Created admin account ${username} was not returned by admin-accounts list.`) + } + if (account.status && account.status !== "active") { + throw new Error(`Created admin account ${username} is ${account.status}, expected active.`) + } + + recordAdminAccountCredential(state, { + username, + password, + ...(account.id ? { id: account.id } : {}), + ...(displayName ? { displayName } : {}), + ...(account.publicKeyHex ? { publicKeyHex: account.publicKeyHex } : {}), + ...(account.npub ? { npub: account.npub } : {}), + source: createAdminAccountBlock.name + }) + } +} + +export const deleteAdminAccountBlock: TestBlock = { + name: "admin-account-delete", + description: + "Delete an admin account through the Legion CLI and verify it is absent from the list.", + requires: { minNodes: 1 }, + async run(ctx, _hook, state, params) { + const username = optionalStringParam(params, "username") + const explicitId = optionalStringParam(params, "id") + const timeoutMs = optionalIntegerParam(params, "timeoutMs") ?? DEFAULT_ADMIN_TIMEOUT_MS + const beforeList = parseAdminAccountListResult( + ( + await ctx.runLegionCli( + "admin-account-list-before-delete", + ["admin-accounts", "list", "--json"], + timeoutMs + ) + ).stdout, + "admin-account-delete" + ) + const target = resolveDeleteTarget(state, beforeList, explicitId, username) + + await ctx.runLegionCli( + `admin-account-delete-${target.id}`, + ["admin-accounts", "delete", target.id, "--json"], + timeoutMs + ) + + const afterList = parseAdminAccountListResult( + ( + await ctx.runLegionCli( + "admin-account-list-after-delete", + ["admin-accounts", "list", "--json"], + timeoutMs + ) + ).stdout, + "admin-account-delete" + ) + const remaining = afterList.accounts?.find( + (account) => + account.id === target.id || (!!target.username && account.username === target.username) + ) + if (remaining) { + throw new Error( + `Deleted admin account ${target.username ?? target.id} is still present in admin-accounts list.` + ) + } + } +} + +export const adminWebLoginBlock: TestBlock = { + name: "admin-web-login", + description: "Log into the Tribes web UI as an admin account with Playwright.", + requires: { minNodes: 1 }, + async run(_ctx, hook, state, params) { + const username = requireStringParam(params, "username") + const password = + optionalStringParam(params, "password") ?? requireRecordedPassword(state, username) + const server = selectServer(params, hook) + const hostname = optionalStringParam(params, "hostname") ?? defaultWebHostname(server) + const timeoutMs = optionalIntegerParam(params, "timeoutMs") ?? DEFAULT_WEB_LOGIN_TIMEOUT_MS + const ignoreHttpsErrors = optionalBooleanParam(params, "ignoreHttpsErrors") ?? true + const origin = toHttpsOrigin(hostname) + const executablePath = resolveChromiumExecutable() + + const browser = await chromium.launch({ + ...(executablePath ? { executablePath } : {}), + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"] + }) + + try { + const context = await browser.newContext({ ignoreHTTPSErrors: ignoreHttpsErrors }) + const page = await context.newPage() + + await page.goto(`${origin}/sign-in`, { waitUntil: "domcontentloaded", timeout: timeoutMs }) + await page.locator("#sign-in-form").waitFor({ state: "visible", timeout: timeoutMs }) + await page.fill('input[name="user[username]"]', username) + await page.fill('input[name="user[password]"]', password) + await Promise.all([ + page + .waitForURL((url) => !url.pathname.endsWith("/sign-in"), { timeout: timeoutMs }) + .catch(() => {}), + page.click("#sign-in-submit") + ]) + await page.goto(`${origin}/settings`, { waitUntil: "domcontentloaded", timeout: timeoutMs }) + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => {}) + + if ((await page.locator("#sign-in-page").count()) > 0) { + throw new Error(`Admin web login for ${username} returned to the sign-in page.`) + } + + const bodyText = (await page.locator("body").innerText({ timeout: timeoutMs })).trim() + if (!bodyText.includes(username)) { + throw new Error(`Admin web login for ${username} succeeded but did not show the username.`) + } + } finally { + await browser.close() + } + } +} + +export const adminAccountBlocks = [ + createAdminAccountBlock, + deleteAdminAccountBlock, + adminWebLoginBlock +] + +function parseAdminAccountListResult(stdout: string, username: string): AdminAccountListResult { + let parsed: unknown + try { + parsed = JSON.parse(stdout) + } catch (error) { + throw new Error( + `Failed to parse admin-accounts list JSON while verifying ${username}: ${(error as Error).message}` + ) + } + + if ( + !parsed || + typeof parsed !== "object" || + !Array.isArray((parsed as AdminAccountListResult).accounts) + ) { + throw new Error( + `admin-accounts list did not return an accounts array while verifying ${username}.` + ) + } + + return parsed as AdminAccountListResult +} + +function recordAdminAccountCredential( + state: RunState, + credential: RecordedAdminAccountCredential +): void { + const credentials = + state.get(ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY) ?? [] + state.set(ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY, [...credentials, credential]) +} + +function resolveDeleteTarget( + state: RunState, + listResult: AdminAccountListResult, + explicitId: string | undefined, + username: string | undefined +): { id: string; username?: string } { + if (!explicitId && !username) { + throw new Error("admin-account-delete requires an id or username param.") + } + + const accounts = listResult.accounts ?? [] + const recorded = username ? findRecordedCredential(state, username) : undefined + const account = explicitId + ? accounts.find((entry) => entry.id === explicitId) + : accounts.find((entry) => entry.username === username) + const id = explicitId ?? recorded?.id ?? account?.id + + if (!id) { + throw new Error(`Could not resolve admin account id for ${username ?? explicitId}.`) + } + + if (username && account?.username && account.username !== username) { + throw new Error(`Resolved admin account ${id} did not match username ${username}.`) + } + + const resolvedUsername = username ?? account?.username + return resolvedUsername ? { id, username: resolvedUsername } : { id } +} + +function findRecordedCredential( + state: RunState, + username: string +): RecordedAdminAccountCredential | undefined { + const credentials = + state.get(ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY) ?? [] + return [...credentials].reverse().find((entry) => entry.username === username) +} + +function requireRecordedPassword(state: RunState, username: string): string { + const credential = findRecordedCredential(state, username) + if (!credential) { + throw new Error(`No recorded password found for admin account ${username}.`) + } + return credential.password +} + +function selectServer( + params: BlockParams, + hook: Parameters[1] +): LegionTrackedServer { + const role = optionalStringParam(params, "role") + const nodeId = optionalStringParam(params, "nodeId") + + if (nodeId) { + const node = hook.nodes.find((entry) => entry.id === nodeId) + return requireHookNode(node, nodeId).server + } + + if (role) { + return requireHookNode(hook.byRole(role)[0], role).server + } + + return requireHookNode(hook.nodes[0], "first available").server +} + +function defaultWebHostname(server: LegionTrackedServer): string { + return server.managedBootstrap?.dnsHostname ?? server.publicIp +} + +function toHttpsOrigin(hostname: string): string { + if (/^https?:\/\//i.test(hostname)) { + return hostname.replace(/\/$/, "") + } + + return `https://${formatHost(hostname)}` +} + +function formatHost(hostname: string): string { + return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname +} + +function resolveChromiumExecutable(): string | undefined { + const explicit = process.env["PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"] + if (explicit) { + return explicit + } + + return findOnPath("chromium") ?? findOnPath("chromium-browser") ?? findOnPath("google-chrome") +} + +function findOnPath(binary: string): string | undefined { + for (const entry of (process.env["PATH"] ?? "").split(delimiter)) { + const candidate = join(entry, binary) + if (existsSync(candidate)) { + return candidate + } + } + return undefined +} + +function generateAdminPassword(): string { + return `st-${randomBytes(18).toString("base64url")}` +} + +function requireStringParam(params: BlockParams, name: string): string { + const value = optionalStringParam(params, name) + if (!value) { + throw new Error(`Missing required block param ${name}.`) + } + return value +} + +function optionalStringParam(params: BlockParams, name: string): string | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (typeof value !== "string") { + throw new Error(`Block param ${name} must be a string.`) + } + const trimmed = value.trim() + return trimmed || undefined +} + +function optionalIntegerParam(params: BlockParams, name: string): number | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + throw new Error(`Block param ${name} must be a positive integer.`) + } + return value +} + +function optionalBooleanParam(params: BlockParams, name: string): boolean | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (typeof value !== "boolean") { + throw new Error(`Block param ${name} must be a boolean.`) + } + return value +} diff --git a/src/test-blocks/dns.ts b/src/test-blocks/dns.ts new file mode 100644 index 0000000..763296a --- /dev/null +++ b/src/test-blocks/dns.ts @@ -0,0 +1,254 @@ +import { Resolver, resolve4, resolve6, resolveNs } from "node:dns/promises" +import net from "node:net" + +import type { BlockParams, TestBlock } from "../architecture.js" +import type { LegionTrackedServer } from "../legion-state.js" +import { requireHookNode } from "../topologies/common.js" + +const DEFAULT_DNS_TIMEOUT_MS = 15_000 + +export const dnsValidationBlock: TestBlock = { + name: "dns-validation", + description: "Resolve DNS records by querying authoritative nameservers directly.", + requires: { minNodes: 1 }, + async run(_ctx, hook, _state, params) { + const server = selectServer(params, hook) + const hostname = optionalStringParam(params, "hostname") ?? server.managedBootstrap?.dnsHostname + if (!hostname) { + throw new Error("dns-validation requires a hostname param or a node with dnsHostname.") + } + + const expectedIpv4 = optionalStringArrayParam(params, "expectedIpv4") ?? inferIpv4(server) + const expectedIpv6 = optionalStringArrayParam(params, "expectedIpv6") ?? inferIpv6(server) + const allowExtra = optionalBooleanParam(params, "allowExtra") ?? false + const timeoutMs = optionalIntegerParam(params, "timeoutMs") ?? DEFAULT_DNS_TIMEOUT_MS + const nameservers = await resolveAuthoritativeNameservers( + hostname, + optionalStringParam(params, "zone"), + optionalStringArrayParam(params, "nameservers"), + timeoutMs + ) + + if (expectedIpv4.length === 0 && expectedIpv6.length === 0) { + throw new Error( + "dns-validation needs expectedIpv4/expectedIpv6 params or node public IPv4/IPv6 state." + ) + } + + for (const nameserver of nameservers) { + if (expectedIpv4.length > 0) { + const actual = await queryRecord(hostname, "A", nameserver, timeoutMs) + assertRecords(hostname, nameserver, "A", actual, expectedIpv4, allowExtra) + } + + if (expectedIpv6.length > 0) { + const actual = await queryRecord(hostname, "AAAA", nameserver, timeoutMs) + assertRecords(hostname, nameserver, "AAAA", actual, expectedIpv6, allowExtra) + } + } + } +} + +export const dnsBlocks = [dnsValidationBlock] + +async function resolveAuthoritativeNameservers( + hostname: string, + zone: string | undefined, + explicitNameservers: string[] | undefined, + timeoutMs: number +): Promise { + const nameserverHosts = explicitNameservers ?? (await discoverNameserverHosts(hostname, zone)) + if (nameserverHosts.length === 0) { + throw new Error(`No authoritative nameservers found for ${zone ?? hostname}.`) + } + + const nameserverIps: string[] = [] + for (const nameserver of nameserverHosts) { + if (net.isIP(nameserver)) { + nameserverIps.push(nameserver) + continue + } + + const [ipv4, ipv6] = await Promise.all([ + resolveRecordWithDefaultResolver(nameserver, "A", timeoutMs), + resolveRecordWithDefaultResolver(nameserver, "AAAA", timeoutMs) + ]) + nameserverIps.push(...ipv4, ...ipv6) + } + + if (nameserverIps.length === 0) { + throw new Error(`Could not resolve IP addresses for nameservers: ${nameserverHosts.join(", ")}`) + } + + return [...new Set(nameserverIps)] +} + +async function discoverNameserverHosts( + hostname: string, + zone: string | undefined +): Promise { + if (zone) { + return await resolveNs(zone) + } + + const labels = hostname.replace(/\.$/, "").split(".") + const errors: string[] = [] + for (let index = 0; index < labels.length - 1; index += 1) { + const candidate = labels.slice(index).join(".") + try { + const records = await resolveNs(candidate) + if (records.length > 0) { + return records + } + } catch (error) { + errors.push(`${candidate}: ${toErrorMessage(error)}`) + } + } + + throw new Error( + `Could not discover authoritative nameservers for ${hostname}: ${errors.join("; ")}` + ) +} + +async function queryRecord( + hostname: string, + type: "A" | "AAAA", + nameserver: string, + timeoutMs: number +): Promise { + const resolver = new Resolver({ timeout: timeoutMs, tries: 1 }) + resolver.setServers([nameserver]) + + try { + return type === "A" ? await resolver.resolve4(hostname) : await resolver.resolve6(hostname) + } catch (error) { + if (isNoDnsDataError(error)) { + return [] + } + throw new Error( + `Failed to resolve ${type} ${hostname} against nameserver ${nameserver}: ${toErrorMessage(error)}` + ) + } +} + +async function resolveRecordWithDefaultResolver( + hostname: string, + type: "A" | "AAAA", + _timeoutMs: number +): Promise { + try { + return type === "A" ? await resolve4(hostname) : await resolve6(hostname) + } catch (error) { + if (isNoDnsDataError(error)) { + return [] + } + throw error + } +} + +function assertRecords( + hostname: string, + nameserver: string, + type: "A" | "AAAA", + actual: string[], + expected: string[], + allowExtra: boolean +): void { + const actualSet = new Set(actual) + const expectedSet = new Set(expected) + const missing = expected.filter((entry) => !actualSet.has(entry)) + const extra = actual.filter((entry) => !expectedSet.has(entry)) + + if (missing.length > 0 || (!allowExtra && extra.length > 0)) { + throw new Error( + `${type} records for ${hostname} from ${nameserver} did not match. expected=${expected.join(",")} actual=${actual.join(",")} missing=${missing.join(",")} extra=${extra.join(",")}` + ) + } +} + +function selectServer( + params: BlockParams, + hook: Parameters[1] +): LegionTrackedServer { + const role = optionalStringParam(params, "role") + const nodeId = optionalStringParam(params, "nodeId") + + if (nodeId) { + const node = hook.nodes.find((entry) => entry.id === nodeId) + return requireHookNode(node, nodeId).server + } + + if (role) { + return requireHookNode(hook.byRole(role)[0], role).server + } + + return requireHookNode(hook.nodes[0], "first available").server +} + +function inferIpv4(server: LegionTrackedServer): string[] { + const ipv4 = + server.managedBootstrap?.publicIpv4 ?? + (net.isIPv4(server.publicIp) ? server.publicIp : undefined) + return ipv4 ? [ipv4] : [] +} + +function inferIpv6(server: LegionTrackedServer): string[] { + const ipv6 = + server.managedBootstrap?.publicIpv6 ?? + (net.isIPv6(server.publicIp) ? server.publicIp : undefined) + return ipv6 ? [ipv6] : [] +} + +function optionalStringParam(params: BlockParams, name: string): string | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (typeof value !== "string") { + throw new Error(`Block param ${name} must be a string.`) + } + const trimmed = value.trim() + return trimmed || undefined +} + +function optionalStringArrayParam(params: BlockParams, name: string): string[] | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) { + throw new Error(`Block param ${name} must be a non-empty string array.`) + } + return value.map((entry) => entry.trim()) +} + +function optionalIntegerParam(params: BlockParams, name: string): number | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + throw new Error(`Block param ${name} must be a positive integer.`) + } + return value +} + +function optionalBooleanParam(params: BlockParams, name: string): boolean | undefined { + const value = params[name] + if (value === undefined || value === null) { + return undefined + } + if (typeof value !== "boolean") { + throw new Error(`Block param ${name} must be a boolean.`) + } + return value +} + +function isNoDnsDataError(error: unknown): boolean { + const code = typeof error === "object" && error ? (error as { code?: unknown }).code : undefined + return code === "ENODATA" || code === "ENOTFOUND" || code === "ENODOMAIN" +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/tests/admin-accounts-block.test.ts b/tests/admin-accounts-block.test.ts new file mode 100644 index 0000000..c7ccaf6 --- /dev/null +++ b/tests/admin-accounts-block.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY, + InMemoryRunState, + createHookPayload, + type RecordedAdminAccountCredential +} from "../src/architecture.js" +import type { CommandResult, TestContext } from "../src/context.js" +import { + createAdminAccountBlock, + deleteAdminAccountBlock +} from "../src/test-blocks/admin-accounts.js" + +function commandResult(args: string[], stdout: string): CommandResult { + return { + label: args.join(" "), + command: "legion", + args, + cwd: "/tmp", + startedAt: "2026-04-09T12:34:56.000Z", + finishedAt: "2026-04-09T12:34:56.000Z", + exitCode: 0, + stdout, + stderr: "" + } +} + +test("admin-account-create generates a password, calls Legion CLI, and records credentials", async () => { + const calls: string[][] = [] + const context = { + async runLegionCli(_label: string, args: string[]) { + calls.push(args) + return commandResult( + args, + JSON.stringify({ + accounts: [ + { + id: "acct-1", + username: "alice", + displayName: "Alice Admin", + publicKeyHex: "a".repeat(64), + npub: "npub1alice", + status: "active" + } + ] + }) + ) + } + } as Partial as TestContext + const state = new InMemoryRunState() + + await createAdminAccountBlock.run( + context, + createHookPayload({ hook: "ready", nodes: [] }), + state, + { username: "alice", displayName: "Alice Admin" } + ) + + assert.equal(calls.length, 2) + assert.deepEqual(calls[0]?.slice(0, 4), ["admin-accounts", "create", "alice", "--password"]) + assert.ok(calls[0]?.includes("--json")) + assert.ok(calls[0]?.includes("--display-name")) + assert.deepEqual(calls[1], ["admin-accounts", "list", "--json"]) + + const credentials = state.get( + ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY + ) + assert.equal(credentials?.length, 1) + assert.equal(credentials?.[0]?.username, "alice") + assert.equal(credentials?.[0]?.id, "acct-1") + assert.equal(credentials?.[0]?.displayName, "Alice Admin") + assert.equal(credentials?.[0]?.npub, "npub1alice") + assert.match(credentials?.[0]?.password ?? "", /^st-/) +}) + +test("admin-account-delete resolves an account by username and verifies removal", async () => { + const calls: string[][] = [] + const context = { + async runLegionCli(_label: string, args: string[]) { + calls.push(args) + if (args[1] === "delete" || calls.length > 2) { + return commandResult(args, JSON.stringify({ accounts: [] })) + } + return commandResult( + args, + JSON.stringify({ + accounts: [{ id: "acct-1", username: "alice", status: "active" }] + }) + ) + } + } as Partial as TestContext + + await deleteAdminAccountBlock.run( + context, + createHookPayload({ hook: "ready", nodes: [] }), + new InMemoryRunState(), + { username: "alice" } + ) + + assert.deepEqual(calls, [ + ["admin-accounts", "list", "--json"], + ["admin-accounts", "delete", "acct-1", "--json"], + ["admin-accounts", "list", "--json"] + ]) +}) diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 0c5949a..af55104 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -53,6 +53,10 @@ test("block list is generated from registered blocks", async () => { assert.equal(result.exitCode, 0) assert.match(result.stdout, /http3-health/) assert.match(result.stdout, /log-roundtrip/) + assert.match(result.stdout, /admin-account-create/) + assert.match(result.stdout, /admin-account-delete/) + assert.match(result.stdout, /admin-web-login/) + assert.match(result.stdout, /dns-validation/) }) async function captureStdout( diff --git a/tests/runner.test.ts b/tests/runner.test.ts index 2c6d5a3..dd9698f 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -4,7 +4,13 @@ import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" -import { createHookPayload, type TestBlock, type Topology } from "../src/architecture.js" +import { + ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY, + createHookPayload, + type RecordedAdminAccountCredential, + type TestBlock, + type Topology +} from "../src/architecture.js" import { resolveRuntimeConfig } from "../src/config.js" import type { LegionTrackedServer } from "../src/legion-state.js" import { @@ -150,6 +156,75 @@ test("scenario execution passes scenario binding params to blocks", async () => } }) +test("scenario summary prints recorded admin account credentials", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "tribes-supertest-runner-")) + + try { + const invocationLogPath = join(tempDir, "legion-invocations.jsonl") + const fakeCliPath = join(tempDir, "fake-legion-cli.mjs") + await writeFile(fakeCliPath, buildFakeLegionCli(invocationLogPath)) + + const config = resolveRuntimeConfig( + "admin-account-summary", + { + LEGION_UNLOCK_PASSWORD: "unlock-secret", + SUPERTEST_ARTIFACT_ROOT: join(tempDir, "artifacts"), + SUPERTEST_CERT_MODE: "self-signed", + SUPERTEST_LEGION_CLI_ENTRY: fakeCliPath, + SUPERTEST_LEGION_REPO: process.cwd() + }, + process.cwd(), + new Date("2026-04-09T12:34:56.000Z") + ) + + const block: TestBlock = { + name: "record-admin-account-credential", + description: "Record admin account credentials for final summary output.", + async run(_context, _hook, state) { + state.set(ADMIN_ACCOUNT_CREDENTIALS_STATE_KEY, [ + { + username: "alice", + password: "generated-password", + id: "acct-1", + npub: "npub1example", + source: "test" + } satisfies RecordedAdminAccountCredential + ]) + } + } + blockRegistry.register(block) + + const topology: Topology = { + name: "admin-account-summary-topology", + description: "Test topology that emits one hook.", + estimatedDuration: "0m", + roles: [], + hooks: [{ name: "ready", guarantees: { minNodes: 0, roles: [] } }], + drive: async (_context, emit) => { + await emit("ready", createHookPayload({ hook: "ready", nodes: [] })) + } + } + topologyRegistry.register(topology) + + const output = await captureConsoleLog(() => + new ScenarioExecution(config, { + name: "admin-account-summary", + description: "admin account summary test", + topology: topology.name, + blocks: { ready: [block.name] } + }).run() + ) + + assert.match(output, /admin accounts:/) + assert.match(output, /username: alice/) + assert.match(output, /password: generated-password/) + assert.match(output, /id: acct-1/) + assert.match(output, /npub: npub1example/) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) + test("scenario execution keeps Legion daemon running when keep nodes is enabled", async () => { const tempDir = await mkdtemp(join(tmpdir(), "tribes-supertest-runner-"))