mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
3c8fa07f44
The column showed the last sign-in, which a renewing cookie leaves weeks behind for someone using TREK daily. It now shows the later of the sign-in and the last request. Saving an edit no longer blanks the row's avatar, activity and online dot, and the list refreshes every 30 seconds while open.
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { UserSessions } from '../../../../src/db/entities/UserSessions.entity';
|
|
import { Users } from '../../../../src/db/entities/Users.entity';
|
|
import type { UserSessionsRepository } from '../../../../src/db/repositories/UserSessions.repository';
|
|
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
|
import { createUser } from '../../../helpers/factories';
|
|
import { deleteRows, findRow, updateRows } from '../../../helpers/factories/rows';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
const testDb = createSnapshotTestDb();
|
|
let t: TestOrm;
|
|
let sessions: UserSessionsRepository;
|
|
|
|
const NOW = '2026-10-08 12:00:00';
|
|
const LATER = '2026-11-07 12:00:00';
|
|
const EARLIER = '2026-10-01 12:00:00';
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
sessions = t.repo(UserSessions);
|
|
});
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
t.clear();
|
|
});
|
|
afterAll(async () => {
|
|
await t.close();
|
|
testDb.close();
|
|
});
|
|
|
|
function row(id: string) {
|
|
return findRow(t, UserSessions, { id });
|
|
}
|
|
|
|
function revoke(ids: string[], at: string) {
|
|
return updateRows(t, UserSessions, { id: { $in: ids } }, { revoked_at: at });
|
|
}
|
|
|
|
async function add(
|
|
id: string,
|
|
userId: number,
|
|
overrides: { created_at?: string; expires_at?: string; user_agent?: string | null } = {},
|
|
) {
|
|
await sessions.insertSession({
|
|
id,
|
|
user_id: userId,
|
|
created_at: overrides.created_at ?? EARLIER,
|
|
expires_at: overrides.expires_at ?? LATER,
|
|
user_agent: overrides.user_agent ?? null,
|
|
});
|
|
}
|
|
|
|
describe('UserSessionsRepository', () => {
|
|
it('SESSREPO-001: insert writes the row, last_seen_at starting at created_at', async () => {
|
|
const { user } = createUser(testDb);
|
|
await add('s1', user.id, { user_agent: 'Firefox' });
|
|
expect(await row('s1')).toStrictEqual({
|
|
id: 's1',
|
|
user_id: user.id,
|
|
created_at: EARLIER,
|
|
last_seen_at: EARLIER,
|
|
expires_at: LATER,
|
|
revoked_at: null,
|
|
user_agent: 'Firefox',
|
|
});
|
|
});
|
|
|
|
it('SESSREPO-002: findActive matches only an unrevoked, unexpired session of that user', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await add('live', user.id);
|
|
await add('expired', user.id, { expires_at: NOW });
|
|
await add('revoked', user.id);
|
|
await revoke(['revoked'], EARLIER);
|
|
|
|
expect(await sessions.findActive('live', user.id, NOW)).toEqual({ id: 'live', last_seen_at: EARLIER });
|
|
expect(await sessions.findActive('live', other.id, NOW)).toBeNull();
|
|
expect(await sessions.findActive('expired', user.id, NOW)).toBeNull();
|
|
expect(await sessions.findActive('revoked', user.id, NOW)).toBeNull();
|
|
expect(await sessions.findActive('missing', user.id, NOW)).toBeNull();
|
|
});
|
|
|
|
it('SESSREPO-003: touchLastSeen moves last_seen_at only', async () => {
|
|
const { user } = createUser(testDb);
|
|
await add('s1', user.id);
|
|
await sessions.touchLastSeen('s1', NOW);
|
|
expect(await row('s1')).toEqual(
|
|
expect.objectContaining({ last_seen_at: NOW, created_at: EARLIER, expires_at: LATER }),
|
|
);
|
|
});
|
|
|
|
it('SESSREPO-004: extendActive moves the expiry of an active session and refuses an ended one', async () => {
|
|
const { user } = createUser(testDb);
|
|
await add('live', user.id);
|
|
await add('revoked', user.id);
|
|
await revoke(['revoked'], EARLIER);
|
|
|
|
expect(await sessions.extendActive('live', user.id, NOW, '2026-12-01 00:00:00')).toBe(true);
|
|
expect(await row('live')).toEqual(
|
|
expect.objectContaining({ expires_at: '2026-12-01 00:00:00', last_seen_at: NOW }),
|
|
);
|
|
expect(await sessions.extendActive('revoked', user.id, NOW, '2026-12-01 00:00:00')).toBe(false);
|
|
expect(await row('revoked')).toEqual(expect.objectContaining({ expires_at: LATER }));
|
|
});
|
|
|
|
it('SESSREPO-005: listActiveForUser lists the active sessions, most recently seen first', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await add('older', user.id, { user_agent: 'A' });
|
|
await add('newer', user.id, { user_agent: 'B' });
|
|
await add('ended', user.id);
|
|
await add('theirs', other.id);
|
|
await sessions.touchLastSeen('newer', NOW);
|
|
await revoke(['ended'], EARLIER);
|
|
|
|
expect(await sessions.listActiveForUser(user.id, NOW)).toEqual([
|
|
{ id: 'newer', created_at: EARLIER, last_seen_at: NOW, expires_at: LATER, user_agent: 'B' },
|
|
{ id: 'older', created_at: EARLIER, last_seen_at: EARLIER, expires_at: LATER, user_agent: 'A' },
|
|
]);
|
|
});
|
|
|
|
it('SESSREPO-006: revokeForUser ends one session of that user, once', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await add('s1', user.id);
|
|
|
|
expect(await sessions.revokeForUser('s1', other.id, NOW)).toBe(false);
|
|
expect(await row('s1')).toEqual(expect.objectContaining({ revoked_at: null }));
|
|
expect(await sessions.revokeForUser('s1', user.id, NOW)).toBe(true);
|
|
expect(await row('s1')).toEqual(expect.objectContaining({ revoked_at: NOW }));
|
|
expect(await sessions.revokeForUser('s1', user.id, NOW)).toBe(false);
|
|
});
|
|
|
|
it('SESSREPO-007: revokeAllForUser ends every session of the user, or all but one', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await add('a', user.id);
|
|
await add('b', user.id);
|
|
await add('c', user.id);
|
|
await add('theirs', other.id);
|
|
|
|
expect(await sessions.revokeAllForUser(user.id, NOW, 'b')).toBe(2);
|
|
expect(await row('b')).toEqual(expect.objectContaining({ revoked_at: null }));
|
|
expect(await sessions.revokeAllForUser(user.id, NOW)).toBe(1);
|
|
expect(await row('a')).toEqual(expect.objectContaining({ revoked_at: NOW }));
|
|
expect(await row('theirs')).toEqual(expect.objectContaining({ revoked_at: null }));
|
|
});
|
|
|
|
it('SESSREPO-008: deleteInactive removes the expired rows, revoked or not, and keeps the rest', async () => {
|
|
const { user } = createUser(testDb);
|
|
await add('live', user.id);
|
|
await add('expired', user.id, { expires_at: NOW });
|
|
await add('revoked', user.id);
|
|
await add('revoked-expired', user.id, { expires_at: EARLIER });
|
|
await revoke(['revoked', 'revoked-expired'], EARLIER);
|
|
|
|
expect(await sessions.deleteInactive(NOW)).toBe(2);
|
|
expect(await row('live')).not.toBeNull();
|
|
expect(await row('expired')).toBeNull();
|
|
expect(await row('revoked-expired')).toBeNull();
|
|
// Revoked but not yet expired: kept, it still refuses the token it was derived from.
|
|
expect(await row('revoked')).toEqual(expect.objectContaining({ revoked_at: EARLIER }));
|
|
});
|
|
|
|
it('SESSREPO-009: the rows go with their user', async () => {
|
|
const { user } = createUser(testDb);
|
|
await add('s1', user.id);
|
|
await deleteRows(t, Users, { id: user.id });
|
|
expect(await row('s1')).toBeNull();
|
|
});
|
|
|
|
it('SESSREPO-010: listActiveToCarry reads every active session with the email of its owner', async () => {
|
|
const { user } = createUser(testDb, { email: 'carry-a@example.test' });
|
|
const { user: other } = createUser(testDb, { email: 'carry-b@example.test' });
|
|
await add('a1', user.id, { user_agent: 'A' });
|
|
await add('b1', other.id);
|
|
await add('expired', user.id, { expires_at: NOW });
|
|
await add('revoked', other.id);
|
|
await revoke(['revoked'], EARLIER);
|
|
|
|
expect(await sessions.listActiveToCarry(NOW)).toEqual([
|
|
{
|
|
id: 'a1',
|
|
user_id: user.id,
|
|
email: 'carry-a@example.test',
|
|
created_at: EARLIER,
|
|
last_seen_at: EARLIER,
|
|
expires_at: LATER,
|
|
user_agent: 'A',
|
|
},
|
|
{
|
|
id: 'b1',
|
|
user_id: other.id,
|
|
email: 'carry-b@example.test',
|
|
created_at: EARLIER,
|
|
last_seen_at: EARLIER,
|
|
expires_at: LATER,
|
|
user_agent: null,
|
|
},
|
|
]);
|
|
expect(await sessions.listActiveToCarry(LATER)).toEqual([]);
|
|
});
|
|
|
|
it('SESSREPO-011: restoreCarried puts the rows back for the same account only, and keeps a row already there', async () => {
|
|
const { user } = createUser(testDb, { email: 'kept@example.test' });
|
|
const { user: renamed } = createUser(testDb, { email: 'renamed@example.test' });
|
|
await add('kept', user.id, { user_agent: 'Kept' });
|
|
await add('present', user.id);
|
|
await add('elsewhere', renamed.id);
|
|
const carried = await sessions.listActiveToCarry(NOW);
|
|
|
|
// What a swap does: the rows are gone (or ended), and one id now names somebody else.
|
|
await deleteRows(t, UserSessions, { id: { $in: ['kept', 'elsewhere'] } });
|
|
await revoke(['present'], EARLIER);
|
|
await updateRows(t, Users, { id: renamed.id }, { email: 'someone-else@example.test' });
|
|
t.clear();
|
|
|
|
expect(await sessions.restoreCarried(carried)).toBe(2);
|
|
expect(await row('kept')).toStrictEqual({
|
|
id: 'kept',
|
|
user_id: user.id,
|
|
created_at: EARLIER,
|
|
last_seen_at: EARLIER,
|
|
expires_at: LATER,
|
|
revoked_at: null,
|
|
user_agent: 'Kept',
|
|
});
|
|
// The swapped-in file's own row wins, ended or not.
|
|
expect(await row('present')).toEqual(expect.objectContaining({ revoked_at: EARLIER }));
|
|
expect(await row('elsewhere')).toBeNull();
|
|
});
|
|
|
|
it('SESSREPO-012: restoreCarried drops a session whose user is gone, and does nothing for none', async () => {
|
|
const { user } = createUser(testDb, { email: 'gone@example.test' });
|
|
await add('orphan', user.id);
|
|
const carried = await sessions.listActiveToCarry(NOW);
|
|
await deleteRows(t, Users, { id: user.id });
|
|
t.clear();
|
|
|
|
expect(await sessions.restoreCarried(carried)).toBe(0);
|
|
expect(await row('orphan')).toBeNull();
|
|
expect(await sessions.restoreCarried([])).toBe(0);
|
|
});
|
|
|
|
it('SESSREPO-013: insertSessionIfAbsent writes a new row and leaves an existing one alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
await sessions.insertSessionIfAbsent({
|
|
id: 'derived',
|
|
user_id: user.id,
|
|
created_at: EARLIER,
|
|
expires_at: LATER,
|
|
user_agent: 'First',
|
|
});
|
|
await revoke(['derived'], NOW);
|
|
t.clear();
|
|
|
|
await sessions.insertSessionIfAbsent({
|
|
id: 'derived',
|
|
user_id: user.id,
|
|
created_at: NOW,
|
|
expires_at: '2026-12-01 00:00:00',
|
|
user_agent: 'Second',
|
|
});
|
|
|
|
expect(await row('derived')).toStrictEqual({
|
|
id: 'derived',
|
|
user_id: user.id,
|
|
created_at: EARLIER,
|
|
last_seen_at: EARLIER,
|
|
expires_at: LATER,
|
|
revoked_at: NOW,
|
|
user_agent: 'First',
|
|
});
|
|
});
|
|
it('SESSREPO-014: lastSeenByUser gives each account its newest last_seen_at, revoked rows included', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const { user: idle } = createUser(testDb);
|
|
await add('a', user.id);
|
|
await add('b', user.id);
|
|
await add('c', other.id);
|
|
await sessions.touchLastSeen('a', NOW);
|
|
await revoke(['c'], NOW);
|
|
const seen = await sessions.lastSeenByUser();
|
|
expect(seen.get(user.id)).toBe(NOW);
|
|
expect(seen.get(other.id)).toBe(EARLIER);
|
|
expect(seen.has(idle.id)).toBe(false);
|
|
});
|
|
});
|