You've already forked tribes-supertest
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
])
|
||||
})
|
||||
@@ -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
@@ -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-"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user