mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(sessions): carry the signed-in sessions across a demo reset and a restore
Both swap the whole database file. The demo reset copies a baseline saved at first seed, before anyone logged in, so it brings back an empty user_sessions table; a backup restore brings the backup's own rows, or none for a backup from before sessions were tracked. A session token whose row is missing is refused, so the hourly reset signed every demo visitor and the demo admin out, and the admin who ran a restore got a 401 on the next request. Before session tracking only the password version applied, and those tokens survived. Both paths now read the active rows with their owner's email before the swap and put them back after the reopen, in a fresh fork, for every account the new file still holds under the same id and email. A row the new file already has is left as it is, so a session that ended there stays ended, and a session whose id now names somebody else (a backup from another install) is dropped instead of handed to them. The password version check still refuses a token the restored account no longer matches. Reading or writing them is best effort and never fails the reset or the restore.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { UserSessions } from '../entities/UserSessions.entity';
|
||||
import { Users } from '../entities/Users.entity';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
@@ -18,6 +19,14 @@ const _userSessionRowKeys: AssertRowKeys<UserSessionRow, UserSessions> = true;
|
||||
/** What a session list shows: the row without its owner, who is the caller. */
|
||||
export type UserSessionListRow = Omit<UserSessionRow, 'user_id' | 'revoked_at'>;
|
||||
|
||||
/**
|
||||
* An active session read before a database file swap, with the email its
|
||||
* owner had then, so it is put back only for that same account.
|
||||
*/
|
||||
export interface CarriedUserSessionRow extends Omit<UserSessionRow, 'revoked_at'> {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** The columns a new sign-in writes; `last_seen_at` starts at `created_at`. */
|
||||
export interface NewUserSessionRow {
|
||||
id: string;
|
||||
@@ -124,6 +133,70 @@ export class UserSessionsRepository extends TrekRepository<UserSessions> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every active session with its owner's email, read before the database
|
||||
* file is swapped (a demo reset, a backup restore):
|
||||
* `SELECT s.* FROM user_sessions s WHERE s.revoked_at IS NULL AND s.expires_at > ?`,
|
||||
* then `SELECT id, email FROM users WHERE id IN (...)`.
|
||||
*/
|
||||
async listActiveToCarry(now: string): Promise<CarriedUserSessionRow[]> {
|
||||
const rows = await this.find({ revoked_at: null, expires_at: { $gt: now } }, { orderBy: { id: 'asc' } });
|
||||
if (rows.length === 0) return [];
|
||||
const emails = await this.ownerEmails(rows.map((row) => row.user.id));
|
||||
const carried: CarriedUserSessionRow[] = [];
|
||||
for (const row of rows) {
|
||||
const email = emails.get(row.user.id);
|
||||
if (email === undefined) continue;
|
||||
carried.push({
|
||||
id: row.id,
|
||||
user_id: row.user.id,
|
||||
email,
|
||||
created_at: row.created_at,
|
||||
last_seen_at: row.last_seen_at,
|
||||
expires_at: row.expires_at,
|
||||
user_agent: row.user_agent ?? null,
|
||||
});
|
||||
}
|
||||
return carried;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the sessions `listActiveToCarry` read back after the swap:
|
||||
* `INSERT INTO user_sessions (...) VALUES (...) ON CONFLICT (id) DO NOTHING`
|
||||
* for each one whose user is still there under the same id and the same
|
||||
* email. A row the swapped-in file already holds is left as it is, revoked
|
||||
* or not, and a session whose id now belongs to somebody else (a backup
|
||||
* from another install) is dropped rather than handed to them. Answers how
|
||||
* many were offered for insert.
|
||||
*/
|
||||
async restoreCarried(rows: readonly CarriedUserSessionRow[]): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
const emails = await this.ownerEmails(rows.map((row) => row.user_id));
|
||||
const kept = rows.filter((row) => emails.get(row.user_id) === row.email);
|
||||
if (kept.length === 0) return 0;
|
||||
await this.upsertMany(
|
||||
kept.map((row) => ({
|
||||
id: row.id,
|
||||
user: row.user_id,
|
||||
created_at: row.created_at,
|
||||
last_seen_at: row.last_seen_at,
|
||||
expires_at: row.expires_at,
|
||||
revoked_at: null,
|
||||
user_agent: row.user_agent,
|
||||
})),
|
||||
{ onConflictFields: ['id'], onConflictAction: 'ignore' },
|
||||
);
|
||||
return kept.length;
|
||||
}
|
||||
|
||||
/** `SELECT id, email FROM users WHERE id IN (...)`, as a map. */
|
||||
private async ownerEmails(userIds: readonly number[]): Promise<Map<number, string>> {
|
||||
const owners = await this.getEntityManager()
|
||||
.getRepository(Users)
|
||||
.find({ id: { $in: [...new Set(userIds)] } }, { fields: ['id', 'email'] });
|
||||
return new Map(owners.map((owner) => [owner.id, owner.email]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The nightly purge: `DELETE FROM user_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL`.
|
||||
* A row that is gone refuses its token just as a revoked one does, so
|
||||
|
||||
@@ -5,6 +5,9 @@ import { readEnv } from '../app-config';
|
||||
import { resolveDataPaths } from '../app-config/data-paths';
|
||||
import { DemoRepository, type DemoAdminCredentialsRow, type DemoInstanceKeyRow } from '../db/repositories/DemoRepository';
|
||||
import { DatabaseConnectionLostError, type DatabaseBackupStrategy } from '../nest/database/database-backup.interface';
|
||||
import type { CarriedUserSessionRow } from '../db/repositories/UserSessions.repository';
|
||||
import { UserSessions } from '../db/entities/UserSessions.entity';
|
||||
import { dbNow } from '../db/types';
|
||||
|
||||
const baselinePath = path.join(resolveDataPaths().dataDir, 'travel-baseline.db');
|
||||
|
||||
@@ -76,6 +79,17 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
|
||||
console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// Everyone signed in stays signed in. The baseline was saved at first seed,
|
||||
// before anybody logged in, so it holds no session rows, and a session
|
||||
// token whose row is gone is refused: without this every demo visitor and
|
||||
// the demo admin would be signed out on the hour.
|
||||
let liveSessions: CarriedUserSessionRow[] = [];
|
||||
try {
|
||||
liveSessions = await preSwapEm.getRepository(UserSessions).listActiveToCarry(dbNow());
|
||||
} catch (e: unknown) {
|
||||
console.error('[Demo Reset] Failed to read the active sessions:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// Flush the WAL into the main file before the connection closes. Best effort.
|
||||
try {
|
||||
await database.checkpoint();
|
||||
@@ -128,6 +142,14 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
|
||||
console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
if (liveSessions.length) {
|
||||
try {
|
||||
await freshEm.getRepository(UserSessions).restoreCarried(liveSessions);
|
||||
} catch (e: unknown) {
|
||||
console.error('[Demo Reset] Failed to restore the active sessions:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Demo Reset] Database restored from baseline');
|
||||
|
||||
@@ -4,6 +4,10 @@ import { pipeline } from 'node:stream/promises';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { resolveDataPaths } from '../../app-config/data-paths';
|
||||
import fs from 'fs';
|
||||
import { RequestContext } from '@mikro-orm/core';
|
||||
import type { CarriedUserSessionRow } from '../../db/repositories/UserSessions.repository';
|
||||
import { UserSessions } from '../../db/entities/UserSessions.entity';
|
||||
import { dbNow } from '../../db/types';
|
||||
import { logError, logWarn } from '../audit/audit-log.logger';
|
||||
import type { DatabaseBackupStrategy } from '../database/database-backup.interface';
|
||||
import { VALID_INTERVALS } from './auto-backup.settings';
|
||||
@@ -410,6 +414,44 @@ export async function rehydrateUploads(storage: StorageService, extractedUploads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sessions signed in right now, read before the swap. The restored file
|
||||
* brings the backup's own `user_sessions` rows (none at all for a backup from
|
||||
* before sessions were tracked), and a session token whose row is missing is
|
||||
* refused, so without carrying these across the admin who ran the restore and
|
||||
* everybody else would be signed out by it. Best effort, like the snapshot:
|
||||
* a missing request context or an unreadable table carries nothing.
|
||||
*/
|
||||
async function readSessionsToCarry(): Promise<CarriedUserSessionRow[]> {
|
||||
try {
|
||||
const em = RequestContext.getEntityManager();
|
||||
if (!em) return [];
|
||||
return await em.getRepository(UserSessions).listActiveToCarry(dbNow());
|
||||
} catch (err) {
|
||||
logWarn(`Restore: could not read the active sessions (${err instanceof Error ? err.message : String(err)})`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the carried sessions into the restored database, in a fresh fork so no
|
||||
* entity read from the replaced file is flushed into it. Only a session whose
|
||||
* user is still there under the same id and email comes back; the password
|
||||
* version check still refuses a token the restored account no longer matches.
|
||||
*/
|
||||
async function restoreCarriedSessions(rows: readonly CarriedUserSessionRow[]): Promise<void> {
|
||||
const em = RequestContext.getEntityManager();
|
||||
if (!em || rows.length === 0) return;
|
||||
try {
|
||||
await RequestContext.create(em, async () => {
|
||||
const fresh = RequestContext.getEntityManager() ?? em;
|
||||
await fresh.getRepository(UserSessions).restoreCarried(rows);
|
||||
});
|
||||
} catch (err) {
|
||||
logWarn(`Restore: could not keep the active sessions (${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreFromZip({ storage, database }: BackupDeps, zipPath: string): Promise<RestoreResult> {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
let reinitFailed: unknown;
|
||||
@@ -424,6 +466,7 @@ export async function restoreFromZip({ storage, database }: BackupDeps, zipPath:
|
||||
}
|
||||
|
||||
await database.keepCopyBeforeRestore();
|
||||
const liveSessions = await readSessionsToCarry();
|
||||
|
||||
try {
|
||||
// Closes the connection, swaps the database in and reopens it, migrating
|
||||
@@ -465,6 +508,7 @@ export async function restoreFromZip({ storage, database }: BackupDeps, zipPath:
|
||||
// nothing to read, and the restore is already reported as "restart
|
||||
// required" below — rehydrating into a stale/guessed config would be worse.
|
||||
await storage.reloadConfig();
|
||||
await restoreCarriedSessions(liveSessions);
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
|
||||
@@ -146,4 +146,61 @@ describe('UserSessionsRepository', () => {
|
||||
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
expect(row('s1')).toBeUndefined();
|
||||
});
|
||||
|
||||
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);
|
||||
testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(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.
|
||||
testDb.prepare("DELETE FROM user_sessions WHERE id IN ('kept', 'elsewhere')").run();
|
||||
testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'present'").run(EARLIER);
|
||||
testDb.prepare("UPDATE users SET email = 'someone-else@example.test' WHERE id = ?").run(renamed.id);
|
||||
t.clear();
|
||||
|
||||
expect(await sessions.restoreCarried(carried)).toBe(2);
|
||||
expect(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(row('present')).toEqual(expect.objectContaining({ revoked_at: EARLIER }));
|
||||
expect(row('elsewhere')).toBeUndefined();
|
||||
});
|
||||
|
||||
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);
|
||||
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
t.clear();
|
||||
|
||||
expect(await sessions.restoreCarried(carried)).toBe(0);
|
||||
expect(row('orphan')).toBeUndefined();
|
||||
expect(await sessions.restoreCarried([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* The hourly demo reset keeps everybody signed in.
|
||||
*
|
||||
* The reset copies the baseline over the live database file. That baseline
|
||||
* was saved at first seed, before anybody logged in, so it holds no
|
||||
* `user_sessions` rows, and a session token whose row is gone is refused.
|
||||
* Without carrying the active rows across the swap, every demo visitor and
|
||||
* the demo admin would get a 401 on the hour.
|
||||
*
|
||||
* The connection lifecycle is stubbed the way demo-reset-path.test.ts stubs
|
||||
* it, and the stubbed `reinitialize()` stands in for the file swap by
|
||||
* emptying `user_sessions`, which is exactly what the baseline brings back.
|
||||
* The reads and writes around it run for real against the snapshot schema.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
|
||||
const LIVE_DB = path.join(path.sep, 'srv', 'trek', 'travel.db');
|
||||
|
||||
const { handleStub, swap, databaseModule } = vi.hoisted(() => {
|
||||
const handleStub = { name: '' };
|
||||
const swap: { run: () => void } = { run: () => undefined };
|
||||
return {
|
||||
handleStub,
|
||||
swap,
|
||||
databaseModule: {
|
||||
getRawConnection: () => handleStub,
|
||||
closeDb: vi.fn(),
|
||||
reinitialize: vi.fn(async () => {
|
||||
swap.run();
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => databaseModule);
|
||||
|
||||
const BASELINE = path.resolve(__dirname, '..', '..', '..', 'data', 'travel-baseline.db');
|
||||
|
||||
import { resetDemoUser } from '../../../src/demo/demo-reset';
|
||||
import { SessionsService } from '../../../src/nest/sessions/sessions.service';
|
||||
import { verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { UserSessions } from '../../../src/db/entities/UserSessions.entity';
|
||||
|
||||
describe('demo reset and the signed-in sessions', () => {
|
||||
let ormDb: Database.Database;
|
||||
let t: TestOrm;
|
||||
|
||||
beforeEach(async () => {
|
||||
handleStub.name = LIVE_DB;
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
ormDb = createSnapshotTestDb();
|
||||
t = await createTestOrm(ormDb);
|
||||
swap.run = () => {
|
||||
ormDb.exec('DELETE FROM user_sessions');
|
||||
};
|
||||
vi.spyOn(fs, 'copyFileSync').mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(((p: fs.PathLike) => String(p) === BASELINE) as typeof fs.existsSync);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await t.close();
|
||||
ormDb.close();
|
||||
});
|
||||
|
||||
const verify = (token: string) => verifyJwtAndLoadUser(token, t.repo(Users), t.repo(UserSessions));
|
||||
|
||||
it('DEMORESET-SESS-001: a session signed in before the reset still authenticates after it', async () => {
|
||||
const { user } = createUser(ormDb, { email: 'demo-visitor@example.test' });
|
||||
const sessions = new SessionsService(t.repo(UserSessions));
|
||||
const token = await sessions.issue({ id: user.id, pv: 0 }, undefined, { userAgent: 'Visitor' });
|
||||
expect((await verify(token))?.id).toBe(user.id);
|
||||
|
||||
await withRequestContext(t.orm, () => resetDemoUser());
|
||||
t.clear();
|
||||
|
||||
expect(databaseModule.reinitialize).toHaveBeenCalled();
|
||||
expect((await verify(token))?.id).toBe(user.id);
|
||||
expect(ormDb.prepare('SELECT user_id, user_agent, revoked_at FROM user_sessions').all()).toEqual([
|
||||
{ user_id: user.id, user_agent: 'Visitor', revoked_at: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('DEMORESET-SESS-002: a session that had already ended stays ended', async () => {
|
||||
const { user } = createUser(ormDb, { email: 'demo-ended@example.test' });
|
||||
const sessions = new SessionsService(t.repo(UserSessions));
|
||||
const token = await sessions.issue({ id: user.id, pv: 0 });
|
||||
await sessions.revokeAll(user.id);
|
||||
|
||||
await withRequestContext(t.orm, () => resetDemoUser());
|
||||
t.clear();
|
||||
|
||||
expect(await verify(token)).toBeNull();
|
||||
expect(ormDb.prepare('SELECT COUNT(*) AS n FROM user_sessions').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('DEMORESET-SESS-003: a session whose account the baseline does not hold is not brought back', async () => {
|
||||
const { user } = createUser(ormDb, { email: 'registered-after-seed@example.test' });
|
||||
const sessions = new SessionsService(t.repo(UserSessions));
|
||||
const token = await sessions.issue({ id: user.id, pv: 0 });
|
||||
swap.run = () => {
|
||||
ormDb.exec('DELETE FROM user_sessions');
|
||||
ormDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
};
|
||||
|
||||
await withRequestContext(t.orm, () => resetDemoUser());
|
||||
t.clear();
|
||||
|
||||
expect(await verify(token)).toBeNull();
|
||||
expect(ormDb.prepare('SELECT COUNT(*) AS n FROM user_sessions').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1385,6 +1385,63 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
|
||||
expect(dbMock.closeDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-045i: the sessions signed in before the restore are put back after the reopen', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
const callOrder: string[] = [];
|
||||
const carried = [{ id: 'sess-1', user_id: 1, email: 'admin@example.test', created_at: 'a', last_seen_at: 'b', expires_at: 'c', user_agent: null }];
|
||||
const sessionsRepo = {
|
||||
listActiveToCarry: vi.fn(async () => { callOrder.push('read'); return carried; }),
|
||||
restoreCarried: vi.fn(async () => { callOrder.push('restore'); return 1; }),
|
||||
};
|
||||
const { RequestContext } = await import('@mikro-orm/core');
|
||||
const em = vi.spyOn(RequestContext, 'getEntityManager').mockReturnValue({ getRepository: () => sessionsRepo } as never);
|
||||
const fork = vi.spyOn(RequestContext, 'create').mockImplementation(((_em: unknown, next: () => unknown) => next()) as never);
|
||||
onTestFinished(() => { em.mockRestore(); fork.mockRestore(); });
|
||||
dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); });
|
||||
dbMock.reinitialize.mockImplementation(() => { callOrder.push('reinitialize'); });
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
fsMock.existsSync.mockImplementation((p: string) => !String(p).includes('uploads') && !String(p).endsWith('.encryption_key'));
|
||||
|
||||
const result = await restoreFromZip(stubStorage(), '/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(callOrder).toEqual(['read', 'closeDb', 'reinitialize', 'restore']);
|
||||
expect(sessionsRepo.restoreCarried).toHaveBeenCalledWith(carried);
|
||||
// Put back in a fresh fork, not through the one that read the replaced file.
|
||||
expect(fork).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('BACKUP-045j: sessions that cannot be read or put back do not fail the restore', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
const sessionsRepo = {
|
||||
listActiveToCarry: vi.fn().mockRejectedValueOnce(new Error('no such table: user_sessions')).mockResolvedValueOnce([{ id: 's' }]),
|
||||
restoreCarried: vi.fn().mockRejectedValue(new Error('database is locked')),
|
||||
};
|
||||
const { RequestContext } = await import('@mikro-orm/core');
|
||||
const em = vi.spyOn(RequestContext, 'getEntityManager').mockReturnValue({ getRepository: () => sessionsRepo } as never);
|
||||
const fork = vi.spyOn(RequestContext, 'create').mockImplementation(((_em: unknown, next: () => unknown) => next()) as never);
|
||||
onTestFinished(() => { em.mockRestore(); fork.mockRestore(); });
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
fsMock.existsSync.mockImplementation((p: string) => !String(p).includes('uploads') && !String(p).endsWith('.encryption_key'));
|
||||
|
||||
const unreadable = await restoreFromZip(stubStorage(), '/data/tmp/upload.zip');
|
||||
expect(unreadable).toEqual({ success: true });
|
||||
expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('could not read the active sessions (no such table: user_sessions)'));
|
||||
expect(sessionsRepo.restoreCarried).not.toHaveBeenCalled();
|
||||
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
const unwritable = await restoreFromZip(stubStorage(), '/data/tmp/upload.zip');
|
||||
expect(unwritable).toEqual({ success: true });
|
||||
expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('could not keep the active sessions (database is locked)'));
|
||||
});
|
||||
|
||||
it('BACKUP-045c — reinitialize is called even when copyFileSync throws', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
@@ -46,6 +46,8 @@ Before restoring, TREK runs integrity checks on the uploaded database:
|
||||
|
||||
The database being replaced is kept as `pre-restore-<time>.db` next to it (in `data/` on a standard install), so a restore of the wrong archive can be undone by hand. Delete these copies once you no longer need them.
|
||||
|
||||
The sessions signed in when the restore starts stay signed in, yours included: they are carried into the restored database for every account it holds under the same id and email. An account whose password differs in the backup still has to sign in again, and a session for an account the backup does not have ends with the restore.
|
||||
|
||||
Either way, TREK asks first in a **Restore Backup?** dialog that names the file, warns that all current data (trips, places, users, uploads) will be permanently replaced, and suggests creating a backup of the current state first. **Yes, restore** starts it; when it is done the page reloads.
|
||||
|
||||
> **Warning:** Restoring replaces all current data. Back up your current state first if you want to keep it.
|
||||
|
||||
@@ -46,6 +46,8 @@ The seeder defaults the admin address to `admin@trek.app` while the reset defaul
|
||||
|
||||
The instance-wide Maps and Unsplash keys stored in `app_settings` are carried across either way, so map and photo search keep working after a reset.
|
||||
|
||||
Everybody signed in stays signed in. The baseline holds no sign-in sessions of its own, so the sessions that are active when the reset runs are carried across as well, for every account the baseline still holds under the same email. A visitor or admin mid-session is not sent back to the login screen on the hour.
|
||||
|
||||
## Saving a baseline
|
||||
|
||||
The baseline is the snapshot the hourly reset restores to. The admin can update it at any time:
|
||||
|
||||
@@ -54,6 +54,8 @@ A session ends when:
|
||||
|
||||
Sessions issued before this was introduced carry no session id. They keep working until they expire (or until the password changes, which ends them through the password version), are not listed, and are not ended by **revoke-others**. Once such a session passes half its lifetime, the sliding renewal replaces it with a tracked one. Expired and ended session rows are removed by a nightly job.
|
||||
|
||||
A backup restore and the hourly demo reset replace the whole database file. Both carry the sessions that are active at that moment into the new file, for every account it holds under the same id and email, so neither signs anybody out by itself.
|
||||
|
||||
> **Deprecated:** the login, registration, demo-login, MFA and passkey sign-in responses still carry the session JWT as `token` in the JSON body, for API clients that read it. The web app does not use it; the session is the httpOnly cookie the same response sets. The field will be removed in a future major version.
|
||||
|
||||
## Password Policy
|
||||
|
||||
Reference in New Issue
Block a user