Files
cinny/src/app/utils/securityNudge.test.ts
T
Lotus CIandClaude Opus 5.5 c907b0ba99
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m20s
feat(security): nudge to sign out sessions unused for 3 months (#113)
Sessions of this account not seen for 90+ days (never the current one; a
session with no last-seen time is left alone) get a nudge in the existing
security strip, after the device-security ones and with the same rules
("Not now" snoozes 7 days, gone after 3 dismissals; a snoozed security
nudge no longer hides it): "You have 3 sessions you haven't used in 3
months. Signing them out keeps your account safe if an old device is
lost." [Review]

Review opens Settings -> Devices with those sessions ticked for sign-out
(one confirm, the usual re-auth incl. SSO); they are listed first and
marked "Inactive for 3+ months". OIDC accounts sign out on the
provider's dashboard, so nothing is ticked there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 23:52:16 -04:00

127 lines
4.8 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
dismissNudge,
NUDGE_GRACE_MS,
NUDGE_SNOOZE_MS,
nudgeFor,
parseNudgeRecord,
SecurityState,
shouldShowNudge,
pickNudge,
staleSessions,
staleSessionsText,
} from './securityNudge';
const healthy: SecurityState = {
crossSigning: true,
deviceVerified: true,
backupOnServer: true,
backupActive: true,
};
test('the #123 table: which nudge for which state', () => {
// E: healthy → nothing, ever.
assert.equal(nudgeFor(healthy), undefined);
// B: cross-signing, this device unverified, backup on server → verify first.
assert.equal(nudgeFor({ ...healthy, deviceVerified: false }), 'verify-device');
// C: cross-signing, unverified, no backup → still verify first.
assert.equal(
nudgeFor({ ...healthy, deviceVerified: false, backupOnServer: false }),
'verify-device',
);
// D: verified, no backup → #110's set-up nudge.
assert.equal(nudgeFor({ ...healthy, backupOnServer: false }), 'setup-backup');
// #110 state 2: backup exists, this device not using it.
assert.equal(nudgeFor({ ...healthy, backupActive: false }), 'connect-backup');
// A: never set up cross-signing, no backup → set up (the flow does both).
assert.equal(
nudgeFor({
crossSigning: false,
deviceVerified: false,
backupOnServer: false,
backupActive: false,
}),
'setup-backup',
);
});
test('nothing while crypto is still loading (F)', () => {
assert.equal(nudgeFor({ ...healthy, deviceVerified: undefined }), undefined);
assert.equal(nudgeFor({ ...healthy, backupOnServer: undefined }), undefined);
assert.equal(nudgeFor({ ...healthy, backupActive: undefined }), undefined);
// Unknown backup connection doesn't hide a known "no backup at all".
assert.equal(
nudgeFor({ ...healthy, backupOnServer: false, backupActive: undefined }),
'setup-backup',
);
});
test('timing: 24 h grace, 7-day snooze, stop after 3 dismissals', () => {
const t0 = 1_700_000_000_000;
let record = parseNudgeRecord(null, t0);
assert.equal(shouldShowNudge('verify-device', record, t0), false, 'first launch');
assert.equal(shouldShowNudge('verify-device', record, t0 + NUDGE_GRACE_MS - 1), false);
let now = t0 + NUDGE_GRACE_MS;
assert.equal(shouldShowNudge('verify-device', record, now), true, 'after 24 h');
for (let i = 1; i <= 3; i += 1) {
record = dismissNudge('verify-device', record, now);
assert.equal(shouldShowNudge('verify-device', record, now), false, `snoozed after #${i}`);
now += NUDGE_SNOOZE_MS;
assert.equal(shouldShowNudge('verify-device', record, now), i < 3, `after 7 days (#${i})`);
}
// Dismissing one nudge doesn't hide another.
assert.equal(shouldShowNudge('setup-backup', record, now), true);
});
test('stored record: round trip, and garbage starts a fresh grace period', () => {
const t0 = 1_700_000_000_000;
const r = dismissNudge('setup-backup', parseNudgeRecord(null, t0 - 5), t0);
assert.deepEqual(parseNudgeRecord(JSON.stringify(r), t0 + 1), r);
for (const bad of ['{', 'null', '"x"', '{"firstSeen":"yesterday"}', '{"firstSeen":1}']) {
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
}
});
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
const now = Date.UTC(2026, 9, 25);
const day = 86_400_000;
const devices = [
{ device_id: 'ME', last_seen_ts: now - 400 * day },
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
{ device_id: 'UNKNOWN' },
{ device_id: 'NULL', last_seen_ts: null },
];
assert.deepEqual(
staleSessions(devices, 'ME', now).map((d) => d.device_id),
['OLD', 'EDGE'],
);
});
test('[#113] wording follows the count', () => {
assert.match(
staleSessionsText(1),
/^You have a session you haven't used in 3 months\. Signing it out/,
);
assert.match(
staleSessionsText(3),
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
);
});
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
const now = 10 * 86_400_000;
const fresh = { firstSeen: 0, dismissals: {} };
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
const snoozed = dismissNudge('verify-device', fresh, now);
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
// inside the 24 h grace period nothing shows
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
});