@andrewheberle/serverless-ssh-ca-testing 0.0.0-stage → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/log.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { appendFileSync } from "node:fs"
2
+ import z from "zod"
3
+ import { formatRequest, formatValue } from "./format.js"
4
+
5
+ /**
6
+ * Messages the CA logs at error level when it has failed rather than rejected
7
+ * a request, see {@link isFault}.
8
+ */
9
+ export const FAULT_MESSAGES: readonly string[] = ["unhandled error", "unexpected error from router"]
10
+
11
+ /**
12
+ * The message of the line written by {@link LogFile.write} after a CA log line
13
+ * that indicates a fault.
14
+ */
15
+ export const FAULT_MESSAGE = "harness fault"
16
+
17
+ // the fields of a CA log line used to detect faults
18
+ const LogLineSchema = z.object({
19
+ level: z.string(),
20
+ message: z.string(),
21
+ })
22
+
23
+ /**
24
+ * Report whether a line logged by the CA indicates a fault, such as a database
25
+ * error, rather than an expected rejection of a request. Some faults are only
26
+ * logged, as the CA still issues a certificate when it cannot record it.
27
+ *
28
+ * @param line A line of output from the CA
29
+ * @returns true if the line is an error logged by the CA for a fault
30
+ */
31
+ export const isFault = (line: string): boolean => {
32
+ let json: unknown
33
+ try {
34
+ json = JSON.parse(line)
35
+ } catch {
36
+ return false
37
+ }
38
+
39
+ const result = LogLineSchema.safeParse(json)
40
+ if (!result.success || result.data.level.toLowerCase() !== "error") {
41
+ return false
42
+ }
43
+
44
+ return FAULT_MESSAGES.includes(result.data.message) || result.data.message.includes("database")
45
+ }
46
+
47
+ /**
48
+ * The log file of a test server, written as JSON lines.
49
+ *
50
+ * Lines from the CA are written as they are logged. The harness adds the
51
+ * following lines, whose messages all start with "harness":
52
+ *
53
+ * - `{"level":"info","message":"harness request",...}` for each request, with
54
+ * `method`, `url`, `status` and, when `status` is 400 or more, the response
55
+ * `body`.
56
+ * - `{"level":"error","message":"harness ...",...}` for a fault in the CA or
57
+ * harness. A CA log line that indicates a fault (see {@link isFault}) is
58
+ * followed by a line with the message {@link FAULT_MESSAGE}.
59
+ */
60
+ export class LogFile {
61
+ readonly path: string
62
+
63
+ constructor(path: string) {
64
+ this.path = path
65
+ }
66
+
67
+ /**
68
+ * Append a line of output from the CA, followed by a fault line if it
69
+ * indicates a fault.
70
+ */
71
+ write(line: string): void {
72
+ this.append(line)
73
+ if (isFault(line)) {
74
+ this.append(formatValue({ level: "error", message: FAULT_MESSAGE, line }))
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Append a request handled by the CA.
80
+ */
81
+ request(method: string, url: string, status: number, body: string): void {
82
+ this.append(formatRequest(method, url, status, body))
83
+ }
84
+
85
+ /**
86
+ * Append a fault in the harness.
87
+ *
88
+ * @param message A description of the fault, which is prefixed with "harness"
89
+ * @param error The error that caused the fault
90
+ */
91
+ fault(message: string, error: unknown): void {
92
+ this.append(formatValue({ level: "error", message: `harness ${message}`, error }))
93
+ }
94
+
95
+ private append(line: string): void {
96
+ appendFileSync(this.path, line + "\n")
97
+ }
98
+ }
package/src/node.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { createServer, type IncomingMessage } from "node:http"
2
+ import { DatabaseSync } from "node:sqlite"
3
+ import { createSshCa } from "@andrewheberle/serverless-ssh-ca"
4
+ import { bindingsFromEnv, fromNodeSqlite, secretFromString } from "@andrewheberle/serverless-ssh-ca/node"
5
+ import type { TestServerConfig } from "./config.js"
6
+ import { formatArgs } from "./format.js"
7
+ import type { LogFile } from "./log.js"
8
+ import type { TestServer } from "./server.js"
9
+
10
+ const consoleMethods = ["debug", "info", "log", "warn", "error"] as const
11
+
12
+ // convert a request received by the HTTP server into a Fetch API request
13
+ const toRequest = async (req: IncomingMessage): Promise<Request> => {
14
+ const chunks: Buffer[] = []
15
+ for await (const chunk of req) {
16
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
17
+ }
18
+
19
+ const headers = new Headers()
20
+ for (const [name, values] of Object.entries(req.headersDistinct)) {
21
+ for (const value of values ?? []) {
22
+ headers.append(name, value)
23
+ }
24
+ }
25
+
26
+ const method = req.method ?? "GET"
27
+
28
+ return new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
29
+ method,
30
+ headers,
31
+ body: method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks),
32
+ })
33
+ }
34
+
35
+ /**
36
+ * Run the CA on Node.js, created with the package's Node.js helpers and an
37
+ * in-memory `node:sqlite` database, behind an HTTP server on a random port.
38
+ *
39
+ * Output the CA writes with `console` is written to `log` until the server is
40
+ * closed.
41
+ *
42
+ * @param config The test server configuration
43
+ * @param log The log file for the CA and harness
44
+ * @returns The running server
45
+ * @throws InvalidConfigurationError if `config.bindings` is not a valid CA configuration
46
+ */
47
+ export const startNode = async (config: TestServerConfig, log: LogFile): Promise<TestServer> => {
48
+ const db = new DatabaseSync(":memory:")
49
+
50
+ // bindingsFromEnv validates the configuration so a mistake fails at start up
51
+ const ca = createSshCa(bindingsFromEnv({
52
+ DB: fromNodeSqlite(db),
53
+ PRIVATE_KEY: secretFromString(config.private_key),
54
+ }, config.bindings))
55
+
56
+ // the CA logs JSON lines via console, so send them to the log file
57
+ const original = consoleMethods.map(level => [level, console[level]] as const)
58
+ for (const level of consoleMethods) {
59
+ console[level] = (...args: unknown[]): void => log.write(formatArgs(args))
60
+ }
61
+ const restoreConsole = (): void => {
62
+ for (const [level, method] of original) {
63
+ console[level] = method
64
+ }
65
+ }
66
+
67
+ const server = createServer(async (req, res) => {
68
+ try {
69
+ const request = await toRequest(req)
70
+ const response = await ca.fetch(request)
71
+ const body = Buffer.from(await response.arrayBuffer())
72
+
73
+ log.request(request.method, req.url ?? "/", response.status, body.toString())
74
+
75
+ res.writeHead(response.status, Object.fromEntries(response.headers))
76
+ res.end(body)
77
+ } catch (err) {
78
+ log.fault("request failed", err)
79
+ res.writeHead(502)
80
+ res.end()
81
+ }
82
+ })
83
+
84
+ try {
85
+ await new Promise<void>((resolve, reject) => {
86
+ server.once("error", reject)
87
+ server.listen(0, "127.0.0.1", () => resolve())
88
+ })
89
+ } catch (err) {
90
+ restoreConsole()
91
+ db.close()
92
+ throw err
93
+ }
94
+
95
+ const address = server.address()
96
+ if (address === null || typeof address === "string") {
97
+ restoreConsole()
98
+ server.close()
99
+ db.close()
100
+ throw new Error(`unexpected server address: ${String(address)}`)
101
+ }
102
+
103
+ return {
104
+ port: address.port,
105
+ close: async (): Promise<void> => {
106
+ server.closeAllConnections()
107
+ await new Promise<void>(resolve => server.close(() => resolve()))
108
+ restoreConsole()
109
+ db.close()
110
+ },
111
+ }
112
+ }
package/src/server.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A running test server, returned by {@link startTestServer}.
3
+ */
4
+ export type TestServer = {
5
+ /** The port the CA is listening on at 127.0.0.1 */
6
+ port: number
7
+ /** Stop the CA and release its resources */
8
+ close(): Promise<void>
9
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Worker entry point used by startWorkerd, bundled by Wrangler when the test
2
+ // server starts.
3
+ //
4
+ // The CA's default export is used as on Cloudflare Workers, wrapped only to
5
+ // log each request and to write console output as JSON lines, as the Node.js
6
+ // runtime does.
7
+ import ca from "@andrewheberle/serverless-ssh-ca"
8
+ import { formatArgs, formatRequest } from "./format.js"
9
+
10
+ type FetchParameters = Parameters<typeof ca.fetch>
11
+
12
+ // workerd formats objects passed to console for display rather than as JSON,
13
+ // so format them here
14
+ for (const level of ["debug", "info", "log", "warn", "error"] as const) {
15
+ const write = console[level].bind(console)
16
+ console[level] = (...args: unknown[]): void => write(formatArgs(args))
17
+ }
18
+
19
+ export default {
20
+ ...ca,
21
+ async fetch(request: FetchParameters[0], env: FetchParameters[1], ctx: FetchParameters[2]): Promise<Response> {
22
+ const response = await ca.fetch(request, env, ctx)
23
+ const url = new URL(request.url)
24
+ const body = response.status >= 400 ? await response.clone().text() : ""
25
+
26
+ console.log(formatRequest(request.method, url.pathname + url.search, response.status, body))
27
+
28
+ return response
29
+ },
30
+ }
package/src/workerd.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { fileURLToPath } from "node:url"
2
+ import { createTestHarness } from "wrangler"
3
+ import type { TestServerConfig } from "./config.js"
4
+ import type { LogFile } from "./log.js"
5
+ import type { TestServer } from "./server.js"
6
+
7
+ // the Secrets Store binding exposes an admin API in local development, used
8
+ // to create the CA private key secret
9
+ type SecretsStoreAdmin = {
10
+ create(value: string): Promise<string>
11
+ }
12
+
13
+ type WorkerEnv = {
14
+ PRIVATE_KEY: {
15
+ "SecretsStoreSecret::admin_api": () => Promise<SecretsStoreAdmin>
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The Workers compatibility date the CA is run with, which matches the
21
+ * configuration the CA is tested with on Workers.
22
+ */
23
+ export const COMPATIBILITY_DATE = "2026-03-13"
24
+
25
+ /**
26
+ * Run the CA under workerd, the Cloudflare Workers runtime, using the Wrangler
27
+ * test harness as "wrangler dev" would, with a local D1 database and Secrets
28
+ * Store that are not persisted.
29
+ *
30
+ * The CA's output is copied to `log` every 100ms and when the server is
31
+ * closed. Errors from workerd itself, such as an uncaught exception, are
32
+ * logged as harness faults.
33
+ *
34
+ * @param config The test server configuration
35
+ * @param log The log file for the CA and harness
36
+ * @param root The directory Wrangler's working files are written to
37
+ * @returns The running server
38
+ */
39
+ export const startWorkerd = async (config: TestServerConfig, log: LogFile, root: string): Promise<TestServer> => {
40
+ // use placeholder request.cf data rather than downloading it from
41
+ // Cloudflare and caching it in the working directory
42
+ process.env.CLOUDFLARE_CF_FETCH_ENABLED = "false"
43
+
44
+ const server = createTestHarness({
45
+ root,
46
+ workers: [{
47
+ config: {
48
+ name: "serverless-ssh-ca",
49
+ main: fileURLToPath(new URL("./worker.js", import.meta.url)),
50
+ compatibility_date: COMPATIBILITY_DATE,
51
+ compatibility_flags: ["nodejs_compat"],
52
+ vars: config.bindings,
53
+ d1_databases: [{
54
+ binding: "DB",
55
+ database_name: "ssh-ca-database",
56
+ database_id: "00000000-0000-0000-0000-000000000000",
57
+ }],
58
+ secrets_store_secrets: [{
59
+ binding: "PRIVATE_KEY",
60
+ store_id: "test",
61
+ secret_name: "ssh-ca-private-key",
62
+ }],
63
+ },
64
+ }],
65
+ })
66
+
67
+ // copy the runtime logs to the log file. Errors that are not JSON lines
68
+ // from the CA are from workerd, such as an uncaught exception, so are
69
+ // logged as a harness fault.
70
+ const flushLogs = (): void => {
71
+ for (const entry of server.getLogs()) {
72
+ let json = true
73
+ try {
74
+ JSON.parse(entry.message)
75
+ } catch {
76
+ json = false
77
+ }
78
+
79
+ if (json || entry.level !== "error") {
80
+ log.write(entry.message)
81
+ } else {
82
+ log.fault("runtime error", entry.message)
83
+ }
84
+ }
85
+ server.clearLogs()
86
+ }
87
+
88
+ try {
89
+ const { url } = await server.listen()
90
+
91
+ // the secret must exist before the port is reported and requests are made
92
+ const env = await server.getWorker<WorkerEnv>().getEnv()
93
+ const secrets = await env.PRIVATE_KEY["SecretsStoreSecret::admin_api"]()
94
+ await secrets.create(config.private_key)
95
+
96
+ const timer = setInterval(flushLogs, 100)
97
+
98
+ return {
99
+ port: Number(url.port),
100
+ close: async (): Promise<void> => {
101
+ clearInterval(timer)
102
+ flushLogs()
103
+ await server.close()
104
+ },
105
+ }
106
+ } catch (err) {
107
+ flushLogs()
108
+ await server.close()
109
+ throw err
110
+ }
111
+ }