Skip to content

Commit 53cb0f1

Browse files
author
Andrew Bernat
committed
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.
1 parent 8fc5bc5 commit 53cb0f1

4 files changed

Lines changed: 282 additions & 4 deletions

File tree

clients/passkeys-browser/test/ceremonies.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type {
1212
PublicKeyCredentialCreationOptionsJson,
1313
PublicKeyCredentialRequestOptionsJson,
1414
} from "../src/types";
15+
import {HttpHandler, HttpServer} from "./helpers/httpServer";
16+
import * as http from "node:http";
1517

1618
const CREATE_OPTIONS_JSON: PublicKeyCredentialCreationOptionsJson = {
1719
rp: { id: "example.com", name: "Example" },
@@ -132,6 +134,40 @@ describe("encodeAuthenticationResponse", () => {
132134
});
133135
});
134136

137+
describe("PkAuthCeremonyClient http test", () => {
138+
it("when startRegistration endpoint returns 500 then the client makes no attempt to create credential", async() => {
139+
using server= await startHttpServer({
140+
[startPath]: errorHttpHandler,
141+
});
142+
const credentialsContainer = noOpCredentialsContainer();
143+
const client = new PkAuthCeremonyClient(
144+
{ apiBase: server.url },
145+
{ credentials: credentialsContainer },
146+
);
147+
148+
await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow("HTTP 500: unit test error from errorHttpHandler");
149+
expect(credentialsContainer.create).not.toHaveBeenCalled(); // this proves the client did not attempt to create a credential upon failure to start registration
150+
})
151+
152+
const startPath = "/auth/passkeys/registration/start";
153+
//currently not used: const finishPath = "/auth/passkeys/registration/finish"
154+
155+
function noOpCredentialsContainer() : CredentialsContainer {
156+
return { create: vi.fn(), get: vi.fn(), preventSilentAccess: vi.fn(), store: vi.fn()};
157+
}
158+
159+
function errorHttpHandler(_: http.IncomingMessage, res: http.ServerResponse) : void {
160+
res.writeHead(500)
161+
res.end("unit test error from errorHttpHandler")
162+
}
163+
164+
async function startHttpServer(routes: Record<string, HttpHandler>) : Promise<HttpServer> {
165+
const server = new HttpServer(routes);
166+
await server.listen();
167+
return server
168+
}
169+
});
170+
135171
describe("PkAuthCeremonyClient.register (end-to-end with stubbed credentials)", () => {
136172
it("walks start -> create -> finish, propagating the challenge id", async () => {
137173
const startBody: PublicKeyCredentialCreationOptionsJson = CREATE_OPTIONS_JSON;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, expect, it } from "vitest";
3+
import {HttpServer, decodeJson, encodeJson} from "./httpServer";
4+
5+
describe("Httpserver", () => {
6+
it("routes a POST request to the matching handler and returns its response", async () => {
7+
await using server = new HttpServer({
8+
"/hello": async (req, res) => {
9+
const body = await decodeJson<{ msg: string }>(req);
10+
await encodeJson({ echo: body.msg }, res);
11+
},
12+
});
13+
await server.listen();
14+
15+
const res = await fetch(`${server.url}/hello`, {
16+
method: "POST",
17+
headers: { "content-type": "application/json" },
18+
body: JSON.stringify({ msg: "world" }),
19+
});
20+
21+
expect(res.status).toBe(200);
22+
expect(await res.json()).toEqual({ echo: "world" });
23+
});
24+
25+
it("returns 404 for an unregistered path", async () => {
26+
await using server = new HttpServer({});
27+
await server.listen();
28+
29+
const res = await fetch(`${server.url}/missing`, {
30+
method: "POST",
31+
headers: { "content-type": "application/json" },
32+
body: JSON.stringify({}),
33+
});
34+
35+
expect(res.status).toBe(404);
36+
});
37+
38+
it("returns 405 for a non-POST request", async () => {
39+
await using server = new HttpServer({
40+
"/hello": async (_req, res) => { res.writeHead(200); res.end(); },
41+
});
42+
await server.listen();
43+
44+
const res = await fetch(`${server.url}/hello`, { method: "GET" });
45+
46+
expect(res.status).toBe(405);
47+
});
48+
49+
it("returns 500 when the handler throws", async () => {
50+
await using server = new HttpServer({
51+
"/boom": () => {
52+
throw new Error("oops");
53+
},
54+
});
55+
await server.listen();
56+
57+
const res = await fetch(`${server.url}/boom`, {
58+
method: "POST",
59+
headers: { "content-type": "application/json" },
60+
body: JSON.stringify({}),
61+
});
62+
63+
expect(res.status).toBe(500);
64+
});
65+
66+
it("forwards a non-200 status from the handler", async () => {
67+
await using server = new HttpServer({
68+
"/fail": async (_req, res) => {
69+
res.writeHead(403);
70+
res.end('{ "error": "nope" }');
71+
},
72+
});
73+
await server.listen();
74+
75+
const res = await fetch(`${server.url}/fail`, {
76+
method: "POST",
77+
headers: { "content-type": "application/json" },
78+
body: JSON.stringify({}),
79+
});
80+
81+
expect(res.status).toBe(403);
82+
expect(await res.json()).toEqual({ error: "nope" });
83+
});
84+
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// SPDX-License-Identifier: MIT
2+
import * as http from "node:http";
3+
import {ServerResponse} from "node:http";
4+
5+
/*
6+
HttpServer is a server used for testing purposes.
7+
Pass in a map of path to handler and any POST request on that path
8+
will invoke the respective handler.
9+
- listen() sets up the server to listen on a random port
10+
- close() shuts the server down
11+
- async dispose() to support async using similar to auto close in java
12+
- url to return the url the server is listening on
13+
A common usage is:
14+
await using server = new HttpServer({
15+
"/hello": async (req, res) => {
16+
w.writeHead(200, { "content-type"": "text/plain"" });
17+
w.end("howdy");
18+
},
19+
});
20+
await server.listen();
21+
console.log(server.url)
22+
*/
23+
export class HttpServer {
24+
private readonly server: http.Server;
25+
private _url = "";
26+
27+
constructor(private readonly routes: Record<string, HttpHandler>) {
28+
this.server = http.createServer((req, res) => void this.handle(req, res));
29+
}
30+
31+
listen(): Promise<void> {
32+
return new Promise((resolve, reject) => {
33+
this.server.once("error", reject);
34+
this.server.listen(0, "127.0.0.1", () => {
35+
this.server.off("error", reject);
36+
const addr = this.server.address() as { port: number };
37+
this._url = `http://127.0.0.1:${addr.port}`;
38+
resolve();
39+
});
40+
});
41+
}
42+
43+
close(): Promise<void> {
44+
return new Promise((resolve, reject) => {
45+
this.server.close((err) => (err ? reject(err) : resolve()));
46+
});
47+
}
48+
49+
[Symbol.asyncDispose](): Promise<void> {
50+
return this.close();
51+
}
52+
53+
[Symbol.dispose](): Promise<void> {
54+
return this.close();
55+
}
56+
57+
get url(): string {
58+
if (!this._url) throw new Error("HttpServer: call listen() before accessing url");
59+
return this._url;
60+
}
61+
62+
private async handle(
63+
req: http.IncomingMessage,
64+
res: http.ServerResponse,
65+
): Promise<void> {
66+
const path = req.url ?? "";
67+
const handler = this.routes[path];
68+
69+
if (!handler) {
70+
res.writeHead(404);
71+
res.end();
72+
return;
73+
}
74+
if (req.method !== "POST") {
75+
res.writeHead(405);
76+
res.end();
77+
return;
78+
}
79+
80+
try {
81+
await handler(req, res);
82+
} catch(err) {
83+
errorHttp(err, res)
84+
}
85+
}
86+
}
87+
88+
// Handler has three pieces: a decoder to decode http requests to a structured request,
89+
// an endpoint to take a structured request and return a structured response,
90+
// an encoder to take a structured response and encode it on the wire as an http response.
91+
export class Handler<Req, Res> {
92+
constructor(
93+
readonly decoder: Decoder<Req>,
94+
readonly endpoint: Endpoint<Req, Res>,
95+
readonly encoder: Encoder<Res>,
96+
) {}
97+
}
98+
99+
// Decoder decodes an http request to a structured type.
100+
export type Decoder<Req> = (msg: http.IncomingMessage) => Promise<Req> | Req;
101+
102+
// Encoder takes a structured type and writes it as an http response with headers and status code.
103+
export type Encoder<Res> = (res: Res, w: http.ServerResponse) => Promise<void> | void;
104+
105+
// Endpoint is the business logic. It takes a structured request and returns a structured response.
106+
// An endpoint is agnostic of the transport; it knows nothing about http requests and responses.
107+
export type Endpoint<Req, Res> = (req: Req) => Promise<Res>;
108+
109+
// HttpHandler takes in an http request; and actions on the http response.
110+
export type HttpHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void;
111+
112+
export class HttpError extends Error {
113+
constructor(readonly status: number, readonly body: unknown) {
114+
super(`HTTP ${status}`);
115+
}
116+
}
117+
118+
export function newHttpHandler<Req, Res>(handler: Handler<Req, Res>): HttpHandler {
119+
return async (req, res) => {
120+
try {
121+
const request = await handler.decoder(req);
122+
const response = await handler.endpoint(request);
123+
await handler.encoder(response, res);
124+
} catch (err) {
125+
errorHttp(err, res)
126+
}
127+
};
128+
}
129+
130+
export async function encodeJson<T>(res: T, w: http.ServerResponse): Promise<void> {
131+
w.writeHead(200, { [contentType]: applicationJson });
132+
w.end(JSON.stringify(res));
133+
}
134+
135+
export async function decodeJson<T>(msg: http.IncomingMessage): Promise<T> {
136+
const chunks: Buffer[] = [];
137+
for await (const chunk of msg) chunks.push(chunk as Buffer);
138+
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;
139+
}
140+
141+
function errorHttp(err: any, res: ServerResponse) {
142+
const headers = { [contentType]: applicationJson }
143+
if (err instanceof HttpError) {
144+
res.writeHead(err.status, headers);
145+
res.end(JSON.stringify(err.body));
146+
} else if (err instanceof Error) {
147+
res.writeHead(500);
148+
res.end(err.message);
149+
} else {
150+
res.writeHead(500);
151+
res.end();
152+
}
153+
}
154+
155+
const contentType = 'content-type';
156+
const applicationJson = 'application/json';

clients/passkeys-browser/tsconfig.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"target": "ES2022",
44
"module": "ESNext",
55
"moduleResolution": "Bundler",
6-
"lib": ["ES2022", "DOM"],
6+
"lib": ["ES2022", "DOM", "ESNext.Disposable"],
77
// tsup's .d.ts pipeline injects a `baseUrl`, which TypeScript 6.0 flags as a
88
// deprecation error (TS5101). Acknowledge it explicitly; remove once tsup/
99
// rollup-plugin-dts stops emitting baseUrl (slated to stop working in TS 7.0).
@@ -21,9 +21,11 @@
2121
"forceConsistentCasingInFileNames": true,
2222
"verbatimModuleSyntax": false,
2323
"outDir": "./dist",
24-
"rootDir": "./src",
2524
"types": ["node"]
2625
},
27-
"include": ["src/**/*.ts"],
28-
"exclude": ["node_modules", "dist", "test"]
26+
"include": [
27+
"src/**/*.ts",
28+
"test/**/*.ts"
29+
],
30+
"exclude": ["node_modules", "dist"]
2931
}

0 commit comments

Comments
 (0)