137 lines
5.6 KiB
TypeScript
137 lines
5.6 KiB
TypeScript
/**
|
|
* settings-profile-keys-account-scoped-smoke (cp346)
|
|
*
|
|
* Regression guard for the cross-account profile-draft leak: signing out of
|
|
* one account and into another used to show the previous account's cached
|
|
* display-name / short-bio / blurt.media / nostr values in the settings form,
|
|
* because those localStorage keys were GLOBAL (`morphit.displayName` etc.).
|
|
*
|
|
* This smoke is a static source scan of the settings page. It asserts the four
|
|
* profile-draft keys are scoped by the current account, that the pre-cp346
|
|
* global keys are purged on mount, and that the form hydrates empty fields from
|
|
* the on-chain profile (so a fresh device shows real values, not blanks) — and,
|
|
* critically, that none of the four keys is ever written under its bare global
|
|
* name (which is what caused the leak).
|
|
*
|
|
* Static-only (no runtime); runs under the smoke tsconfig like the other
|
|
* source-scan sentinels.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const webRoot = join(here, '..');
|
|
|
|
function read(p: string): string {
|
|
return readFileSync(p, 'utf8');
|
|
}
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
function check(label: string, cond: boolean, detail = ''): void {
|
|
if (cond) {
|
|
passed++;
|
|
console.log(` \u2713 ${label}`);
|
|
} else {
|
|
failed++;
|
|
console.log(` \u2717 ${label}${detail ? `: ${detail}` : ''}`);
|
|
}
|
|
}
|
|
|
|
console.log('\nsettings-profile-keys-account-scoped smoke:\n');
|
|
|
|
const settings = read(join(webRoot, 'src/routes/[lang]/settings/+page.svelte'));
|
|
|
|
const bases = ['morphit.displayName', 'morphit.nostrUrl', 'morphit.streamingUrl', 'morphit.websiteUrl', 'morphit.shortBio'];
|
|
|
|
// 1. The per-account suffix is derived from the logged-in account.
|
|
// v1.8.11 — this used to require the scope be a plain `const … = browser ?
|
|
// (getUserBlurtAccount() …`, i.e. resolved ONCE at component init. That is
|
|
// precisely the bug Ken hit: sign out and back in as someone else without a
|
|
// page reload and the component never remounts, so the suffix stays pinned to
|
|
// the PREVIOUS account and the new person reads their drafts. The requirement
|
|
// is now the STRONGER one — the scope must be REACTIVE — so the assertion
|
|
// pins $derived rather than the const form it replaced.
|
|
check(
|
|
'profile-key scope is REACTIVE (re-resolves on sign-out/sign-in), not resolved once',
|
|
/PROFILE_KEY_SCOPE\s*=\s*\$derived/.test(settings),
|
|
'a const captured at init keeps the previous account\'s suffix across an in-SPA account switch'
|
|
);
|
|
check(
|
|
'profile-key scope still derives from getUserBlurtAccount()',
|
|
/PROFILE_KEY_SCOPE\s*=\s*\$derived[\s\S]{0,400}?getUserBlurtAccount\(\)/.test(settings)
|
|
);
|
|
check(
|
|
'the suffix, and every key built from it, are reactive too',
|
|
/PROFILE_KEY_SUFFIX\s*=\s*\$derived\(PROFILE_KEY_SCOPE\s*\?\s*`\.\$\{PROFILE_KEY_SCOPE\}`/.test(
|
|
settings
|
|
),
|
|
'a const suffix would freeze at the first account, reintroducing the bug one layer down'
|
|
);
|
|
|
|
// 2. Each of the four keys is defined WITH the scope suffix.
|
|
for (const base of bases) {
|
|
const scoped = new RegExp('`' + base.replace('.', '\\.') + '\\$\\{PROFILE_KEY_SUFFIX\\}`');
|
|
check(`${base} key is account-scoped`, scoped.test(settings));
|
|
}
|
|
|
|
// 3. No key is ever WRITTEN under its bare global name (the leak vector).
|
|
// setItem/getItem/removeItem must use the scoped constant, never a string
|
|
// literal equal to one of the bare base names.
|
|
for (const base of bases) {
|
|
const literalWrite = new RegExp(
|
|
'(setItem|getItem|removeItem)\\(\\s*[\'"]' + base.replace('.', '\\.') + '[\'"]'
|
|
);
|
|
// Exception: the LEGACY_GLOBAL_PROFILE_KEYS purge list intentionally names
|
|
// the bare keys, but it removes them via the array (removeItem(legacy)),
|
|
// not via a string-literal removeItem — so a literal call here is a leak.
|
|
check(`${base} is never read/written under its bare global name`, !literalWrite.test(settings));
|
|
}
|
|
|
|
// 4. The legacy global keys are declared and purged on mount.
|
|
check(
|
|
'legacy global keys are declared for purge',
|
|
/LEGACY_GLOBAL_PROFILE_KEYS\s*=\s*\[/.test(settings) &&
|
|
bases.every((b) => settings.includes(`'${b}'`))
|
|
);
|
|
check(
|
|
'legacy global keys are removed on mount',
|
|
/for \(const legacy of LEGACY_GLOBAL_PROFILE_KEYS\)/.test(settings) &&
|
|
/removeItem\(legacy\)/.test(settings)
|
|
);
|
|
|
|
// 5. Empty fields hydrate from the on-chain profile (Bug B), gated on "no local
|
|
// draft for this account".
|
|
check(
|
|
'tracks which fields have no local draft for this account',
|
|
/const noLocalName = !s;/.test(settings) &&
|
|
/const noLocalStreaming = !bm;/.test(settings) &&
|
|
/const noLocalWebsite = !ws;/.test(settings) &&
|
|
/const noLocalBio = !bio;/.test(settings) &&
|
|
/const noLocalNostr = !n;/.test(settings)
|
|
);
|
|
check(
|
|
'hydrates empty fields from extractLabelPropsFromProfile',
|
|
/if \(noLocalName && props\.displayName\)/.test(settings) &&
|
|
/if \(noLocalBio && props\.shortBio\)/.test(settings) &&
|
|
/if \(noLocalStreaming && props\.streamingUrl\)/.test(settings) &&
|
|
/if \(noLocalWebsite && props\.websiteUrl\)/.test(settings) &&
|
|
/if \(noLocalNostr && props\.nostrUrl\)/.test(settings)
|
|
);
|
|
|
|
// 6. The extractor actually exposes shortBio (so the bio hydration is real).
|
|
const profileProps = read(join(webRoot, 'src/lib/indexer/profileProps.ts'));
|
|
check(
|
|
"profileProps exposes shortBio from json_metadata.short_bio",
|
|
/readonly shortBio: string \| null;/.test(profileProps) &&
|
|
/shortBio: str\('short_bio'\)/.test(profileProps)
|
|
);
|
|
|
|
console.log('');
|
|
if (failed === 0) {
|
|
console.log(`\u2713 all ${passed} settings-profile-keys-account-scoped scenarios passed`);
|
|
} else {
|
|
console.log(`\u2717 ${failed} failed, ${passed} passed`);
|
|
process.exit(1);
|
|
}
|