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.
This commit is contained in:
2026-06-28 17:45:02 +02:00
parent 17a3d959e7
commit 5a2f3c7aba
13 changed files with 895 additions and 1 deletions
+8
View File
@@ -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://<node-or-hostname>`.
- `dns-validation` resolves A/AAAA records by querying authoritative nameservers directly.
## Dev Branch Helper
For rapid `guix-tribes` dev-channel iteration, use:
+4
View File
@@ -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
+14
View File
@@ -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",
+1
View File
@@ -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"
+12
View File
@@ -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<T>(key: string): T | undefined
+1
View File
@@ -140,6 +140,7 @@ export interface TestContext {
args: string[],
timeoutMs?: number
): Promise<CommandResult>
runLegionCli(label: string, args: string[], timeoutMs?: number): Promise<CommandResult>
ensureDevGuixTribesChannel(nodeRef: string): Promise<RolloutChannelRef | null>
systemTargetWithChannel(
+43
View File
@@ -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<RecordedAdminAccountCredential[]>(
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<void> {
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<CommandResult> {
return await this.legion.runCli(label, args, timeoutMs)
}
async ensureDevGuixTribesChannel(nodeRef: string): Promise<RolloutChannelRef | null> {
const devChannel = this.config.devGuixTribesChannel
if (!devChannel) {
@@ -2547,6 +2586,10 @@ class LegionAdapter {
)
}
async runCli(label: string, args: string[], timeoutMs: number): Promise<CommandResult> {
return await this.expectLegionSuccess(label, args, timeoutMs)
}
async configureHetznerProvider(): Promise<void> {
await this.expectLegionSuccess(
"providers-configure-hetzner",
+4
View File
@@ -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,
+367
View File
@@ -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<RecordedAdminAccountCredential[]>(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<RecordedAdminAccountCredential[]>(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<TestBlock["run"]>[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
}
+254
View File
@@ -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<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
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<TestBlock["run"]>[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)
}
+107
View File
@@ -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<TestContext> 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<RecordedAdminAccountCredential[]>(
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<TestContext> 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"]
])
})
+4
View File
@@ -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(
+76 -1
View File
@@ -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-"))