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