Client half of #370. Lists every device signed in to your account, with a per-row sign-out and a "sign out all other devices" action. The card does one thing the API alone doesn't: it says "Address changed" when created_ip and last_ip differ, rather than printing two addresses and leaving you to compare them. That mismatch — same device string, different origin — is the shape of a stolen token, and it's the reason IP capture was worth a migration. Making the operator spot it by eye would have wasted the data. Placed with Password and API Token rather than at the bottom of the page: those three are the account-security group, and this is the one that tells you the other two need attention. Details worth naming: - The current session gets a "This device" badge and NO sign-out button — offering one would log you out of the page you're standing on. The server already excludes it from logout-others; this makes that visible. - Sign-out-all-others is a two-step confirm and states the count, so the button can't be a surprise. - A 404 on revoke reloads instead of erroring. It means the session is already gone — revoked elsewhere, or expired — so the list was simply stale and showing the truth is the right response. The code is `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it. - Empty and error states both handled (rule #24); the empty case is practically unreachable since listing requires an authenticated request, and is handled rather than assumed. - User-agent parsing is deliberately coarse. A real UA parser is a dependency and a maintenance burden for a string whose only job is "do you recognise this?" — the addresses carry the actual signal. Tests cover the parts that would be quiet if broken: the current-session badge suppressing its own sign-out button, the address-changed warning appearing and NOT appearing, the two-step confirm not firing on first click, and the load-failure retry. Android parity is a separate decision, not assumed.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import ActiveSessions from './ActiveSessions.svelte';
|
||||
|
||||
const listSessions = vi.fn();
|
||||
const revokeSession = vi.fn();
|
||||
const revokeOtherSessions = vi.fn();
|
||||
|
||||
vi.mock('$lib/api/me', () => ({
|
||||
listSessions: (...a: unknown[]) => listSessions(...a),
|
||||
revokeSession: (...a: unknown[]) => revokeSession(...a),
|
||||
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
user_agent: string;
|
||||
created_ip: string;
|
||||
last_ip: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
function row(over: Partial<Row> = {}): Row {
|
||||
return {
|
||||
id: 'a1',
|
||||
user_agent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
|
||||
created_ip: '203.0.113.1',
|
||||
last_ip: '203.0.113.1',
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
current: false,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ActiveSessions', () => {
|
||||
test('flags the current session and gives it no sign-out button', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ id: 'cur', current: true }),
|
||||
row({ id: 'other', current: false })
|
||||
]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await screen.findByText('This device');
|
||||
// One sign-out button, for the non-current row. Offering one on the
|
||||
// current session would sign the user out of the page they're using.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The whole reason IP is stored: surfacing the mismatch rather than making
|
||||
// someone compare two addresses by eye.
|
||||
test('warns when a session is used from a different address than it was created', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ id: 'moved', created_ip: '203.0.113.1', last_ip: '198.51.100.9' })
|
||||
]);
|
||||
render(ActiveSessions);
|
||||
|
||||
expect(await screen.findByText('Address changed')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('does not warn when the address has not changed', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ created_ip: '203.0.113.1', last_ip: '203.0.113.1' })
|
||||
]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await screen.findByText(/Signed in/);
|
||||
expect(screen.queryByText('Address changed')).toBeNull();
|
||||
});
|
||||
|
||||
test('sign-out-all-others confirms before acting', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ id: 'cur', current: true }),
|
||||
row({ id: 'o1' }),
|
||||
row({ id: 'o2' })
|
||||
]);
|
||||
revokeOtherSessions.mockResolvedValue(2);
|
||||
render(ActiveSessions);
|
||||
|
||||
const start = await screen.findByRole('button', { name: 'Sign out all other devices' });
|
||||
await fireEvent.click(start);
|
||||
// First click only arms the action.
|
||||
expect(revokeOtherSessions).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Sign out 2 other devices\?/)).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Sign them out' }));
|
||||
await waitFor(() => expect(revokeOtherSessions).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('offers no bulk action when there are no other devices', async () => {
|
||||
listSessions.mockResolvedValue([row({ id: 'cur', current: true })]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await screen.findByText('This device');
|
||||
expect(screen.queryByRole('button', { name: 'Sign out all other devices' })).toBeNull();
|
||||
});
|
||||
|
||||
test('surfaces a retry when loading fails', async () => {
|
||||
listSessions.mockRejectedValue(new Error('boom'));
|
||||
render(ActiveSessions);
|
||||
|
||||
const retry = await screen.findByRole('button', { name: 'Try again' });
|
||||
listSessions.mockResolvedValue([row({ id: 'cur', current: true })]);
|
||||
await fireEvent.click(retry);
|
||||
await screen.findByText('This device');
|
||||
});
|
||||
|
||||
test('renders unknown for a missing address rather than an empty cell', async () => {
|
||||
listSessions.mockResolvedValue([row({ created_ip: '', last_ip: '' })]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('unknown').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user