feat: device-security nudge — verify this device / key backup (#110, #123)
CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m38s
CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m38s
One "security" strip, in the same top slot and style as the status banner (#124), for this device, in priority order: - "Verify this device" — cross-signing exists but this device isn't verified (can't unlock backed-up history, untrusted to others). First, because verifying with the recovery key also connects the backup. - "Connect this device to your key backup" — a backup exists, this device isn't using it. - "Set up key backup" — no backup at all (includes accounts without cross-signing; the setup flow does both). The button opens Settings → Devices (existing flows); "Not now" snoozes. Rules: nothing in a device's first 24 h; "Not now" snoozes that nudge 7 days; 3 dismissals stop it; never on a healthy device; waits until sync has settled (never under "Connecting…"); outage/maintenance/connection strips win. Per-device record in localStorage, wiped on logout. Mounted inside the Matrix client context and an error boundary (an early version outside the context crashed the app on load — caught before pushing). Also: the status strip wraps its text on phones instead of truncating it when there's no Details button (benefits #124's strips too). Design approved on #123 (real-client screenshots there). Tests: 4 unit (the #123 state table, loading, timing, stored record); e2e: nothing during the grace period, "Set up key backup" → Settings → Devices, "Not now" holds across a reload. Unit 1319, Playwright 29 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8e30c73e2f
commit
6a7627fa26
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
dismissNudge,
|
||||
NUDGE_GRACE_MS,
|
||||
NUDGE_SNOOZE_MS,
|
||||
nudgeFor,
|
||||
parseNudgeRecord,
|
||||
SecurityState,
|
||||
shouldShowNudge,
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user