SPB Git

Repository Public

account.spec.ts 15.2 KB 257 lines typescript

1// Account life cycle through the real screens: invitation, registration, recovery codes, sign-out, sign-in, a second2// passkey, session revocation, recovery into the enrolment step; and the administrator's invitation and quota flows.3import { createHmac } from 'node:crypto';4import { type BrowserContext, type CDPSession, expect, type Page, test } from '@playwright/test';5import { apiSignUp, inviteUrl } from './helpers.ts';67test.use({ locale: 'fr-CA' });89interface Credential { credentialId: string; isResidentCredential: boolean; rpId?: string; privateKey: string; userHandle?: string; signCount: number }10/** A virtual platform authenticator whose credentials can be read, copied and cleared (helpers.ts keeps no handle on its own). */11async function authenticator(context: BrowserContext, page: Page): Promise<{ cdp: CDPSession; id: string; credentials(): Promise<Credential[]>; add(c: Credential): Promise<void>; clear(): Promise<void> }> {12  const cdp = await context.newCDPSession(page);13  await cdp.send('WebAuthn.enable');14  const { authenticatorId: id } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });15  return {16    cdp, id,17    credentials: async () => (await cdp.send('WebAuthn.getCredentials', { authenticatorId: id })).credentials as Credential[],18    add: async (credential) => { await cdp.send('WebAuthn.addCredential', { authenticatorId: id, credential }); },19    clear: async () => { await cdp.send('WebAuthn.clearCredentials', { authenticatorId: id }); },20  };21}22/** RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, as an authenticator app computes it. */23function totp(secret: string, at = Date.now()): string {24  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';25  let bits = '';26  for (const c of secret.replace(/=+$/, '')) bits += A.indexOf(c).toString(2).padStart(5, '0');27  const key = Buffer.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));28  const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000)));29  const h = createHmac('sha1', key).update(counter).digest();30  const o = h[h.length - 1]! & 15;31  return String((h.readUInt32BE(o) & 0x7fffffff) % 1_000_000).padStart(6, '0');32}33const signOut = async (page: Page) => {34  await page.getByRole('button', { name: /^Compte de / }).click();35  await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();36  await expect(page).toHaveURL(/\/login$/);37};38const signIn = async (page: Page) => {39  await page.getByRole('button', { name: 'Se connecter avec une passkey' }).click();40  await expect(page.getByRole('button', { name: /^Compte de / })).toBeVisible();41};4243test('invitation, passkeys, sessions and recovery', async ({ page, context, browser }) => {44  const { email, token } = inviteUrl();45  const auth = await authenticator(context, page);4647  // --- invitation → registration → recovery codes shown once ---48  await page.goto(`/invite/${token}`);49  await expect(page.getByText(email)).toBeVisible();50  await page.getByLabel('Votre nom').fill('Camille Tremblay');51  await expect(page.getByLabel('Nom de la passkey')).not.toHaveValue('');52  await page.getByLabel('Nom de la passkey').fill('Premier appareil');53  await page.getByRole('button', { name: 'Créer mon compte avec une passkey' }).click();54  await expect(page.getByRole('heading', { name: 'Vos codes de récupération' })).toBeVisible();55  const codes = await page.getByTestId('recovery-codes').locator('li span.select-all').allTextContents();56  expect(codes).toHaveLength(10);57  for (const c of codes) expect(c).toMatch(/^[A-Z2-7]{4}(-[A-Z2-7]{4}){3}$/);58  const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Télécharger (.txt)' }).click()]);59  expect(download.suggestedFilename()).toBe('sp-drive-codes-recuperation.txt');60  const finish = page.getByRole('button', { name: 'Continuer vers mes fichiers' });61  await expect(finish).toBeDisabled();62  await page.getByLabel('J’ai conservé ces codes').check();63  await finish.click();64  await expect(page).toHaveURL(/\/$/);65  await expect(page.getByRole('button', { name: 'Compte de Camille Tremblay' })).toBeVisible();66  // a used invitation says so67  const other = await context.newPage();68  await other.goto(`/invite/${token}`);69  await expect(other.getByRole('heading', { name: 'Invitation invalide ou expirée' })).toBeVisible();70  await other.close();7172  // --- sign out, then sign in with the passkey; a deep link survives the sign-in ---73  await signOut(page);74  await page.goto('/settings/securite');75  await expect(page).toHaveURL(/\/login\?next=%2Fsettings%2Fsecurite$/);76  await signIn(page);77  await expect(page).toHaveURL(/\/settings\/securite$/);78  await expect(page.getByTestId('passkey')).toHaveCount(1);79  await expect(page.getByTestId('codes-left')).toContainText('10 / 10');80  await expect(page.getByRole('button', { name: 'La dernière passkey ne peut pas être retirée' })).toBeDisabled();8182  // --- a second passkey, on another authenticator (the first one would refuse a duplicate) ---83  const first = await auth.credentials();84  expect(first).toHaveLength(1);85  await auth.clear();86  await page.getByRole('button', { name: 'Ajouter' }).click();87  await page.getByLabel('Nom de la passkey').fill('Clé de secours');88  await page.getByRole('button', { name: 'Créer la passkey' }).click();89  await expect(page.getByTestId('passkey')).toHaveCount(2);90  await expect(page.getByTestId('passkey').filter({ hasText: 'Clé de secours' })).toBeVisible();91  // rename the first one92  await page.getByRole('button', { name: 'Renommer Premier appareil' }).click();93  await page.getByLabel('Nom de la passkey').fill('MacBook');94  await page.getByRole('button', { name: 'Enregistrer' }).click();95  await expect(page.getByTestId('passkey').filter({ hasText: 'MacBook' })).toBeVisible();9697  // --- another device signs in with the first passkey; this one revokes it ---98  const ctx2 = await browser.newContext({ locale: 'fr-CA', viewport: page.viewportSize(), baseURL: new URL(page.url()).origin });99  const page2 = await ctx2.newPage();100  const auth2 = await authenticator(ctx2, page2);101  await auth2.add(first[0]!);102  await page2.goto('/login');103  await signIn(page2);104  await page.goto('/settings/appareils');105  await expect(page.getByTestId('session')).toHaveCount(2);106  await expect(page.locator('[data-testid=session][data-current]')).toContainText('Cet appareil');107  await page.locator('[data-testid=session]:not([data-current])').getByRole('button', { name: /^Révoquer/ }).click();108  await page.getByRole('dialog').getByRole('button', { name: 'Révoquer' }).click();109  await expect(page.getByTestId('session')).toHaveCount(1);110  await page2.goto('/recent');111  await expect(page2).toHaveURL(/\/login\?next=%2Frecent$/);112113  // --- activity shows what happened, with human labels ---114  await page.goto('/settings/activite');115  await expect(page.locator('[data-action="account.passkey_added"]')).toContainText('Passkey ajoutée');116  await expect(page.locator('[data-action="account.session_revoked"]')).toContainText('Session révoquée');117  await expect(page.locator('[data-action="auth.login"]').first()).toContainText('Connexion par passkey');118119  // --- recovery on the second device (no passkey left there): a code opens the enrolment step only ---120  await auth2.clear();121  await page2.goto('/recovery');122  await page2.getByLabel('Courriel').fill(email);123  // one attempt only: the recovery route allows 8 calls per 15 minutes and per address, shared by every run124  await page2.getByLabel('Code de récupération').fill(codes[3]!.toLowerCase());125  await page2.getByRole('button', { name: 'Continuer' }).click();126  await expect(page2.getByRole('heading', { name: 'Enregistrer une nouvelle passkey' })).toBeVisible();127  await page2.goto('/'); // a recovery session reaches nothing else128  await expect(page2).toHaveURL(/\/recovery$/);129  await page2.getByLabel('Nom de la passkey').fill('Nouveau téléphone');130  await page2.getByRole('button', { name: 'Enregistrer une passkey' }).click();131  await expect(page2).toHaveURL(/\/$/);132  await expect(page2.getByRole('button', { name: 'Compte de Camille Tremblay' })).toBeVisible();133134  // --- the bell tells the story; codes left went down ---135  await expect(page2.getByTestId('unread-count')).toBeVisible();136  await page2.getByRole('button', { name: /^Notifications, \d+ non lues?$/ }).click();137  const panel = page2.getByRole('dialog', { name: 'Notifications' });138  await expect(panel.getByText('Un code de récupération a servi à ouvrir votre compte.')).toBeVisible();139  await expect(panel.getByText('Une passkey a été ajoutée à votre compte.').first()).toBeVisible();140  await panel.getByRole('button', { name: 'Tout marquer comme lu' }).click();141  await expect(page2.getByTestId('unread-count')).toHaveCount(0);142  await page2.keyboard.press('Escape');143  await page2.goto('/settings/securite');144  await expect(page2.getByTestId('passkey')).toHaveCount(3);145  await expect(page2.getByTestId('codes-left')).toContainText('9 / 10');146  await ctx2.close();147});148149test('administrator: invitation created, quota edited, system state', async ({ page, context }) => {150  const { email } = await apiSignUp(context, page, { admin: true });151  await page.goto('/admin');152  await expect(page.getByRole('heading', { name: 'Administration', level: 1 })).toBeVisible();153  await expect(page.getByTestId('system')).toContainText('Base de données');154  await expect(page.getByTestId('system')).toContainText('Antivirus');155156  // invitation: the link is shown once157  const guest = `guest-${Date.now()}@example.test`;158  await page.getByRole('button', { name: 'Inviter' }).click();159  await page.getByLabel('Courriel de la personne invitée').fill(guest);160  await page.getByLabel('Quota (Go)').fill('3');161  await page.getByRole('button', { name: 'Créer l’invitation' }).click();162  const link = await page.getByLabel('Lien d’invitation').inputValue();163  expect(link).toMatch(/\/invite\/[A-Za-z0-9_-]{20,}$/);164  await expect(page.getByRole('dialog', { name: 'Invitation créée' }).locator(':focus')).toHaveCount(1); // focus moved into the new dialog165  await page.getByRole('button', { name: 'J’ai copié le lien' }).click();166  const invite = page.locator(`[data-testid=admin-invite][data-email="${guest}"]`);167  await expect(invite).toContainText('En attente');168  // the link really opens the registration screen for that address169  const fresh = await context.newPage();170  await fresh.goto(new URL(link).pathname);171  await expect(fresh.getByText(guest)).toBeVisible();172  await fresh.close();173  await invite.getByRole('button', { name: /^Supprimer l’invitation/ }).click();174  await page.getByRole('dialog').getByRole('button', { name: 'Supprimer' }).click();175  await expect(invite).toHaveCount(0);176177  // quota of an account178  const row = page.locator(`[data-testid=admin-user][data-email="${email}"]`).locator('visible=true');179  await row.getByRole('button', { name: /^Modifier / }).click();180  const dialog = page.getByRole('dialog');181  await expect(dialog.getByLabel('Rôle')).toBeDisabled(); // no self-demotion182  await dialog.getByLabel('Quota (Go)').fill('7,5');183  await dialog.getByRole('button', { name: 'Enregistrer' }).click();184  await expect(dialog).toHaveCount(0);185  await expect(row).toContainText('7,5 Go');186  await page.goto('/settings/profil');187  await expect(page.getByText('utilisés sur 7,5 Go', { exact: true })).toBeVisible();188});189190test('shell: navigation, search, command palette, theme and language', async ({ page, context }, info) => {191  await apiSignUp(context, page);192  await page.goto('/');193  const desktop = info.project.name === 'desktop';194  const nav = page.getByRole('navigation', { name: 'Navigation principale' }).locator('visible=true');195  await expect(nav.getByRole('link', { name: desktop ? 'Mes fichiers' : 'Fichiers' })).toHaveAttribute('aria-current', 'page');196  await nav.getByRole('link', { name: 'Récents' }).click();197  await expect(page).toHaveURL(/\/recent$/);198  await expect(nav.getByRole('link', { name: 'Récents' })).toHaveAttribute('aria-current', 'page');199  if (!desktop) {200    await nav.getByRole('button', { name: 'Plus' }).click();201    await page.getByRole('dialog', { name: 'Plus' }).getByRole('link', { name: 'Corbeille' }).click();202    await expect(page).toHaveURL(/\/trash$/);203  }204  // search field: "/" focuses it, Enter goes to the results205  await page.locator('#main').click({ position: { x: 5, y: 5 } });206  await page.keyboard.press('/');207  await expect(page.getByRole('searchbox', { name: 'Rechercher dans mes fichiers' })).toBeFocused();208  await page.keyboard.type('facture été');209  await page.keyboard.press('Enter');210  await expect(page).toHaveURL(/\/search\?q=facture%20%C3%A9t%C3%A9$/);211  // palette: filter, arrows, Enter212  await page.keyboard.press('Control+k');213  const palette = page.getByRole('dialog', { name: 'Commandes' });214  const input = palette.getByRole('combobox');215  await expect(input).toBeFocused();216  await input.fill('regl');217  await expect(palette.getByRole('option')).toHaveCount(1);218  await expect(palette.getByRole('option', { name: /Réglages/ })).toHaveAttribute('aria-selected', 'true');219  await page.keyboard.press('Enter');220  await expect(page).toHaveURL(/\/settings$/);221  await expect(palette).toHaveCount(0);222  // palette commands: theme and language223  await page.keyboard.press('Control+k');224  await input.fill('sombre');225  await page.keyboard.press('Enter');226  await expect(page.locator('html')).toHaveClass(/dark/);227  await page.keyboard.press('Control+k');228  await input.fill('english');229  await page.keyboard.press('Enter');230  await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();231  await expect(page.locator('html')).toHaveAttribute('lang', 'en');232  await page.reload(); // the language is stored on the account and on the device233  await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();234  await page.keyboard.press('Control+k');235  await page.keyboard.press('Escape');236  await expect(page.getByRole('dialog')).toHaveCount(0);237  // authenticator app: QR code and key, a wrong code is refused, the right one enables it; then disable238  await page.goto('/settings/securite');239  await page.getByRole('button', { name: 'Set up' }).click();240  const setup = page.getByRole('dialog', { name: 'Set up the authenticator app' });241  await expect(setup.getByRole('img', { name: 'Setup QR code' })).toBeVisible();242  const secret = await setup.getByLabel('Key to type by hand').inputValue();243  expect(secret).toMatch(/^[A-Z2-7]{32}$/);244  const good = totp(secret);245  await setup.getByLabel('6-digit code').fill(good === '000000' ? '000001' : '000000');246  await setup.getByRole('button', { name: 'Enable' }).click();247  await expect(setup.getByRole('alert')).toContainText('Code not accepted');248  await setup.getByLabel('6-digit code').fill(totp(secret));249  await setup.getByRole('button', { name: 'Enable' }).click();250  await expect(page.getByTestId('totp-state')).toContainText('Enabled');251  await page.getByRole('button', { name: 'Disable' }).click();252  await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click();253  await expect(page.getByTestId('totp-state')).toContainText('Disabled');254  // no horizontal overflow255  expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);256});257
Files main
Menu

Theme