detached 388d3be

passkey.test.mjs RawHistoryFolder

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});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close