155 lines
7.1 KiB
TypeScript
155 lines
7.1 KiB
TypeScript
/**
|
|
* locked-session-ux-smoke — the locked-but-remembered UX (cp340, cp341 icons).
|
|
*
|
|
* WHY: a keyfile + password + "Remember me" user who refreshes is correctly
|
|
* locked (decrypted keys never persist across a reload — the security
|
|
* posture), and their encrypted keystore SURVIVES (cp334). The envelope is
|
|
* intact; they only need to re-enter their password. cp340 makes that obvious
|
|
* instead of looking like a full logout:
|
|
*
|
|
* 1. The header CTA reads "Unlock" (common.unlock), not "Start", when this
|
|
* device has a remembered keystore — a click lands on the welcome-back
|
|
* unlock screen, not a fresh import.
|
|
* 2. Refreshing while locked on a protected page (Settings) routes to the
|
|
* homepage (the user can't use Settings without a live session); the
|
|
* "Unlock" CTA then takes them to welcome-back. Runs ONCE on mount (not
|
|
* an $effect — a later idle auto-lock must not yank an active user away).
|
|
* 3. The "sign in with your keys" buttons (welcome-back use_seed_instead AND
|
|
* the import-needed login.import_existing) carry the yellow 🔐 lock
|
|
* emoji, while the "use phone instead" buttons use the monochrome QR
|
|
* svg glyph. cp35x reverted cp341's monochrome lock svg back to the
|
|
* 🔐 emoji: the colourful padlock is the more recognizable "this
|
|
* unlocks your account" affordance, which the operator prefers over a
|
|
* strictly-monochrome icon set.
|
|
*
|
|
* Usage (from apps/web): tsx scripts/locked-session-ux-smoke.ts
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const read = (...p: string[]): string =>
|
|
readFileSync(join(import.meta.dirname, '..', ...p), 'utf-8');
|
|
|
|
const avatar = read('src', 'lib', 'components', 'AvatarMenu.svelte');
|
|
const settings = read('src', 'routes', '[lang]', 'settings', '+page.svelte');
|
|
const requireLiveSession = read('src', 'lib', 'components', 'RequireLiveSession.svelte');
|
|
const login = read('src', 'routes', '[lang]', 'login', '+page.svelte');
|
|
const en = JSON.parse(read('src', 'lib', 'i18n', 'locales', 'en.json')) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
|
|
let failures = 0;
|
|
let checks = 0;
|
|
function check(name: string, cond: boolean, detail = ''): void {
|
|
checks++;
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
failures++;
|
|
console.log(` ✗ ${name}${detail ? `: ${detail}` : ''}`);
|
|
}
|
|
}
|
|
|
|
// ── 1. Header CTA: conditional Unlock vs Start ───────────────────────────────
|
|
// v1.8.11 — this required the label read `hasPersistedKeystore()`, a plain
|
|
// localStorage call. Inside a $derived that only re-runs when $hasAnySession
|
|
// changes, it covers LOCK (session flips true→false) but NOT signing out while
|
|
// ALREADY locked: $hasAnySession is false on both sides, the derived never
|
|
// re-runs, and the button stays on "Unlock" after the keystore has been wiped.
|
|
// That path is reachable — the welcome-back screen offers sign-out. The label
|
|
// now reads the reactive $persistedKeystorePresent store, so the assertion
|
|
// requires the reactive source rather than the function it replaced.
|
|
check(
|
|
'AvatarMenu CTA reads the REACTIVE keystore store, not a one-shot localStorage call',
|
|
/signedOutCtaLabel\s*=\s*\$derived\([\s\S]*?\$persistedKeystorePresent/.test(avatar),
|
|
'hasPersistedKeystore() cannot retrigger a derived, so sign-out-while-locked left the label stale'
|
|
);
|
|
const keystoreSrc = read('src', 'lib', 'crypto', 'persistentKeystore.ts');
|
|
check(
|
|
'the keystore store exists and is notified by its mutators',
|
|
/export const persistedKeystorePresent/.test(keystoreSrc) &&
|
|
(keystoreSrc.match(/notifyKeystoreChanged\(\)/g) ?? []).length >= 4,
|
|
'a store nobody updates is worse than the function it replaced — every write/clear path must notify'
|
|
);
|
|
check(
|
|
'signed-out CTA chooses common.unlock (remembered) vs nav.start (fresh)',
|
|
/signedOutCtaLabel\s*=\s*\$derived\([\s\S]*?common\.unlock[\s\S]*?nav\.start/.test(avatar)
|
|
);
|
|
check(
|
|
'the header sign-in button renders {signedOutCtaLabel}, not a hardcoded nav.start',
|
|
/\{signedOutCtaLabel\}/.test(avatar) &&
|
|
// the only remaining nav.start reference is inside the derived
|
|
(avatar.match(/nav\.start/g) || []).length === 1
|
|
);
|
|
|
|
// ── 2. Locked visitors on session-required pages go to the welcome-back ──────
|
|
// unlock screen, carrying the page they wanted as ?next= so they land
|
|
// there after unlocking (cp356) instead of on the homepage.
|
|
check(
|
|
'Settings delegates the locked-redirect to <RequireLiveSession />',
|
|
/<RequireLiveSession\s*\/>/.test(settings)
|
|
);
|
|
check(
|
|
'RequireLiveSession redirects to the welcome-back unlock screen carrying ?next=',
|
|
/gotoLocale\(\s*['"]\/login\?next=/.test(requireLiveSession)
|
|
);
|
|
check(
|
|
'the redirect is guarded by !isUnlocked && !isPairedReadOnly (paired keeps access) and runs in onMount (once), routing to /login?next=',
|
|
/!get\(isUnlocked\)[\s\S]{0,40}!get\(isPairedReadOnly\)/.test(requireLiveSession) &&
|
|
/onMount\(\(\)\s*=>\s*\{[\s\S]*?gotoLocale\(\s*['"]\/login\?next=[\s\S]*?\}\)/.test(
|
|
requireLiveSession
|
|
)
|
|
);
|
|
|
|
// ── 3. Key buttons show the yellow 🔐 lock emoji; phone buttons the QR svg ───
|
|
// (cp35x: reverted the monochrome-svg lock back to the 🔐 emoji per
|
|
// operator preference — the colourful padlock is the recognizable
|
|
// "this unlocks your account" affordance.)
|
|
check(
|
|
'welcome-back seed button uses the use_seed_instead label',
|
|
/use_seed_instead/.test(login)
|
|
);
|
|
check(
|
|
'en use_seed_instead label has no inline emoji (the 🔐 lives in the markup span)',
|
|
typeof (en.login as { welcome_back?: { use_seed_instead?: string } })?.welcome_back
|
|
?.use_seed_instead === 'string' &&
|
|
!(
|
|
en.login as { welcome_back: { use_seed_instead: string } }
|
|
).welcome_back.use_seed_instead.includes('\u{1F510}')
|
|
);
|
|
check(
|
|
'en login.import_existing label has no inline emoji (the 🔐 lives in the markup span)',
|
|
typeof (en.login as { import_existing?: string }).import_existing === 'string' &&
|
|
!(en.login as { import_existing: string }).import_existing.includes('\u{1F510}')
|
|
);
|
|
check(
|
|
'the 🔐 lock emoji renders on BOTH the welcome-back seed and import-needed import buttons',
|
|
(login.match(/\u{1F510}/gu) || []).length >= 2
|
|
);
|
|
check(
|
|
'the QR icon svg renders on BOTH the welcome-back and import-needed phone buttons',
|
|
(login.match(/viewBox="-1 -1 2002 2002"/g) || []).length >= 2
|
|
);
|
|
|
|
// ── 4. common.unlock locale key exists ───────────────────────────────────────
|
|
check(
|
|
'common.unlock exists in en',
|
|
typeof (en.common as { unlock?: string })?.unlock === 'string' &&
|
|
(en.common as { unlock: string }).unlock.length > 0
|
|
);
|
|
|
|
// ── 5. Welcome-back autofocuses the password field (cp343) ───────────────────
|
|
check(
|
|
'the welcome-back password field autofocuses on mount (use:focusOnMount)',
|
|
/id="unlock-password"[\s\S]*?use:focusOnMount/.test(login) && /function focusOnMount\(/.test(login)
|
|
);
|
|
|
|
console.log('');
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${checks} locked-session-ux scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures} check(s) failed`);
|
|
process.exit(1);
|
|
}
|