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.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..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" - 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 da85f73..37f4c6c 100644 --- a/tests/unit/qd-login.test.ts +++ b/tests/unit/qd-login.test.ts @@ -333,15 +333,91 @@ 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.title).toBe('4-digit PIN'); + expect(pinInput.placeholder).toContain('4 digits'); 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');