Repository Public
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