Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
/**
|
|
* Shared test helpers: a valid PanelConfig factory, an authed session cookie, an Ed25519 PKCS#8 PEM
|
|
* generator (for the manage-token test), and simple fakes for the CP client + token minter.
|
|
*/
|
|
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
|
|
import { createSessionToken } from '../src/security/session.js'
|
|
import type { PanelConfig } from '../src/config.js'
|
|
import type { CpClient, HostView, IssuedPairing } from '../src/cp-client.js'
|
|
import type { ManageTokenMinter } from '../src/manage-token.js'
|
|
|
|
export const TEST_SESSION_SECRET = 'test-session-secret-0123456789'
|
|
|
|
export function makeConfig(overrides: Partial<PanelConfig> = {}): PanelConfig {
|
|
return {
|
|
panelPassword: 'hunter2-correct-horse',
|
|
sessionSecret: TEST_SESSION_SECRET,
|
|
cpUrl: 'http://127.0.0.1:8080',
|
|
baseDomain: 'terminal.yaojia.wang',
|
|
operatorAccountId: 'acct-operator-001',
|
|
capabilitySignKeyPath: '/nonexistent/key.pem',
|
|
tunnelZone: 'terminal.yaojia.wang',
|
|
panelBindPort: 8090,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** A valid session cookie header value for injection (`cookie` header). */
|
|
export function authCookieHeader(secret: string = TEST_SESSION_SECRET, nowMs: number = Date.now()): string {
|
|
return `${SESSION_COOKIE_NAME}=${createSessionToken(secret, nowMs)}`
|
|
}
|
|
|
|
/** A minter that returns a fixed opaque token and records how many times it was called. */
|
|
export function fakeMinter(token = 'fake.manage.token'): ManageTokenMinter & { readonly calls: () => number } {
|
|
let n = 0
|
|
return {
|
|
async mint() {
|
|
n += 1
|
|
return token
|
|
},
|
|
calls: () => n,
|
|
}
|
|
}
|
|
|
|
export interface FakeCpCall {
|
|
readonly method: string
|
|
readonly accountIdOrHostId: string
|
|
readonly token: string
|
|
}
|
|
|
|
/** A CP client fake that records calls and returns canned data (or throws an injected error). */
|
|
export function fakeCpClient(opts: {
|
|
hosts?: readonly HostView[]
|
|
pairing?: IssuedPairing
|
|
throwErr?: Error
|
|
} = {}): CpClient & { readonly calls: readonly FakeCpCall[] } {
|
|
const calls: FakeCpCall[] = []
|
|
const guard = (): void => {
|
|
if (opts.throwErr) throw opts.throwErr
|
|
}
|
|
return {
|
|
calls,
|
|
async listHosts(accountId, token) {
|
|
calls.push({ method: 'listHosts', accountIdOrHostId: accountId, token })
|
|
guard()
|
|
return opts.hosts ?? []
|
|
},
|
|
async createPairingCode(accountId, token) {
|
|
calls.push({ method: 'createPairingCode', accountIdOrHostId: accountId, token })
|
|
guard()
|
|
return opts.pairing ?? { code: 'ABCD-EFGH', expiresAt: '2026-01-01T00:00:00.000Z' }
|
|
},
|
|
async deleteHost(hostId, token) {
|
|
calls.push({ method: 'deleteHost', accountIdOrHostId: hostId, token })
|
|
guard()
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Generate an Ed25519 PKCS#8 PEM (private key) for signing-key tests. */
|
|
export async function generatePkcs8Pem(): Promise<{ pem: string; publicRaw: Uint8Array }> {
|
|
const pair = (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as {
|
|
publicKey: CryptoKey
|
|
privateKey: CryptoKey
|
|
}
|
|
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey))
|
|
const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
|
|
const b64 = Buffer.from(pkcs8).toString('base64')
|
|
const lines = b64.match(/.{1,64}/g) ?? [b64]
|
|
const pem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`
|
|
return { pem, publicRaw: raw }
|
|
}
|