From 53cb0f116331e005e0222cea36ff95c1d6c22145 Mon Sep 17 00:00:00 2001 From: Andrew Bernat Date: Sat, 13 Jun 2026 14:37:03 -0700 Subject: [PATCH] Create http server for the purpose of testing. Test that client.register fails appropriately when attempting to start registration. Confirms that no credential is created on the client. This is the opening for a series of tests that validate behavior of the client using a close-to-real http server that returns real payloads. --- .../passkeys-browser/test/ceremonies.test.ts | 36 ++++ .../test/helpers/httpServer.test.ts | 84 ++++++++++ .../test/helpers/httpServer.ts | 156 ++++++++++++++++++ clients/passkeys-browser/tsconfig.json | 10 +- 4 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 clients/passkeys-browser/test/helpers/httpServer.test.ts create mode 100644 clients/passkeys-browser/test/helpers/httpServer.ts diff --git a/clients/passkeys-browser/test/ceremonies.test.ts b/clients/passkeys-browser/test/ceremonies.test.ts index d868cef..f75ee6f 100644 --- a/clients/passkeys-browser/test/ceremonies.test.ts +++ b/clients/passkeys-browser/test/ceremonies.test.ts @@ -12,6 +12,8 @@ import type { PublicKeyCredentialCreationOptionsJson, PublicKeyCredentialRequestOptionsJson, } from "../src/types"; +import {HttpHandler, HttpServer} from "./helpers/httpServer"; +import * as http from "node:http"; const CREATE_OPTIONS_JSON: PublicKeyCredentialCreationOptionsJson = { rp: { id: "example.com", name: "Example" }, @@ -132,6 +134,40 @@ describe("encodeAuthenticationResponse", () => { }); }); +describe("PkAuthCeremonyClient http test", () => { + it("when startRegistration endpoint returns 500 then the client makes no attempt to create credential", async() => { + using server= await startHttpServer({ + [startPath]: errorHttpHandler, + }); + const credentialsContainer = noOpCredentialsContainer(); + const client = new PkAuthCeremonyClient( + { apiBase: server.url }, + { credentials: credentialsContainer }, + ); + + await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow("HTTP 500: unit test error from errorHttpHandler"); + expect(credentialsContainer.create).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration + }) + + const startPath = "/auth/passkeys/registration/start"; + //currently not used: const finishPath = "/auth/passkeys/registration/finish" + + function noOpCredentialsContainer() : CredentialsContainer { + return { create: vi.fn(), get: vi.fn(), preventSilentAccess: vi.fn(), store: vi.fn()}; + } + + function errorHttpHandler(_: http.IncomingMessage, res: http.ServerResponse) : void { + res.writeHead(500) + res.end("unit test error from errorHttpHandler") + } + + async function startHttpServer(routes: Record) : Promise { + const server = new HttpServer(routes); + await server.listen(); + return server + } +}); + describe("PkAuthCeremonyClient.register (end-to-end with stubbed credentials)", () => { it("walks start -> create -> finish, propagating the challenge id", async () => { const startBody: PublicKeyCredentialCreationOptionsJson = CREATE_OPTIONS_JSON; diff --git a/clients/passkeys-browser/test/helpers/httpServer.test.ts b/clients/passkeys-browser/test/helpers/httpServer.test.ts new file mode 100644 index 0000000..0abf821 --- /dev/null +++ b/clients/passkeys-browser/test/helpers/httpServer.test.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from "vitest"; +import {HttpServer, decodeJson, encodeJson} from "./httpServer"; + +describe("Httpserver", () => { + it("routes a POST request to the matching handler and returns its response", async () => { + await using server = new HttpServer({ + "/hello": async (req, res) => { + const body = await decodeJson<{ msg: string }>(req); + await encodeJson({ echo: body.msg }, res); + }, + }); + await server.listen(); + + const res = await fetch(`${server.url}/hello`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ msg: "world" }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ echo: "world" }); + }); + + it("returns 404 for an unregistered path", async () => { + await using server = new HttpServer({}); + await server.listen(); + + const res = await fetch(`${server.url}/missing`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(404); + }); + + it("returns 405 for a non-POST request", async () => { + await using server = new HttpServer({ + "/hello": async (_req, res) => { res.writeHead(200); res.end(); }, + }); + await server.listen(); + + const res = await fetch(`${server.url}/hello`, { method: "GET" }); + + expect(res.status).toBe(405); + }); + + it("returns 500 when the handler throws", async () => { + await using server = new HttpServer({ + "/boom": () => { + throw new Error("oops"); + }, + }); + await server.listen(); + + const res = await fetch(`${server.url}/boom`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(500); + }); + + it("forwards a non-200 status from the handler", async () => { + await using server = new HttpServer({ + "/fail": async (_req, res) => { + res.writeHead(403); + res.end('{ "error": "nope" }'); + }, + }); + await server.listen(); + + const res = await fetch(`${server.url}/fail`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "nope" }); + }); +}); diff --git a/clients/passkeys-browser/test/helpers/httpServer.ts b/clients/passkeys-browser/test/helpers/httpServer.ts new file mode 100644 index 0000000..3c9ffea --- /dev/null +++ b/clients/passkeys-browser/test/helpers/httpServer.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +import * as http from "node:http"; +import {ServerResponse} from "node:http"; + +/* +HttpServer is a server used for testing purposes. +Pass in a map of path to handler and any POST request on that path +will invoke the respective handler. +- listen() sets up the server to listen on a random port +- close() shuts the server down +- async dispose() to support async using similar to auto close in java +- url to return the url the server is listening on +A common usage is: + await using server = new HttpServer({ + "/hello": async (req, res) => { + w.writeHead(200, { "content-type"": "text/plain"" }); + w.end("howdy"); + }, + }); + await server.listen(); + console.log(server.url) + */ +export class HttpServer { + private readonly server: http.Server; + private _url = ""; + + constructor(private readonly routes: Record) { + this.server = http.createServer((req, res) => void this.handle(req, res)); + } + + listen(): Promise { + return new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(0, "127.0.0.1", () => { + this.server.off("error", reject); + const addr = this.server.address() as { port: number }; + this._url = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + } + + close(): Promise { + return new Promise((resolve, reject) => { + this.server.close((err) => (err ? reject(err) : resolve())); + }); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + [Symbol.dispose](): Promise { + return this.close(); + } + + get url(): string { + if (!this._url) throw new Error("HttpServer: call listen() before accessing url"); + return this._url; + } + + private async handle( + req: http.IncomingMessage, + res: http.ServerResponse, + ): Promise { + const path = req.url ?? ""; + const handler = this.routes[path]; + + if (!handler) { + res.writeHead(404); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405); + res.end(); + return; + } + + try { + await handler(req, res); + } catch(err) { + errorHttp(err, res) + } + } +} + +// Handler has three pieces: a decoder to decode http requests to a structured request, +// an endpoint to take a structured request and return a structured response, +// an encoder to take a structured response and encode it on the wire as an http response. +export class Handler { + constructor( + readonly decoder: Decoder, + readonly endpoint: Endpoint, + readonly encoder: Encoder, + ) {} +} + +// Decoder decodes an http request to a structured type. +export type Decoder = (msg: http.IncomingMessage) => Promise | Req; + +// Encoder takes a structured type and writes it as an http response with headers and status code. +export type Encoder = (res: Res, w: http.ServerResponse) => Promise | void; + +// Endpoint is the business logic. It takes a structured request and returns a structured response. +// An endpoint is agnostic of the transport; it knows nothing about http requests and responses. +export type Endpoint = (req: Req) => Promise; + +// HttpHandler takes in an http request; and actions on the http response. +export type HttpHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void; + +export class HttpError extends Error { + constructor(readonly status: number, readonly body: unknown) { + super(`HTTP ${status}`); + } +} + +export function newHttpHandler(handler: Handler): HttpHandler { + return async (req, res) => { + try { + const request = await handler.decoder(req); + const response = await handler.endpoint(request); + await handler.encoder(response, res); + } catch (err) { + errorHttp(err, res) + } + }; +} + +export async function encodeJson(res: T, w: http.ServerResponse): Promise { + w.writeHead(200, { [contentType]: applicationJson }); + w.end(JSON.stringify(res)); +} + +export async function decodeJson(msg: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of msg) chunks.push(chunk as Buffer); + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as T; +} + +function errorHttp(err: any, res: ServerResponse) { + const headers = { [contentType]: applicationJson } + if (err instanceof HttpError) { + res.writeHead(err.status, headers); + res.end(JSON.stringify(err.body)); + } else if (err instanceof Error) { + res.writeHead(500); + res.end(err.message); + } else { + res.writeHead(500); + res.end(); + } +} + +const contentType = 'content-type'; +const applicationJson = 'application/json'; diff --git a/clients/passkeys-browser/tsconfig.json b/clients/passkeys-browser/tsconfig.json index 85e81bf..29ae3ce 100644 --- a/clients/passkeys-browser/tsconfig.json +++ b/clients/passkeys-browser/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "ESNext.Disposable"], // tsup's .d.ts pipeline injects a `baseUrl`, which TypeScript 6.0 flags as a // deprecation error (TS5101). Acknowledge it explicitly; remove once tsup/ // rollup-plugin-dts stops emitting baseUrl (slated to stop working in TS 7.0). @@ -21,9 +21,11 @@ "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": false, "outDir": "./dist", - "rootDir": "./src", "types": ["node"] }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "test"] + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": ["node_modules", "dist"] }