From fa2154d3cffb99debfa0be7c27ff3f7724e28f8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:27:28 +0000 Subject: [PATCH 1/2] fix: widen the login panel so the PIN field can state its own rule Follow-up to the login affordances. I had kept the PIN field narrow because widening it wrapped the Instructor button onto a second row, and I attributed that to the header being short of space. That was wrong: the header has plenty of room. The constraint was the panel's own `max-width: 480px`. Raising it to 560px lets the PIN field carry a "PIN (4 digits)" placeholder, so all three fields now show worked examples the way Name and Service ID already did, and every control still sits on one row. Measured across viewports: 1540px and 1280px are a single row 75px tall, which is shorter than the 81px this panel occupied before any of these changes. At 1024px and below the panel shrinks under its max-width and wraps, exactly as it did beforehand, so nothing narrow regresses. Bundle 39.41 KB gzip against the 40 KB limit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015u1mfVebscuZA3YDc6hc87 --- src/components/qd-login.styles.ts | 17 +++++++++-------- src/components/qd-login.ts | 2 +- tests/unit/qd-login.test.ts | 3 +-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/qd-login.styles.ts b/src/components/qd-login.styles.ts index f9d09b7..151b6d6 100644 --- a/src/components/qd-login.styles.ts +++ b/src/components/qd-login.styles.ts @@ -24,7 +24,10 @@ export const loginStyles = css` background: #fff; border: 1px solid #ddd; border-radius: 4px; - max-width: 480px; + /* Wide enough to keep every control on one row, including the PIN field + with its "PIN (4 digits)" placeholder. The surrounding header has + ample horizontal space; this panel was the constraint, not the page. */ + max-width: 560px; } .title { @@ -52,13 +55,11 @@ export const loginStyles = css` } input.pin-input { - /* Kept narrow deliberately: the header bar has only a few pixels of - slack at 1280px, and a wider field wraps the Instructor button onto a - second row. The "4 digits" rule is carried by the hint message and the - field's title/aria-label instead. */ - width: 45px; - min-width: 45px; - max-width: 45px; + /* Sized to show the "PIN (4 digits)" placeholder in full, so the field + states its own rule rather than reading as an unexplained box. */ + width: 88px; + min-width: 88px; + max-width: 88px; text-align: center; letter-spacing: 1px; } diff --git a/src/components/qd-login.ts b/src/components/qd-login.ts index 19f5e31..3692ea5 100644 --- a/src/components/qd-login.ts +++ b/src/components/qd-login.ts @@ -164,7 +164,7 @@ export class QdLogin extends LitElement { type="password" name="pin" class="pin-input" - placeholder="PIN" + placeholder="PIN (4 digits)" title="4-digit PIN" inputmode="numeric" pattern="[0-9]*" diff --git a/tests/unit/qd-login.test.ts b/tests/unit/qd-login.test.ts index da85f73..9fe6111 100644 --- a/tests/unit/qd-login.test.ts +++ b/tests/unit/qd-login.test.ts @@ -333,10 +333,9 @@ describe('QdLogin Component', () => { }); it('should state the 4-digit requirement on the PIN field', () => { - // The field stays narrow to keep the header on one row, so the rule is - // carried by the tooltip and aria-label rather than the placeholder. const pinInput = element.shadowRoot?.querySelector('input[name="pin"]') as HTMLInputElement; + expect(pinInput.placeholder).toContain('4 digits'); expect(pinInput.title).toBe('4-digit PIN'); expect(pinInput.getAttribute('aria-label')).toContain('4-digit PIN'); }); From 2bf36bbac9bcfd2f9463f696bdd9b56f5389918a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:48:12 +0000 Subject: [PATCH 2/2] feat: offer "Create" for a new service ID and confirm the PIN A first-time user had no way to tell that logging in would create an account, or that the PIN they typed would become their PIN permanently. The submit button now reads "Create" when the entered service ID has no account for this release, and "Login" when it does. Pressing "Create" takes the PIN a second time before storing it, so a typo cannot silently become the stored PIN and lock a student out of their own record. A mismatch warns and returns to the create state; a match creates the account and logs in. Registration is resolved on submit as well as on a debounced keystroke, so a fast typist who clicks before the lookup returns still gets the confirm step rather than a silently different flow. The lookup never throws: a storage problem resolves to "unknown" and the form falls back to its neutral "Login" label. Also fixes a latent bug found while testing: logout cleared every other form field but left the previous user's registration state behind. E2E: 50 login call sites across 16 specs clicked submit directly and now break on the second step, so they route through one new helper, tests/e2e/helpers.ts. The helper reads the PIN already typed, scopes its locators to the login form so dialog buttons cannot match, and waits for a real outcome (confirm requested, logged in, or login rejected) instead of a fixed delay. Three new E2E tests cover the create, mismatch and returning-user paths directly. The service-ID lookup makes account existence observable to anyone at the keyboard. That trade-off is recorded in docs/SECURITY.md. Bundle 39.77 KB gzip against the 40 KB limit, 0.23 KB remaining. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015u1mfVebscuZA3YDc6hc87 --- docs/SECURITY.md | 13 +++ src/components/qd-login.ts | 97 ++++++++++++++++- src/services/auth/auth-service.ts | 32 ++++++ tests/e2e/encrypted-storage.spec.ts | 3 +- tests/e2e/helpers.ts | 81 ++++++++++++++ tests/e2e/workflows/analysis-capture.spec.ts | 3 +- .../workflows/answer-styling-reset.spec.ts | 3 +- tests/e2e/workflows/build-info.spec.ts | 3 +- tests/e2e/workflows/cohort-management.spec.ts | 11 +- tests/e2e/workflows/csv-export.spec.ts | 9 +- tests/e2e/workflows/data-coexistence.spec.ts | 7 +- .../workflows/dita-instructor-flow.spec.ts | 10 +- tests/e2e/workflows/dita-student-flow.spec.ts | 100 +++++++++++++++--- .../instructor-dynamic-reveal.spec.ts | 3 +- .../instructor-mode-improvements.spec.ts | 3 +- tests/e2e/workflows/instructor-review.spec.ts | 3 +- .../e2e/workflows/pin-authentication.spec.ts | 3 +- tests/e2e/workflows/progress-tracking.spec.ts | 15 +-- tests/e2e/workflows/relogin-progress.spec.ts | 3 +- tests/e2e/workflows/session-timeout.spec.ts | 15 +-- tests/integration/login-flow.test.ts | 83 +++++++++++++++ tests/unit/auth-service.test.ts | 34 ++++++ tests/unit/qd-login.test.ts | 79 +++++++++++++- 23 files changed, 556 insertions(+), 57 deletions(-) create mode 100644 tests/e2e/helpers.ts diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9dabd3b..9c11aee 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -196,6 +196,19 @@ exceptions, verified by grep: to any field starting with `=`, `+`, `-`, `@`, tab or CR so a student name or free-text answer cannot become a live formula when the instructor opens the CSV in Excel or Sheets. +### Account enumeration + +The login form asks storage whether a service ID already has an account, so it +can offer "Create" rather than a "Login" that cannot succeed +(`AuthService.isRegistered`). That answer is visible to anyone at the keyboard: +typing a service ID reveals whether it is registered for the current release. + +This is accepted. The tool is an offline, single-machine classroom aid with no +accounts worth enumerating remotely, and the alternative — leaving a first-time +user staring at a disabled button — was a real usability failure. Anyone who can +type a service ID into the form can already open DevTools and read the whole +IndexedDB database, so the lookup discloses nothing that was otherwise protected. + ## 9. Explicitly out of scope - No network, no server, no accounts: nothing here authenticates a person, only a tab. diff --git a/src/components/qd-login.ts b/src/components/qd-login.ts index 3692ea5..d21e964 100644 --- a/src/components/qd-login.ts +++ b/src/components/qd-login.ts @@ -58,6 +58,13 @@ export class QdLogin extends LitElement { @state() private lockoutUntil = 0; /** Whether the "PIN stored" confirmation is shown */ @state() private showPinConfirmation = false; + /** + * Whether the entered service ID already has an account. + * `null` means not looked up yet, or the lookup failed. + */ + @state() private isRegistered: boolean | null = null; + /** First PIN entry, held while the user re-types it to confirm (create flow) */ + @state() private pendingPin = ''; /** Whether the help popup is open */ @state() private helpOpen = false; /** Whether the migration dialog is shown */ @@ -76,6 +83,9 @@ export class QdLogin extends LitElement { /** Student authentication service (storage/crypto/rate-limit logic) */ private authService = new AuthService(); + /** Pending debounce for the "is this service ID known?" lookup */ + private registrationTimer?: number; + static styles = loginStyles; connectedCallback() { @@ -88,6 +98,7 @@ export class QdLogin extends LitElement { disconnectedCallback() { super.disconnectedCallback(); + window.clearTimeout(this.registrationTimer); document.removeEventListener('qd:logout', this.handleLogoutEvent); document.removeEventListener('qd:login', this.handleLoginEvent); } @@ -117,6 +128,8 @@ export class QdLogin extends LitElement { this.lockoutUntil = 0; this.showPinConfirmation = false; this.helpOpen = false; + this.isRegistered = null; + this.pendingPin = ''; this.updateVisibility(); }; @@ -164,8 +177,7 @@ export class QdLogin extends LitElement { type="password" name="pin" class="pin-input" - placeholder="PIN (4 digits)" - title="4-digit PIN" + placeholder=${this.pendingPin ? 'Re-enter PIN' : 'PIN (4 digits)'} inputmode="numeric" pattern="[0-9]*" maxlength="4" @@ -181,7 +193,7 @@ export class QdLogin extends LitElement { class="login-btn" ?disabled=${this.isSubmitting || !this.isValid() || this.isLockedOut()} > - Login + ${this.submitLabel} ${this.errorMessage @@ -236,6 +248,58 @@ export class QdLogin extends LitElement { private handleServiceIdInput(e: Event) { this.serviceId = (e.target as HTMLInputElement).value; this.errorMessage = ''; + // The identity changed, so a PIN captured for the previous one is void + this.pendingPin = ''; + this.scheduleRegistrationCheck(); + } + + /** + * Debounced lookup of whether the entered service ID already has an account, + * used to label the submit button "Login" or "Create". + */ + private scheduleRegistrationCheck(): void { + window.clearTimeout(this.registrationTimer); + this.isRegistered = null; + + const serviceId = this.serviceId.trim(); + if (!/^[a-zA-Z0-9]{2,10}$/.test(serviceId)) { + return; + } + + this.registrationTimer = window.setTimeout(() => { + void this.checkRegistration(serviceId); + }, 300); + } + + /** Resolve {@link isRegistered} for one service ID, ignoring stale answers. */ + private async checkRegistration(serviceId: string): Promise { + let known: boolean | null = null; + try { + const release = readRelease(); + known = release + ? await this.authService.isRegistered(serviceId, release, readDbName()) + : null; + } catch { + known = null; + } + // Discard a late answer for a service ID the user has since edited + if (serviceId === this.serviceId.trim()) { + this.isRegistered = known; + } + } + + /** + * Label for the submit button. + * + * A first-time user sees "Create" rather than a "Login" that cannot succeed, + * and "Confirm" while re-entering their new PIN. Unknown registration falls + * back to "Login". + */ + private get submitLabel(): string { + if (this.pendingPin) { + return 'Confirm'; + } + return this.isRegistered === false ? 'Create' : 'Login'; } private handlePinInput(e: Event) { // Filter to digits only using validation helper @@ -261,6 +325,9 @@ export class QdLogin extends LitElement { * untouched or valid */ private get validationHint(): string { + if (this.pendingPin) { + return 'Re-enter your PIN to confirm'; + } if (!this.name && !this.serviceId && !this.pin) { return ''; } @@ -279,6 +346,30 @@ export class QdLogin extends LitElement { return; } + // The debounced lookup may not have answered yet (fast typist, fast click), + // so settle it here rather than letting timing decide whether an account + // is created with or without confirmation. + if (this.isRegistered === null && !this.pendingPin) { + await this.checkRegistration(this.serviceId.trim()); + } + + // Creating an account takes the PIN twice, so a typo cannot silently + // become the stored PIN and lock the student out of their own record. + if (this.pendingPin) { + if (this.pin !== this.pendingPin) { + this.pendingPin = ''; + this.pin = ''; + this.errorMessage = 'PINs did not match. Please enter your PIN again.'; + return; + } + this.pendingPin = ''; + } else if (this.isRegistered === false) { + this.pendingPin = this.pin; + this.pin = ''; + this.errorMessage = ''; + return; + } + this.isSubmitting = true; this.errorMessage = ''; diff --git a/src/services/auth/auth-service.ts b/src/services/auth/auth-service.ts index cb4c91b..67b8279 100644 --- a/src/services/auth/auth-service.ts +++ b/src/services/auth/auth-service.ts @@ -106,6 +106,38 @@ export class AuthService { }); } + /** + * Whether a service ID already has an account with a usable PIN. + * + * Used by the login form to label its submit button "Login" or "Create", so + * a first-time user is told they are about to create an account rather than + * guessing why their credentials are not accepted. Accounts are keyed on + * `{release, serviceId}`, so the name is not part of this check. + * + * Never throws: any storage problem resolves to `null` ("unknown"), leaving + * the form to fall back to its neutral label rather than blocking the user. + * + * @param serviceId - Service ID being entered + * @param release - Release the account would belong to + * @param dbName - IndexedDB database name from page config + * @returns true when a PIN-bearing record exists, false when it does not, + * null when the lookup could not be completed + */ + async isRegistered(serviceId: string, release: string, dbName: string): Promise { + try { + const storage = getStorageAdapter(dbName); + await storage.init(); + const existing = await storage.getStudent(release, serviceId); + if (!existing) { + return false; + } + // A record awaiting PIN setup still needs the create-and-confirm flow + return hasPinSet(existing) && !needsMigration(existing); + } catch { + return null; + } + } + /** * Shared login implementation for both the initial and post-migration paths. */ diff --git a/tests/e2e/encrypted-storage.spec.ts b/tests/e2e/encrypted-storage.spec.ts index 42091bb..af6f11a 100644 --- a/tests/e2e/encrypted-storage.spec.ts +++ b/tests/e2e/encrypted-storage.spec.ts @@ -15,6 +15,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from './helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -74,7 +75,7 @@ async function loginStudent(page: Page): Promise { await loginForm.locator('input[name="name"]').fill(STUDENT.name); await loginForm.locator('input[name="serviceId"]').fill(STUDENT.serviceId); await loginForm.locator('input[name="pin"]').fill(STUDENT.pin); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..6a2234f --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,81 @@ +/** + * Shared helpers for the Playwright E2E suites. + */ + +import type { Locator } from '@playwright/test'; + +/** Milliseconds to wait for the login form to reach a settled state. */ +const SETTLE_MS = 3000; + +/** + * Resolve with `value` only when `promise` succeeds. + * + * A branch that rejects (its `waitFor` timed out) never settles, so it cannot + * win the race below and be mistaken for the outcome that did happen. + */ +function onSuccess(promise: Promise, value: T): Promise { + return promise.then( + () => value, + () => new Promise(() => {}), + ); +} + +/** + * Submit the student login form, completing the confirm step when the form is + * creating a new account. + * + * A service ID with no account offers "Create" and asks for the PIN a second + * time before storing it, so a typo cannot silently become the stored PIN. An + * existing account offers "Login" and needs only the single click. Tests call + * this rather than clicking submit directly, so the two-step flow lives in one + * place instead of in every spec. + * + * @param form - Locator for the `` element (or its wrapper) + * @param pin - PIN to repeat at the confirm step. Defaults to whatever is + * currently in the PIN field, so callers never have to keep the two in sync. + */ +export async function submitStudentLogin(form: Locator, pin?: string): Promise { + // Scope every locator to the login form itself. The component also hosts + // dialogs (PIN confirmation, storage migration) whose buttons and messages + // would otherwise match and make the helper act on the wrong control. + const loginForm = form.locator('form.login-form'); + const pinField = loginForm.locator('input[name="pin"]'); + const typedPin = pin ?? (await pinField.inputValue()); + const submit = loginForm.locator('button.login-btn'); + + await submit.click(); + + // The form resolves registration against storage before deciding whether to + // ask for the PIN again, so wait for whichever outcome actually arrives + // rather than guessing a delay. + const confirming = await Promise.race([ + // Creating an account: the form is asking us to repeat the PIN + onSuccess( + loginForm + .locator('button.login-btn:has-text("Confirm")') + .waitFor({ state: 'visible', timeout: SETTLE_MS }), + true, + ), + // Existing account, correct PIN: logged straight in + onSuccess( + form.page().locator('qd-status').waitFor({ state: 'visible', timeout: SETTLE_MS }), + false, + ), + // Existing account, rejected PIN: the form reports it and stays put. Tests + // that deliberately fail a login rely on this returning promptly. + onSuccess( + loginForm + .locator('.error-message, .lockout-message') + .first() + .waitFor({ state: 'visible', timeout: SETTLE_MS }), + false, + ), + // Anything else (a dialog took over, for instance): leave the form alone. + new Promise((resolve) => setTimeout(() => resolve(false), SETTLE_MS + 200)), + ]); + + if (confirming) { + await pinField.fill(typedPin); + await submit.click(); + } +} diff --git a/tests/e2e/workflows/analysis-capture.spec.ts b/tests/e2e/workflows/analysis-capture.spec.ts index 72f5abd..2df7fc4 100644 --- a/tests/e2e/workflows/analysis-capture.spec.ts +++ b/tests/e2e/workflows/analysis-capture.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -48,7 +49,7 @@ test.describe('Analysis Capture Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await expect(page.locator('qd-status')).toBeVisible(); }); diff --git a/tests/e2e/workflows/answer-styling-reset.spec.ts b/tests/e2e/workflows/answer-styling-reset.spec.ts index 2cf28c0..941156a 100644 --- a/tests/e2e/workflows/answer-styling-reset.spec.ts +++ b/tests/e2e/workflows/answer-styling-reset.spec.ts @@ -8,6 +8,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -47,7 +48,7 @@ async function loginStudent( await loginForm.locator('input[name="serviceId"]').fill(serviceId); await loginForm.locator('input[name="name"]').fill(name); await loginForm.locator('input[name="pin"]').fill(pin); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor({ timeout: 2000 }); } diff --git a/tests/e2e/workflows/build-info.spec.ts b/tests/e2e/workflows/build-info.spec.ts index 00f78a0..9ad1107 100644 --- a/tests/e2e/workflows/build-info.spec.ts +++ b/tests/e2e/workflows/build-info.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -50,7 +51,7 @@ async function loginAsStudent(page: Page): Promise { await login.locator('input[name="serviceId"]').fill('BUILD001'); await login.locator('input[name="name"]').fill('Build Test'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await expect(page.locator('qd-status')).toBeVisible(); } diff --git a/tests/e2e/workflows/cohort-management.spec.ts b/tests/e2e/workflows/cohort-management.spec.ts index 9739938..6eeb147 100644 --- a/tests/e2e/workflows/cohort-management.spec.ts +++ b/tests/e2e/workflows/cohort-management.spec.ts @@ -10,6 +10,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -66,7 +67,7 @@ test.describe('Cohort Management Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await expect(page.locator('qd-status')).toBeVisible(); @@ -181,7 +182,7 @@ test.describe('Cohort Management Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await expect(page.locator('qd-status')).toBeVisible(); @@ -277,7 +278,7 @@ test.describe('Cohort Management Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await expect(page.locator('qd-status')).toBeVisible(); @@ -318,7 +319,7 @@ test.describe('Cohort Management Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST002'); await login.locator('input[name="name"]').fill('Jane Smith'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await expect(page.locator('qd-status')).toBeVisible(); @@ -431,7 +432,7 @@ test.describe('Cohort Management Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await expect(page.locator('qd-status')).toBeVisible(); diff --git a/tests/e2e/workflows/csv-export.spec.ts b/tests/e2e/workflows/csv-export.spec.ts index f9e6490..92bc432 100644 --- a/tests/e2e/workflows/csv-export.spec.ts +++ b/tests/e2e/workflows/csv-export.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -91,7 +92,7 @@ test.describe('CSV Data Export', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Answer a quiz question @@ -150,7 +151,7 @@ test.describe('CSV Data Export', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Answer a question @@ -217,7 +218,7 @@ test.describe('CSV Data Export', () => { await login.locator('input[name="serviceId"]').fill('EXPORT001'); await login.locator('input[name="name"]').fill('Export Test User'); await login.locator('input[name="pin"]').fill('5678'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Answer a question @@ -296,7 +297,7 @@ test.describe('CSV Data Export', () => { await login.locator('input[name="serviceId"]').fill('FNAME001'); await login.locator('input[name="name"]').fill('Filename Test'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Answer a question diff --git a/tests/e2e/workflows/data-coexistence.spec.ts b/tests/e2e/workflows/data-coexistence.spec.ts index ae34933..3d33b18 100644 --- a/tests/e2e/workflows/data-coexistence.spec.ts +++ b/tests/e2e/workflows/data-coexistence.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -58,7 +59,7 @@ test.describe('Data Coexistence', () => { await login.locator('input[name="serviceId"]').fill('COEX01'); await login.locator('input[name="name"]').fill('Test User'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await expect(page.locator('qd-status')).toBeVisible(); }); @@ -295,7 +296,7 @@ test.describe('Data Coexistence', () => { await login.locator('input[name="serviceId"]').fill('COEX02'); await login.locator('input[name="name"]').fill('Second User'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await expect(page.locator('qd-status')).toBeVisible(); // Navigate to coexistence page @@ -324,7 +325,7 @@ test.describe('Data Coexistence', () => { await login2.locator('input[name="serviceId"]').fill('COEX01'); await login2.locator('input[name="name"]').fill('Test User'); await login2.locator('input[name="pin"]').fill('1234'); - await login2.locator('button[type="submit"]').click(); + await submitStudentLogin(login2); await expect(page.locator('qd-status')).toBeVisible(); await page.goto(`file://${demoPath}/${COEXISTENCE_PAGE}`); diff --git a/tests/e2e/workflows/dita-instructor-flow.spec.ts b/tests/e2e/workflows/dita-instructor-flow.spec.ts index 8c468a6..4398990 100644 --- a/tests/e2e/workflows/dita-instructor-flow.spec.ts +++ b/tests/e2e/workflows/dita-instructor-flow.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -82,9 +83,8 @@ test.describe('DITA Instructor Flow', () => { await nameInput.fill('Alice Smith'); await pinInput.fill('1234'); - // Click login button - const loginButton = loginForm.locator('button[type="submit"]'); - await loginButton.click(); + // Click login button (creating the account takes the PIN twice) + await submitStudentLogin(loginForm); // Close PIN confirmation dialog if it appears await closePinConfirmationDialog(page); @@ -142,7 +142,7 @@ test.describe('DITA Instructor Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('STU002'); await loginForm.locator('input[name="name"]').fill('Bob Jones'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor({ timeout: 3000 }); @@ -270,7 +270,7 @@ test.describe('DITA Instructor Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('STU003'); await loginForm.locator('input[name="name"]').fill('Carol White'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor({ timeout: 3000 }); diff --git a/tests/e2e/workflows/dita-student-flow.spec.ts b/tests/e2e/workflows/dita-student-flow.spec.ts index 6e33072..2b8260b 100644 --- a/tests/e2e/workflows/dita-student-flow.spec.ts +++ b/tests/e2e/workflows/dita-student-flow.spec.ts @@ -13,6 +13,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -76,7 +77,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="pin"]').fill('1234'); // Submit login - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); // Close PIN confirmation dialog if it appears await closePinConfirmationDialog(page); @@ -103,7 +104,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('BOB02'); await loginForm.locator('input[name="name"]').fill('Bob Smith'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -142,7 +143,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('CAROL03'); await loginForm.locator('input[name="name"]').fill('Carol White'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -175,7 +176,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('DAVE04'); await loginForm.locator('input[name="name"]').fill('Dave Brown'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); const statusPanel = page.locator('qd-status'); await statusPanel.waitFor(); @@ -224,7 +225,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('EVE05'); await loginForm.locator('input[name="name"]').fill('Eve Davis'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -269,7 +270,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('FRANK06'); await loginForm.locator('input[name="name"]').fill('Frank Miller'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -312,7 +313,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('GRACE07'); await loginForm.locator('input[name="name"]').fill('Grace Lee'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); const statusPanel = page.locator('qd-status'); await statusPanel.waitFor(); @@ -348,7 +349,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('HENRY08'); await loginForm.locator('input[name="name"]').fill('Henry Wilson'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await statusPanel.waitFor(); @@ -374,7 +375,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('IVY09'); await loginForm.locator('input[name="name"]').fill('Ivy Garcia'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -419,7 +420,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('STRUCT01'); await loginForm.locator('input[name="name"]').fill('Structure Test'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -464,7 +465,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('STRUCT02'); await loginForm.locator('input[name="name"]').fill('Structure Test 2'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -499,7 +500,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('STRUCT03'); await loginForm.locator('input[name="name"]').fill('Structure Test 3'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -530,7 +531,7 @@ test.describe('DITA Student Flow', () => { await loginForm.locator('input[name="serviceId"]').fill('STRUCT04'); await loginForm.locator('input[name="name"]').fill('Structure Test 4'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor(); @@ -557,4 +558,77 @@ test.describe('DITA Student Flow', () => { expect(inputType).toBe('text'); } }); + + test.describe('Account creation affordance', () => { + test('offers Create for a new service ID and takes the PIN twice', async ({ page }) => { + await page.goto(`file://${ditaPath}/page-index.html`); + await waitForBootstrap(page); + + const loginForm = page.locator('qd-login'); + const submit = loginForm.locator('button[type="submit"]'); + + // A service ID with no account should not offer a "Login" that cannot work + await loginForm.locator('input[name="name"]').fill('Brand New'); + await loginForm.locator('input[name="serviceId"]').fill('FRESH01'); + await expect(submit).toHaveText('Create', { timeout: 2000 }); + + // First press captures the PIN and asks for it again + await loginForm.locator('input[name="pin"]').fill('5678'); + await submit.click(); + await expect(submit).toHaveText('Confirm', { timeout: 2000 }); + await expect(loginForm.locator('input[name="pin"]')).toHaveValue(''); + + // Repeating it creates the account and logs in + await loginForm.locator('input[name="pin"]').fill('5678'); + await submit.click(); + await closePinConfirmationDialog(page); + await expect(page.locator('qd-status')).toBeVisible({ timeout: 3000 }); + }); + + test('warns and returns to Create when the repeated PIN differs', async ({ page }) => { + await page.goto(`file://${ditaPath}/page-index.html`); + await waitForBootstrap(page); + + const loginForm = page.locator('qd-login'); + const submit = loginForm.locator('button[type="submit"]'); + + await loginForm.locator('input[name="name"]').fill('Typo User'); + await loginForm.locator('input[name="serviceId"]').fill('TYPO01'); + await expect(submit).toHaveText('Create', { timeout: 2000 }); + await loginForm.locator('input[name="pin"]').fill('1111'); + await submit.click(); + await expect(submit).toHaveText('Confirm', { timeout: 2000 }); + + // A mismatch must not create the account + await loginForm.locator('input[name="pin"]').fill('2222'); + await submit.click(); + + await expect(loginForm.locator('.error-message')).toContainText('did not match'); + await expect(submit).toHaveText('Create'); + await expect(page.locator('qd-status')).toBeHidden(); + }); + + test('offers Login once the account exists', async ({ page }) => { + await page.goto(`file://${ditaPath}/page-index.html`); + await waitForBootstrap(page); + + const loginForm = page.locator('qd-login'); + await loginForm.locator('input[name="name"]').fill('Returning User'); + await loginForm.locator('input[name="serviceId"]').fill('BACK01'); + await loginForm.locator('input[name="pin"]').fill('4321'); + await submitStudentLogin(loginForm); + await closePinConfirmationDialog(page); + await expect(page.locator('qd-status')).toBeVisible({ timeout: 3000 }); + + await page.locator('qd-status >> .logout-button').click(); + await expect(loginForm).toBeVisible({ timeout: 2000 }); + + // The same identity is now known, so the button offers Login + await loginForm.locator('input[name="name"]').fill('Returning User'); + await loginForm.locator('input[name="serviceId"]').fill('BACK01'); + await expect(loginForm.locator('button[type="submit"]')).toHaveText('Login', { + timeout: 2000, + }); + }); + }); }); diff --git a/tests/e2e/workflows/instructor-dynamic-reveal.spec.ts b/tests/e2e/workflows/instructor-dynamic-reveal.spec.ts index eaf8eb9..db0a7f3 100644 --- a/tests/e2e/workflows/instructor-dynamic-reveal.spec.ts +++ b/tests/e2e/workflows/instructor-dynamic-reveal.spec.ts @@ -14,6 +14,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -145,7 +146,7 @@ test.describe('Instructor Dynamic Answer Reveal', () => { await loginForm.locator('input[name="serviceId"]').fill('DYN001'); await loginForm.locator('input[name="name"]').fill('Dynamic Test Student'); await loginForm.locator('input[name="pin"]').fill('1234'); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); // Close PIN confirmation dialog if it appears await page.waitForTimeout(200); diff --git a/tests/e2e/workflows/instructor-mode-improvements.spec.ts b/tests/e2e/workflows/instructor-mode-improvements.spec.ts index 6082b81..07a84c3 100644 --- a/tests/e2e/workflows/instructor-mode-improvements.spec.ts +++ b/tests/e2e/workflows/instructor-mode-improvements.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -39,7 +40,7 @@ async function loginAsStudent( await login.locator('input[name="serviceId"]').fill(serviceId); await login.locator('input[name="name"]').fill(name); await login.locator('input[name="pin"]').fill(pin); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await expect(page.locator('qd-status')).toBeVisible({ timeout: 2000 }); } diff --git a/tests/e2e/workflows/instructor-review.spec.ts b/tests/e2e/workflows/instructor-review.spec.ts index 082a3d5..a4b04ee 100644 --- a/tests/e2e/workflows/instructor-review.spec.ts +++ b/tests/e2e/workflows/instructor-review.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -63,7 +64,7 @@ test.describe('Instructor Review Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Wait for status to be visible diff --git a/tests/e2e/workflows/pin-authentication.spec.ts b/tests/e2e/workflows/pin-authentication.spec.ts index af36a58..9e133fe 100644 --- a/tests/e2e/workflows/pin-authentication.spec.ts +++ b/tests/e2e/workflows/pin-authentication.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -49,7 +50,7 @@ async function loginStudent( await loginForm.locator('input[name="name"]').fill(name); await loginForm.locator('input[name="serviceId"]').fill(serviceId); await loginForm.locator('input[name="pin"]').fill(pin); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); } /** diff --git a/tests/e2e/workflows/progress-tracking.spec.ts b/tests/e2e/workflows/progress-tracking.spec.ts index 3cadfc9..be55c61 100644 --- a/tests/e2e/workflows/progress-tracking.spec.ts +++ b/tests/e2e/workflows/progress-tracking.spec.ts @@ -12,6 +12,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; // Get absolute path to demo files const __filename = fileURLToPath(import.meta.url); @@ -84,7 +85,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="pin"]').fill('1234'); // Submit login - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Verify status panel appears @@ -104,7 +105,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Navigate to quiz page @@ -142,7 +143,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Navigate to numeric quiz page @@ -180,7 +181,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); await page.goto(`file://${demoPath}/Pages/quiz-mcq.html`); @@ -212,7 +213,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Initially, badges should be red (unstarted) @@ -249,7 +250,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Navigate to quiz with multiple questions @@ -282,7 +283,7 @@ test.describe('Progress Tracking Workflow', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); diff --git a/tests/e2e/workflows/relogin-progress.spec.ts b/tests/e2e/workflows/relogin-progress.spec.ts index e95f3b3..a860509 100644 --- a/tests/e2e/workflows/relogin-progress.spec.ts +++ b/tests/e2e/workflows/relogin-progress.spec.ts @@ -8,6 +8,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -52,7 +53,7 @@ async function loginStudent( await loginForm.locator('input[name="serviceId"]').fill(serviceId); await loginForm.locator('input[name="name"]').fill(name); await loginForm.locator('input[name="pin"]').fill(pin); - await loginForm.locator('button[type="submit"]').click(); + await submitStudentLogin(loginForm); await closePinConfirmationDialog(page); await page.locator('qd-status').waitFor({ timeout: 2000 }); } diff --git a/tests/e2e/workflows/session-timeout.spec.ts b/tests/e2e/workflows/session-timeout.spec.ts index 9bcebe0..41ec35b 100644 --- a/tests/e2e/workflows/session-timeout.spec.ts +++ b/tests/e2e/workflows/session-timeout.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; +import { submitStudentLogin } from '../helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -72,7 +73,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Verify session created with required fields @@ -102,7 +103,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Get initial lastActivity @@ -140,7 +141,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Manually set expired session @@ -172,7 +173,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Verify session exists @@ -199,7 +200,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Setup logout event listener @@ -226,7 +227,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Verify status panel visible @@ -248,7 +249,7 @@ test.describe('Session Timeout Management', () => { await login.locator('input[name="serviceId"]').fill('TEST001'); await login.locator('input[name="name"]').fill('John Doe'); await login.locator('input[name="pin"]').fill('1234'); - await login.locator('button[type="submit"]').click(); + await submitStudentLogin(login); await closePinConfirmationDialog(page); // Logout student diff --git a/tests/integration/login-flow.test.ts b/tests/integration/login-flow.test.ts index 2a2868b..bfe70b6 100644 --- a/tests/integration/login-flow.test.ts +++ b/tests/integration/login-flow.test.ts @@ -21,6 +21,7 @@ import { clearAttemptState, } from '../../src/services/auth/rate-limiter.js'; import { SCHEMA_VERSION, type StudentRecord } from '../../src/types/contracts.js'; +import { AuthService } from '../../src/services/auth/auth-service.js'; describe('Login Flow with PIN Authentication', () => { const TEST_DB = 'LoginFlowTestDB'; @@ -382,4 +383,86 @@ describe('Login Flow with PIN Authentication', () => { expect(retrieved?.attempted).toBe(5); // Data preserved }); }); + + describe('Registration lookup (Create vs Login)', () => { + // The login form asks this to decide whether to offer "Create" or "Login". + // A wrong answer either blocks a new student or walks an existing one into + // the create-and-confirm flow for an account they already own. + const RELEASE = 'Test Release 2025'; + + it('reports an unknown service ID as unregistered', async () => { + const auth = new AuthService(); + + const result = await auth.isRegistered('NEVERSEEN', RELEASE, TEST_DB); + + expect(result).toBe(false); + }); + + it('reports a student who has completed PIN setup as registered', async () => { + const pinHash = await hashPin('1234'); + const record: StudentRecord = { + schema: SCHEMA_VERSION, + docId: 'known-doc', + updated: new Date().toISOString(), + serviceId: 'KNOWN01', + name: 'Known Student', + release: RELEASE, + pinHash, + attempted: 0, + correct: 0, + pages: {}, + }; + await storage.saveStudent(record); + + const result = await new AuthService().isRegistered('KNOWN01', RELEASE, TEST_DB); + + expect(result).toBe(true); + }); + + it('treats a record still awaiting PIN setup as unregistered', async () => { + // A legacy record with no PIN must still go through create-and-confirm + const record: StudentRecord = { + schema: SCHEMA_VERSION, + docId: 'legacy-doc', + updated: new Date().toISOString(), + serviceId: 'NOPIN01', + name: 'Legacy Student', + release: RELEASE, + attempted: 0, + correct: 0, + pages: {}, + }; + await storage.saveStudent(record); + + const result = await new AuthService().isRegistered('NOPIN01', RELEASE, TEST_DB); + + expect(result).toBe(false); + }); + + it('scopes registration to the release', async () => { + const pinHash = await hashPin('1234'); + await storage.saveStudent({ + schema: SCHEMA_VERSION, + docId: 'scoped-doc', + updated: new Date().toISOString(), + serviceId: 'SCOPED1', + name: 'Scoped Student', + release: RELEASE, + pinHash, + attempted: 0, + correct: 0, + pages: {}, + }); + + const sameRelease = await new AuthService().isRegistered('SCOPED1', RELEASE, TEST_DB); + const otherRelease = await new AuthService().isRegistered( + 'SCOPED1', + 'Some Other Release', + TEST_DB, + ); + + expect(sameRelease).toBe(true); + expect(otherRelease).toBe(false); + }); + }); }); diff --git a/tests/unit/auth-service.test.ts b/tests/unit/auth-service.test.ts index 618e616..5db0fb5 100644 --- a/tests/unit/auth-service.test.ts +++ b/tests/unit/auth-service.test.ts @@ -56,6 +56,40 @@ async function seedStudent(overrides: Partial = {}): Promise { + describe('isRegistered', () => { + it('returns false for a service ID with no record', async () => { + const service = new AuthService(); + + const result = await service.isRegistered('NOBODY01', RELEASE, DB_NAME); + + expect(result).toBe(false); + }); + + it('returns true once an account with a PIN exists', async () => { + const service = new AuthService(); + await service.loginStudent({ + serviceId: 'REG001', + name: 'Registered Student', + pin: '1234', + release: RELEASE, + dbName: DB_NAME, + }); + + const result = await service.isRegistered('REG001', RELEASE, DB_NAME); + + expect(result).toBe(true); + }); + + it('reports unknown rather than throwing when storage is unusable', async () => { + const service = new AuthService(); + + // An empty database name cannot open a store + const result = await service.isRegistered('REG001', RELEASE, ''); + + expect(result).toBeNull(); + }); + }); + let auth: AuthService; beforeEach(() => { diff --git a/tests/unit/qd-login.test.ts b/tests/unit/qd-login.test.ts index 9fe6111..37f4c6c 100644 --- a/tests/unit/qd-login.test.ts +++ b/tests/unit/qd-login.test.ts @@ -336,11 +336,88 @@ describe('QdLogin Component', () => { const pinInput = element.shadowRoot?.querySelector('input[name="pin"]') as HTMLInputElement; expect(pinInput.placeholder).toContain('4 digits'); - expect(pinInput.title).toBe('4-digit PIN'); expect(pinInput.getAttribute('aria-label')).toContain('4-digit PIN'); }); }); + describe('Create vs Login', () => { + const submitButton = () => + element.shadowRoot?.querySelector('button[type="submit"]') as HTMLButtonElement; + + it('should read "Login" while registration is unknown', () => { + // Neutral default: never claim "Create" without having checked + expect(element['isRegistered']).toBeNull(); + expect(submitButton().textContent?.trim()).toBe('Login'); + }); + + it('should read "Create" for an unregistered service ID', async () => { + element['isRegistered'] = false; + await element.updateComplete; + + expect(submitButton().textContent?.trim()).toBe('Create'); + }); + + it('should read "Login" for a registered service ID', async () => { + element['isRegistered'] = true; + await element.updateComplete; + + expect(submitButton().textContent?.trim()).toBe('Login'); + }); + + it('should read "Confirm" while awaiting the repeated PIN', async () => { + element['pendingPin'] = '1234'; + await element.updateComplete; + + expect(submitButton().textContent?.trim()).toBe('Confirm'); + const pinInput = element.shadowRoot?.querySelector('input[name="pin"]') as HTMLInputElement; + expect(pinInput.placeholder).toBe('Re-enter PIN'); + }); + + it('should ask for the PIN again instead of creating on the first press', async () => { + element['name'] = 'J Smith'; + element['serviceId'] = '30012345'; + element['pin'] = '1234'; + element['isRegistered'] = false; + await element.updateComplete; + + await element['handleStudentLogin'](new Event('submit')); + await element.updateComplete; + + expect(element['pendingPin']).toBe('1234'); + expect(element['pin']).toBe(''); + expect(submitButton().textContent?.trim()).toBe('Confirm'); + }); + + it('should warn and return to create mode when the PINs differ', async () => { + element['name'] = 'J Smith'; + element['serviceId'] = '30012345'; + element['isRegistered'] = false; + element['pendingPin'] = '1234'; + element['pin'] = '9999'; + await element.updateComplete; + + await element['handleStudentLogin'](new Event('submit')); + await element.updateComplete; + + expect(element['errorMessage']).toContain('did not match'); + expect(element['pendingPin']).toBe(''); + expect(element['pin']).toBe(''); + expect(submitButton().textContent?.trim()).toBe('Create'); + }); + + it('should clear a half-finished create when the service ID changes', async () => { + element['pendingPin'] = '1234'; + const serviceIdInput = element.shadowRoot?.querySelector( + 'input[name="serviceId"]', + ) as HTMLInputElement; + serviceIdInput.value = '30099999'; + serviceIdInput.dispatchEvent(new Event('input')); + await element.updateComplete; + + expect(element['pendingPin']).toBe(''); + }); + }); + describe('UI Behavior', () => { it('should disable Login button when form is invalid', () => { const buttons = element.shadowRoot?.querySelectorAll('button');