1// Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and
2// navigator.credentials stubbed. Run with
3// node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { test as nodeTest, beforeEach, afterEach } from 'node:test';
6import { dom, el, installDom } from './dom.mjs';
7
8// Every test has two seconds: a ceremony that never settles fails the test
9// instead of hanging the run.
10const test = (title, fn) => nodeTest(title, { timeout: 2000 }, fn);
11
12const doc = installDom({ parseHTML: () => [] });
13// The script registers its listeners when it loads: record them.
14const listeners = [];
15doc.addEventListener = (type, fn) => { listeners.push([type, fn]); };
16// Node defines navigator as a getter: replace it, as a browser test would.
17const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
18setNavigator({ credentials: {} });
19globalThis.PublicKeyCredential = undefined;
20await import('../static/js/passkey.js');
21const P = window.ermitePasskey;
22
23const OPTIONS = { publicKey: { challenge: 'abc' } };
24const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
25
26let fetches; // {url, init}
27let answers; // url -> {ok, status, body} (body undefined: not JSON)
28let parsed; // [kind, publicKey]
29let nav; // where the page was sent: 'assign:/' or 'reload'
30let status;
31let name;
32
33// page builds the DOM of the login page (a form with the sign-in button) or of
34// the passkeys page (a form with the name field and the add button). Both
35// buttons are submit buttons of a [data-passkey-form].
36function page(kind) {
37 dom.body = el('body');
38 dom.focused = [];
39 status = el('p', { attrs: { 'data-passkey-status': '' } });
40 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
41 const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish', type: 'submit' } });
42 const form = el('form', { attrs: { 'data-passkey-form': '' } }, ...(kind === 'register' ? [name, btn] : [btn]));
43 dom.body.append(status, form);
44 btn.form = form;
45 return btn;
46}
47
48// pending holds the resolvers of the authenticator prompts a test left open;
49// afterEach answers them, so that no promise outlives its test.
50let pending = [];
51const prompt = (value) => new Promise((resolve) => { pending.push(() => resolve(value)); });
52afterEach(() => { pending.forEach((release) => release()); pending = []; });
53
54beforeEach(() => {
55 fetches = [];
56 parsed = [];
57 nav = '';
58 answers = {
59 '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
60 '/finish': { ok: true, status: 204, body: undefined },
61 };
62 globalThis.fetch = async (url, init) => {
63 fetches.push({ url, init });
64 const a = answers[url];
65 return {
66 ok: a.ok,
67 status: a.status,
68 json: async () => {
69 if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input');
70 return a.body;
71 },
72 };
73 };
74 globalThis.PublicKeyCredential = {
75 parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
76 parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; },
77 };
78 setNavigator({
79 credentials: {
80 get: async (o) => { navigator.got = o; return CRED; },
81 create: async (o) => { navigator.created = o; return CRED; },
82 },
83 });
84 globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
85});
86
87// submitOn sends a submit event from a form (a click on its submit button, or
88// Enter in a field). It answers the promise of the handler and whether the
89// browser's own navigation was kept (no preventDefault).
90const submitOn = (target) => {
91 let prevented = false;
92 const p = P.submit({ target, preventDefault() { prevented = true; } });
93 return { p, prevented: () => prevented };
94};
95const submitForm = (btn) => submitOn(btn.form).p;
96const jsonBody = (i) => JSON.parse(fetches[i].init.body);
97const beginCalls = () => fetches.filter((f) => f.url === '/begin').length;
98const tick = () => new Promise((r) => setTimeout(r, 0));
99
100test('the script registers exactly one submit listener and no click listener', () => {
101 assert.deepEqual(listeners.map(([t]) => t), ['submit']);
102 assert.equal(listeners[0][1], P.submit);
103 assert.deepEqual(Object.keys(P).sort(), ['init', 'nameProblem', 'submit'], 'only what the tests need is exported');
104});
105
106test('login: begin, get, finish with toJSON(), then home', async () => {
107 const btn = page('login');
108 const { p, prevented } = submitOn(btn.form);
109 await p;
110
111 assert.equal(prevented(), true, 'the form never navigates by itself');
112 assert.equal(fetches.length, 2);
113 assert.equal(fetches[0].url, '/begin');
114 assert.equal(fetches[0].init.method, 'POST');
115 assert.equal(fetches[0].init.credentials, 'same-origin');
116 assert.equal(fetches[0].init.body, undefined, 'a sign-in sends no name');
117 assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
118 assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
119
120 assert.equal(fetches[1].url, '/finish');
121 assert.equal(fetches[1].init.method, 'POST');
122 assert.equal(fetches[1].init.credentials, 'same-origin');
123 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
124 assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } });
125 assert.equal(nav, 'assign:/');
126 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
127});
128
129test('register: the trimmed name goes to begin only, then reload', async () => {
130 const btn = page('register');
131 const { p, prevented } = submitOn(btn.form);
132 await p;
133
134 assert.equal(prevented(), true);
135 assert.equal(fetches[0].url, '/begin');
136 assert.equal(fetches[0].init.credentials, 'same-origin');
137 assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');
138 assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' });
139 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
140 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
141 assert.equal(fetches[1].init.credentials, 'same-origin');
142 assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } }, 'the finish body has no name');
143 assert.equal(nav, 'reload');
144});
145
146test('a submit from another form is not ours and keeps its default', async () => {
147 page('register');
148 const other = el('form', {}, el('input'));
149 dom.body.append(other);
150 const { p, prevented } = submitOn(other);
151 await p;
152 assert.equal(fetches.length, 0);
153 assert.equal(prevented(), false, 'other forms submit as they always did');
154});
155
156// While the ceremony waits for the authenticator the button is busy and
157// disabled, the status says so, and a second submit starts nothing: the
158// disabled button is the guard.
159test('busy: aria-busy, the waiting text, and a double submit makes one begin', async () => {
160 const btn = page('login');
161 navigator.credentials.get = () => prompt(CRED);
162
163 const first = submitForm(btn);
164 await tick();
165 assert.equal(btn.getAttribute('aria-busy'), 'true');
166 assert.equal(btn.disabled, true);
167 assert.equal(status.textContent, 'waiting for your passkey…');
168
169 await submitForm(btn); // the double click, or Enter
170 assert.equal(beginCalls(), 1, 'one begin only');
171
172 pending.forEach((release) => release());
173 await first;
174 assert.equal(beginCalls(), 1);
175 assert.equal(nav, 'assign:/');
176});
177
178test('a double submit of the add form makes one begin', async () => {
179 const btn = page('register');
180 navigator.credentials.create = () => prompt(CRED);
181 const first = submitForm(btn);
182 await tick();
183 await submitForm(btn);
184 assert.equal(beginCalls(), 1);
185 pending.forEach((release) => release());
186 await first;
187 assert.equal(nav, 'reload');
188});
189
190test('after a failure the page is free for another try', async () => {
191 const btn = page('register');
192 answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };
193 await submitForm(btn);
194 assert.equal(status.textContent, 'nope');
195 assert.equal(btn.disabled, false);
196 await submitForm(btn);
197 assert.equal(beginCalls(), 2, 'the second try ran');
198});
199
200test('on failure the button is enabled again, not busy, and has the focus', async () => {
201 const btn = page('login');
202 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
203 await submitForm(btn);
204 assert.equal(status.textContent, 'passkey not accepted');
205 assert.equal(btn.disabled, false);
206 assert.equal(btn.hasAttribute('aria-busy'), false);
207 assert.equal(nav, '');
208 assert.deepEqual(dom.focused, [btn], 'the focus is given back');
209});
210
211test('on success the button stays busy and disabled, without stealing focus', async () => {
212 const btn = page('login');
213 await submitForm(btn);
214 assert.equal(btn.disabled, true);
215 assert.equal(btn.getAttribute('aria-busy'), 'true');
216 assert.deepEqual(dom.focused, []);
217});
218
219test('begin refused: the server reason is shown, nothing else is called', async () => {
220 const btn = page('login');
221 answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
222 await submitForm(btn);
223 assert.equal(status.textContent, 'too many attempts');
224 assert.equal(fetches.length, 1);
225 assert.equal(nav, '');
226 assert.equal(btn.disabled, false);
227 assert.deepEqual(dom.focused, [btn]);
228});
229
230// The server can refuse the name at begin: the field is what to fix, so it
231// gets the focus. Any other refusal gives the button the focus.
232test('a 400 from register/begin focuses the name field', async () => {
233 const btn = page('register');
234 answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } };
235 await submitForm(btn);
236 assert.match(status.textContent, /control character/);
237 assert.equal(navigator.created, undefined, 'create() is not called');
238 assert.equal(btn.disabled, false);
239 assert.deepEqual(dom.focused, [name]);
240});
241
242test('other refusals of register give the focus back to the button', async () => {
243 for (const [at, code] of [['/begin', 429], ['/begin', 403], ['/begin', 500], ['/finish', 400], ['/finish', 409]]) {
244 const btn = page('register');
245 answers = {
246 '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
247 '/finish': { ok: true, status: 204, body: undefined },
248 [at]: { ok: false, status: code, body: { error: 'refused' } },
249 };
250 await submitForm(btn);
251 assert.deepEqual(dom.focused, [btn], at + ' ' + code);
252 }
253});
254
255test('a 400 at begin on the login page focuses the button: there is no name', async () => {
256 const btn = page('login');
257 answers['/begin'] = { ok: false, status: 400, body: { error: 'malformed request' } };
258 await submitForm(btn);
259 assert.deepEqual(dom.focused, [btn]);
260});
261
262test('a refusal without a JSON body falls back to the status', async () => {
263 const btn = page('login');
264 answers['/finish'] = { ok: false, status: 502, body: undefined };
265 await submitForm(btn);
266 assert.equal(status.textContent, 'the server answered 502');
267 assert.equal(nav, '');
268});
269
270test('a 403 without a reason says the session ended', async () => {
271 for (const body of [undefined, { error: 'forbidden' }, {}]) {
272 fetches = [];
273 const btn = page('register');
274 answers['/begin'] = { ok: false, status: 403, body };
275 await submitForm(btn);
276 assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again');
277 assert.equal(fetches.length, 1);
278 }
279 // At finish too.
280 const btn = page('login');
281 answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };
282 answers['/finish'] = { ok: false, status: 403, body: undefined };
283 await submitForm(btn);
284 assert.match(status.textContent, /your session ended/);
285});
286
287test('a 403 with a reason of its own shows that reason', async () => {
288 const btn = page('register');
289 answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };
290 await submitForm(btn);
291 assert.equal(status.textContent, 'the owner must sign in first');
292});
293
294test('the owner cancels the prompt', async () => {
295 const btn = page('login');
296 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
297 await submitForm(btn);
298 assert.match(status.textContent, /cancelled, or it took too long/);
299 assert.equal(fetches.length, 1, 'finish is not called');
300 assert.equal(btn.disabled, false);
301 assert.deepEqual(dom.focused, [btn]);
302});
303
304test('AbortError says cancelled', async () => {
305 const btn = page('login');
306 navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };
307 await submitForm(btn);
308 assert.equal(status.textContent, 'cancelled');
309 assert.equal(fetches.length, 1);
310 assert.equal(btn.disabled, false);
311});
312
313test('an authenticator that already holds a passkey', async () => {
314 const btn = page('register');
315 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
316 await submitForm(btn);
317 assert.match(status.textContent, /already holds a passkey/);
318 assert.equal(fetches.length, 1);
319});
320
321test('a network failure is reported', async () => {
322 const btn = page('login');
323 globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
324 await submitForm(btn);
325 assert.equal(status.textContent, 'Failed to fetch');
326 assert.equal(btn.disabled, false);
327});
328
329test('a null credential is not sent', async () => {
330 const btn = page('login');
331 navigator.credentials.get = async () => null;
332 await submitForm(btn);
333 assert.equal(status.textContent, 'no passkey was returned');
334 assert.equal(fetches.length, 1);
335});
336
337test('a credential without toJSON is reported, not sent', async () => {
338 for (const kind of ['login', 'register']) {
339 fetches = [];
340 const btn = page(kind);
341 const bare = { id: 'x' };
342 navigator.credentials.get = async () => bare;
343 navigator.credentials.create = async () => bare;
344 await submitForm(btn);
345 assert.match(status.textContent, /cannot send; update the browser/);
346 assert.equal(fetches.length, 1, 'finish is not called');
347 assert.equal(btn.disabled, false);
348 }
349});
350
351// The begin answer is checked before it reaches the browser API.
352test('a begin answer that is not JSON', async () => {
353 const btn = page('login');
354 answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON
355 await submitForm(btn);
356 assert.equal(status.textContent, 'the server sent an unexpected answer');
357 assert.equal(parsed.length, 0);
358 assert.equal(btn.disabled, false);
359});
360
361test('a begin answer with the wrong shape', async () => {
362 for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } },
363 { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) {
364 fetches = [];
365 parsed = [];
366 const btn = page('login');
367 answers['/begin'] = { ok: true, status: 200, body };
368 await submitForm(btn);
369 assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));
370 assert.equal(parsed.length, 0, 'nothing reached the browser API');
371 assert.equal(fetches.length, 1);
372 }
373});
374
375// A name the server would refuse is caught before the owner is prompted.
376test('a name over 64 bytes (UTF-8) stops before begin', async () => {
377 const btn = page('register');
378 name.value = 'é'.repeat(33); // 33 characters, 66 bytes
379 await submitForm(btn);
380 assert.match(status.textContent, /longer than 64 bytes/);
381 assert.equal(fetches.length, 0, 'no request');
382 assert.equal(navigator.created, undefined, 'create() is not called');
383 assert.notEqual(btn.disabled, true, 'the button was never disabled');
384 assert.deepEqual(dom.focused, [name], 'the field gets the focus');
385 // The check is not a ceremony: the next try is not blocked.
386 name.value = 'ok';
387 await submitForm(btn);
388 assert.equal(fetches.length, 2);
389});
390
391test('64 bytes is fine, in ASCII or in two-byte characters', async () => {
392 for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) {
393 fetches = [];
394 const btn = page('register'); // a success navigates away: each try is a new page
395 name.value = ok;
396 await submitForm(btn);
397 assert.equal(fetches.length, 2, ok.length + ' characters');
398 }
399});
400
401test('control and direction characters stop before begin', async () => {
402 for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) {
403 fetches = [];
404 const btn = page('register');
405 name.value = bad;
406 await submitForm(btn);
407 assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));
408 assert.equal(fetches.length, 0, JSON.stringify(bad));
409 }
410});
411
412test('plain text with a zero width joiner, accents and emoji is fine', () => {
413 for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) {
414 assert.equal(P.nameProblem(ok), '', JSON.stringify(ok));
415 }
416});
417
418test('an empty name is allowed: the server names the passkey', async () => {
419 const btn = page('register');
420 name.value = ' ';
421 await submitForm(btn);
422 assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });
423 assert.equal(fetches.length, 2);
424});
425
426test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
427 const login = page('login');
428 const register = el('button', { attrs: { 'data-passkey': 'register' } });
429 dom.body.append(register);
430 globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON
431 P.init();
432 assert.equal(login.disabled, true);
433 assert.equal(register.disabled, true);
434 assert.equal(status.textContent, 'this browser does not support passkeys');
435
436 login.disabled = false;
437 await submitForm(login);
438 assert.equal(fetches.length, 0);
439 assert.equal(status.textContent, 'this browser does not support passkeys');
440});
441
442test('no PublicKeyCredential at all', () => {
443 const btn = page('login');
444 globalThis.PublicKeyCredential = undefined;
445 P.init();
446 assert.equal(btn.disabled, true);
447 assert.equal(status.textContent, 'this browser does not support passkeys');
448});
449
450test('a supported browser keeps the buttons enabled', () => {
451 const btn = page('login');
452 P.init();
453 assert.equal(btn.disabled, undefined);
454 assert.equal(status.textContent, '');
455});
456
457test('a disabled button ignores a submit', async () => {
458 const btn = page('login');
459 btn.disabled = true;
460 await submitForm(btn);
461 assert.equal(fetches.length, 0);
462});
463
464test('register needs parseCreationOptionsFromJSON', async () => {
465 const btn = page('register');
466 delete PublicKeyCredential.parseCreationOptionsFromJSON;
467 await submitForm(btn);
468 assert.equal(fetches.length, 0);
469 assert.equal(status.textContent, 'this browser does not support passkeys');
470});