Files
cinny/e2e/security-nudge.spec.ts
T
Lotus CIandClaude Opus 5.5 8e7127a350
CI / Build & Quality Checks (pull_request) Successful in 3m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m21s
fix(security): a dismissed status announcement no longer hides the security nudge (#307)
SecurityBanner asked pickBanner whether another strip would show without
the dismissed set, so a dismissed Kuma announcement (often pinned for
weeks) still counted and suppressed the verify / backup nudge on every
device that had dismissed it. The dismissed set is now a shared jotai
atom (still persisted to localStorage) used by both banners. Adds a
Playwright regression.

Fixes #307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:26:54 -04:00

117 lines
4.4 KiB
TypeScript

import { test, expect, Page } from '@playwright/test';
import { HS, ensureUser, hsReachable, loginUI, TestUser, uniq } from './localHs';
// [Gitea #110, #123] The device-security nudge. A fresh account has no key
// backup and no cross-signing → "Set up key backup", but only after the 24 h
// grace period on this device; "Not now" snoozes it.
const strip = (page: Page) => page.getByRole('status').filter({ hasText: /encryption keys/ });
/** Back-date this device's first-seen record past the 24 h grace period, then reload. */
async function pastGrace(page: Page) {
await page.evaluate(() => {
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
localStorage.setItem(
`lotus-security-nudge-${deviceId}`,
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
);
});
await page.reload();
}
/** The nudge waits until sync has settled (after "Connecting…"). */
async function settled(page: Page) {
await page
.getByText('Connecting...')
.first()
.waitFor({ timeout: 30_000 })
.catch(() => undefined);
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
await page.waitForTimeout(2000);
}
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
async function mockAnnouncement(page: Page) {
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const cors = { 'access-control-allow-origin': '*' };
if (route.request().url().includes('/heartbeat/')) {
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
maintenanceList: [],
publicGroupList: [],
},
});
});
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
});
test.beforeEach(async () => {
user = await ensureUser(uniq('e2e_nudge_'));
});
test('nothing during the first 24 h on a device', async ({ page }) => {
test.setTimeout(150_000);
await loginUI(page, user);
await settled(page);
// Past the point where the nudge shows once the grace period is over (~5 s).
await page.waitForTimeout(8000);
await expect(strip(page)).toHaveCount(0);
});
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
test.setTimeout(150_000);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
const nudge = strip(page);
await expect(nudge).toContainText("Your encryption keys aren't backed up");
await nudge.getByRole('button', { name: 'Set up' }).click();
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
test.setTimeout(200_000);
await mockAnnouncement(page);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// The announcement wins the one strip slot until it's dismissed.
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
await expect(announcement).toBeVisible();
await expect(strip(page)).toHaveCount(0);
await announcement.getByRole('button', { name: 'Dismiss' }).click();
await page.reload();
await settled(page);
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
await strip(page).getByRole('button', { name: 'Not now' }).click();
await expect(strip(page)).toHaveCount(0);
await page.reload();
await settled(page);
await expect(strip(page)).toHaveCount(0);
});
});