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
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
99 lines
3.8 KiB
TypeScript
99 lines
3.8 KiB
TypeScript
import { test, expect, Page } from '@playwright/test';
|
|
import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
|
|
|
// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security
|
|
// one), and Review opens Settings → Devices with them ticked for sign-out.
|
|
// Synapse won't backdate last-seen, so the session list is served with one
|
|
// real extra session made 120 days old.
|
|
const DAY = 86_400_000;
|
|
|
|
const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text });
|
|
|
|
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();
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
test.describe('stale sessions (#113)', () => {
|
|
test.beforeAll(async () => {
|
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
|
});
|
|
|
|
test('nudge after the security one, Review ticks the old session, sign-out works', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const user = await ensureUser(uniq('ss'));
|
|
// Two extra sessions: one we'll make look 4 months old, one recent.
|
|
const login = (device: string) =>
|
|
api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
|
type: 'm.login.password',
|
|
identifier: { type: 'm.id.user', user: user.localpart },
|
|
password: PASSWORD,
|
|
device_id: device,
|
|
initial_device_display_name: device,
|
|
});
|
|
await login('OLDLAPTOP');
|
|
await login('RECENTPHONE');
|
|
await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => {
|
|
const res = await route.fetch();
|
|
const body = await res.json();
|
|
for (const d of body.devices ?? []) {
|
|
if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY;
|
|
else d.last_seen_ts = Date.now() - DAY;
|
|
}
|
|
await route.fulfill({ response: res, json: body });
|
|
});
|
|
|
|
await loginUI(page, user);
|
|
await pastGrace(page);
|
|
await settled(page);
|
|
|
|
// A fresh account has no key backup: that nudge comes first.
|
|
const security = strip(page, /encryption keys/);
|
|
await expect(security).toBeVisible({ timeout: 30_000 });
|
|
await security.getByRole('button', { name: 'Not now' }).click();
|
|
|
|
const stale = strip(page, /a session you haven't used in 3 months/);
|
|
await expect(stale).toBeVisible({ timeout: 30_000 });
|
|
await stale.getByRole('button', { name: 'Review' }).click();
|
|
|
|
const dialog = page.getByRole('dialog').first();
|
|
await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1);
|
|
await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible();
|
|
|
|
// The sticky "Logout from selected devices" bar comes last.
|
|
await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click();
|
|
await page.locator('input[name="passwordInput"]').fill(PASSWORD);
|
|
await page.getByRole('button', { name: 'Continue' }).click();
|
|
await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, {
|
|
timeout: 30_000,
|
|
});
|
|
|
|
const { devices } = await api<{ devices: { device_id: string }[] }>(
|
|
'GET',
|
|
'/_matrix/client/v3/devices',
|
|
user.token,
|
|
);
|
|
const ids = devices.map((d) => d.device_id);
|
|
expect(ids).not.toContain('OLDLAPTOP');
|
|
expect(ids).toContain('RECENTPHONE');
|
|
expect(HS).toBeTruthy();
|
|
});
|
|
});
|