255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
import { fixtureFetch } from './backup-fixture';
|
|
import 'dotenv/config';
|
|
import { test } from 'node:test';
|
|
import { request } from 'node:http';
|
|
import assert from 'node:assert/strict';
|
|
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { hash } from 'bcryptjs';
|
|
import { readBackupZip } from '../src/zip';
|
|
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
|
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
|
const password = 'Fixture-session-only-42!';
|
|
const db = new PrismaClient();
|
|
async function fixture() {
|
|
const u = await db.user.create({
|
|
data: {
|
|
username: 'wp_settings_' + randomUUID(),
|
|
passwordHash: await hash(password, 4),
|
|
idleMinutes: 0,
|
|
},
|
|
});
|
|
const token = randomBytes(32).toString('hex');
|
|
const id = createHash('sha256').update(token).digest('hex');
|
|
await db.session.create({
|
|
data: { id, userId: u.id, expiresAt: new Date(Date.now() + 86400000) },
|
|
});
|
|
return { ...u, sessionId: id, cookie: 'wp_session=' + token };
|
|
}
|
|
// Give this fixture suite its own loopback source address so independent auth
|
|
// scenarios do not consume the existing suite's per-IP production rate limit.
|
|
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
|
if (path.endsWith('-fixture')) {
|
|
const response = await fixtureFetch(base + path, {
|
|
method,
|
|
headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: response.status, data: await response.json(), cookie: null };
|
|
}
|
|
const data = body === undefined ? undefined : JSON.stringify(body);
|
|
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
|
const req = request(
|
|
new URL(base + path),
|
|
{
|
|
method,
|
|
localAddress: '127.0.0.2',
|
|
headers: {
|
|
Cookie: cookie,
|
|
Origin: origin,
|
|
...(data === undefined
|
|
? {}
|
|
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }),
|
|
},
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('error', reject);
|
|
res.on('end', () => {
|
|
try {
|
|
resolve({
|
|
status: res.statusCode!,
|
|
data: JSON.parse(Buffer.concat(chunks).toString()),
|
|
cookie: res.headers['set-cookie']?.[0] ?? null,
|
|
});
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
req.on('error', reject);
|
|
req.end(data);
|
|
});
|
|
}
|
|
test('session duration boundaries, privacy isolation and card settings survive backups', async () => {
|
|
const a = await fixture(),
|
|
b = await fixture(),
|
|
c = await fixture();
|
|
try {
|
|
const initial = await call('/auth/me', a.cookie);
|
|
assert.equal(initial.data.sessionHours, 168);
|
|
assert.equal(initial.data.requireHiddenPassword, true);
|
|
assert.equal(initial.data.overviewCards.length, 7);
|
|
for (const sessionHours of [0, 721, 1.5, '24'])
|
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { sessionHours })).status, 400);
|
|
for (const overviewCards of [['unknown'], ['net', 'net'], [1]])
|
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards })).status, 400);
|
|
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400);
|
|
assert.equal(
|
|
(await call('/auth/reveal', a.cookie, 'POST', { password: 'Incorrect-password-42' })).status,
|
|
403,
|
|
);
|
|
assert.equal((await call('/auth/reveal', a.cookie, 'POST', { password })).status, 201);
|
|
const secondToken = randomBytes(32).toString('hex');
|
|
const secondId = createHash('sha256').update(secondToken).digest('hex');
|
|
const oldExpiry = new Date(Date.now() + 36000000);
|
|
await db.session.create({
|
|
data: {
|
|
id: secondId,
|
|
userId: a.id,
|
|
expiresAt: oldExpiry,
|
|
revealUntil: new Date(Date.now() + 60000),
|
|
},
|
|
});
|
|
const result = await call('/settings', a.cookie, 'PATCH', {
|
|
sessionHours: 1,
|
|
requireHiddenPassword: false,
|
|
overviewCards: ['net', 'recent'],
|
|
});
|
|
assert.equal(result.status, 200);
|
|
assert.match(result.cookie!, /HttpOnly/);
|
|
assert.match(result.cookie!, /Expires=/);
|
|
const current = await db.session.findUniqueOrThrow({ where: { id: a.sessionId } });
|
|
assert.ok(Math.abs(+current.expiresAt - Date.now() - 3600000) < 5000);
|
|
assert.equal(current.revealUntil, null);
|
|
const other = await db.session.findUniqueOrThrow({ where: { id: secondId } });
|
|
assert.ok(Math.abs(+other.expiresAt - +oldExpiry) < 1000);
|
|
assert.equal(other.revealUntil, null);
|
|
assert.equal((await call('/auth/me', b.cookie)).data.requireHiddenPassword, true);
|
|
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 201);
|
|
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
|
|
await call('/settings', a.cookie, 'PATCH', { requireHiddenPassword: true });
|
|
assert.equal((await call('/auth/me', a.cookie)).data.revealed, false);
|
|
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400);
|
|
await call('/settings', a.cookie, 'PATCH', { sessionHours: 720, requireHiddenPassword: false });
|
|
const login = await call('/auth/login', '', 'POST', { username: a.username, password });
|
|
assert.equal(login.status, 201);
|
|
const freshCookie = login.cookie!.split(';')[0];
|
|
const me = (await call('/auth/me', freshCookie)).data;
|
|
assert.ok(Math.abs(+new Date(me.sessionExpiresAt) - Date.now() - 720 * 3600000) < 5000);
|
|
const zip = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
|
|
assert.equal(zip.status, 200);
|
|
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
|
|
assert.deepEqual(backup.preferences.overviewCards, ['net', 'recent']);
|
|
assert.equal(backup.preferences.sessionHours, 720);
|
|
assert.equal(backup.preferences.requireHiddenPassword, false);
|
|
assert.equal(
|
|
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
|
|
201,
|
|
);
|
|
const imported = (await call('/settings', b.cookie)).data;
|
|
assert.equal(imported.sessionHours, 720);
|
|
assert.equal(imported.requireHiddenPassword, false);
|
|
assert.deepEqual(imported.overviewCards, ['net', 'recent']);
|
|
delete backup.preferences.sessionHours;
|
|
delete backup.preferences.requireHiddenPassword;
|
|
delete backup.preferences.overviewCards;
|
|
assert.equal(
|
|
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
|
|
400,
|
|
);
|
|
const legacy = (await call('/settings', c.cookie)).data;
|
|
assert.equal(legacy.sessionHours, 168);
|
|
assert.equal(legacy.requireHiddenPassword, true);
|
|
assert.equal(legacy.overviewCards.length, 7);
|
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards: [] })).status, 200);
|
|
assert.deepEqual((await call('/auth/me', a.cookie)).data.overviewCards, []);
|
|
await db.session.update({
|
|
where: { id: a.sessionId },
|
|
data: { expiresAt: new Date(Date.now() - 1000) },
|
|
});
|
|
assert.equal((await call('/auth/me', a.cookie)).status, 401);
|
|
} finally {
|
|
await db.user.deleteMany({ where: { id: { in: [a.id, b.id, c.id] } } });
|
|
}
|
|
});
|
|
test('plan edits validate accounts, preserve history and reject hidden or foreign plans', async () => {
|
|
const a = await fixture(),
|
|
b = await fixture();
|
|
try {
|
|
const position = async (name: string, hidden = false) => {
|
|
const r = await call('/positions', a.cookie, 'POST', {
|
|
name,
|
|
kind: 'account',
|
|
side: 'asset',
|
|
category: 'cash',
|
|
currency: 'CNY',
|
|
amount: '100',
|
|
date: '2026-09-01T10:00',
|
|
hidden,
|
|
});
|
|
assert.equal(r.status, 201);
|
|
return r.data.id;
|
|
};
|
|
const source = await position('Source'),
|
|
target = await position('Target'),
|
|
hidden = await position('Hidden', true);
|
|
const payload = {
|
|
name: 'Original',
|
|
operation: 'expense',
|
|
sourceId: source,
|
|
targetId: null,
|
|
amount: '10',
|
|
received: '0',
|
|
nextAt: '2026-10-01T10:00',
|
|
intervalDays: 30,
|
|
notes: 'initial',
|
|
};
|
|
const created = await call('/schedules', a.cookie, 'POST', payload);
|
|
assert.equal(created.status, 201);
|
|
const id = created.data.id;
|
|
const run = await call('/schedules/run', a.cookie, 'POST', {});
|
|
assert.equal(run.data.executed, 1);
|
|
const records = await db.revision.findMany({
|
|
where: { positionId: source },
|
|
orderBy: { sequence: 'asc' },
|
|
});
|
|
const updated = {
|
|
...payload,
|
|
name: 'Edited transfer',
|
|
operation: 'transfer',
|
|
targetId: target,
|
|
amount: '20.00000001',
|
|
received: '20.00000001',
|
|
nextAt: '2027-01-01T09:00',
|
|
intervalDays: 7,
|
|
notes: 'new memo',
|
|
};
|
|
assert.equal((await call('/schedules/' + id, b.cookie, 'PUT', updated)).status, 404);
|
|
assert.equal(
|
|
(await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, targetId: hidden })).status,
|
|
400,
|
|
);
|
|
assert.equal(
|
|
(await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, received: '21' })).status,
|
|
400,
|
|
);
|
|
assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 200);
|
|
const plans = (await call('/schedules', a.cookie)).data;
|
|
const plan = plans.find((p: any) => p.id === id);
|
|
assert.equal(plan.name, updated.name);
|
|
assert.equal(plan.nextAt, updated.nextAt);
|
|
assert.equal(plan.received, updated.received);
|
|
assert.equal(plan.notes, updated.notes);
|
|
assert.equal(plan.enabled, true);
|
|
assert.deepEqual(
|
|
await db.revision.findMany({ where: { positionId: source }, orderBy: { sequence: 'asc' } }),
|
|
records,
|
|
);
|
|
const done = await call('/schedules', a.cookie, 'POST', {
|
|
...payload,
|
|
name: 'Once',
|
|
intervalDays: 0,
|
|
});
|
|
await call('/schedules/run', a.cookie, 'POST', {});
|
|
assert.equal((await call('/schedules/' + done.data.id, a.cookie, 'PUT', updated)).status, 400);
|
|
await db.position.update({ where: { id: source }, data: { hidden: true } });
|
|
assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 404);
|
|
} finally {
|
|
await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } });
|
|
await db.$disconnect();
|
|
}
|
|
});
|