Files
WorthPath/apps/api/test/security-backup.test.ts
T

276 lines
10 KiB
TypeScript

import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { hash } from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('credentials rotate sessions; deleting paired and empty histories preserves ZIP recovery', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return {
status: res.status,
data: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function session(userId: string) {
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId,
expiresAt: new Date(Date.now() + 3600000),
},
});
return 'wp_session=' + token;
}
async function user() {
const username = 'wp_fix_' + randomUUID(),
password = randomBytes(18).toString('hex');
const user = await db.user.create({
data: { username, passwordHash: await hash(password, 12) },
});
ids.push(user.id);
const cookie = await session(user.id);
return { id: user.id, username, password, cookie };
}
try {
const a = await user(),
b = await user();
const other = { cookie: await session(a.id) };
assert.equal(
(
await call('/auth/credentials', a.cookie, 'PATCH', {
username: b.username,
currentPassword: 'wrong-password',
})
).status,
403,
);
assert.equal(
(
await call('/auth/credentials', a.cookie, 'PATCH', {
username: b.username,
currentPassword: a.password,
})
).status,
409,
);
assert.equal((await call('/auth/me', other.cookie)).status, 200);
const username = 'wp_changed_' + randomUUID(),
password = randomBytes(18).toString('hex');
const changed = await call('/auth/credentials', a.cookie, 'PATCH', {
username,
currentPassword: a.password,
newPassword: password,
});
assert.equal(changed.status, 200);
a.cookie = changed.cookie;
assert.equal((await call('/auth/me', other.cookie)).status, 401);
assert.equal((await call('/auth/me', a.cookie)).data.username, username);
assert.equal(
(await call('/auth/login', '', 'POST', { username: a.username, password: a.password }))
.status,
401,
);
assert.equal(
(await call('/auth/login', '', 'POST', { username, password: a.password })).status,
401,
);
assert.equal((await call('/auth/login', '', 'POST', { username, password })).status, 201);
await db.session.updateMany({
where: { userId: a.id },
data: { revealUntil: new Date(Date.now() + 300000) },
});
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
async function create(name: string, amount: string) {
const result = await call('/positions', a.cookie, 'POST', {
name,
amount,
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
date: '2026-09-01T00:00',
});
assert.equal(result.status, 201);
return result.data.id;
}
const sourceId = await create('source', '100'),
targetId = await create('target', '0'),
emptyId = await create('empty', '1');
const rev = (id: string) =>
db.revision.findFirstOrThrow({ where: { positionId: id }, orderBy: { sequence: 'asc' } });
const remove = (id: string, revisionId: string, cookie = a.cookie) =>
call('/positions/' + id + '/revisions/' + revisionId, cookie, 'DELETE');
assert.equal((await remove(emptyId, (await rev(emptyId)).id)).status, 200);
const movement = await call('/transfers', a.cookie, 'POST', {
sourceId,
targetId,
amount: '10',
received: '10',
fee: '0',
date: '2026-09-02T00:00',
});
assert.equal(movement.status, 201);
const pair = await db.transfer.findUniqueOrThrow({ where: { id: movement.data.id } });
assert.equal((await remove(sourceId, pair.sourceRevisionId, b.cookie)).status, 404);
assert.equal((await remove(sourceId, (await rev(sourceId)).id)).status, 200);
assert.equal((await remove(targetId, (await rev(targetId)).id)).status, 200);
assert.equal(
(await call('/transfers/revision/' + pair.sourceRevisionId, a.cookie)).data.id,
pair.id,
);
const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(download.status, 200);
assert.equal(download.headers.get('content-type')?.includes('application/zip'), true);
const bytes = Buffer.from(await download.arrayBuffer());
const backup: any = await readBackupZip(bytes);
assert.equal(backup.positions.find((p: any) => p.id === emptyId).revisions.length, 0);
assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10');
const beforeRejected = await db.position.count({ where: { userId: b.id } });
for (const version of [1, 2, 3, 9]) {
const unsupported = new FormData();
unsupported.set(
'file',
new Blob([JSON.stringify({ ...backup, version })], { type: 'application/json' }),
'backup.json',
);
const rejected = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin },
body: unsupported,
});
assert.equal(rejected.status, 400);
}
for (const path of ['/backup/import', '/backup/preview']) {
const removed = await fetch(base + path, {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed: true, backup }),
});
assert.equal(removed.status, 404);
}
assert.equal(await db.position.count({ where: { userId: b.id } }), beforeRejected);
const form = new FormData();
form.append('file', new Blob([bytes]), 'backup.zip');
const upload = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin },
body: form,
});
assert.equal(upload.status, 201);
const preview: any = await upload.json();
assert.equal(
(
await call('/backup/import-file', a.cookie, 'POST', {
token: preview.token,
confirmed: true,
})
).status,
400,
);
assert.equal(
(
await call('/backup/import-file', b.cookie, 'POST', {
token: preview.token,
confirmed: true,
})
).status,
201,
);
const restored = (await call('/positions', b.cookie)).data;
assert.equal(restored.find((p: any) => p.name === 'source').amount, '-10');
assert.equal(restored.find((p: any) => p.name === 'target').amount, '10');
assert.equal(restored.find((p: any) => p.name === 'empty').amount, '0');
await db.position.update({ where: { id: targetId }, data: { hidden: true } });
await call('/auth/lock', a.cookie, 'POST');
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 1);
await db.session.updateMany({
where: { userId: a.id },
data: { revealUntil: new Date(Date.now() + 300000) },
});
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 200);
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 0);
assert.equal(
await db.revision.count({
where: { id: { in: [pair.sourceRevisionId, pair.targetRevisionId] } },
}),
0,
);
assert.equal((await call('/positions/' + sourceId, a.cookie)).data.amount, '0');
assert.equal((await call('/positions/' + targetId, a.cookie)).data.amount, '0');
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
const minute = (delta = 0) =>
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
const plan = await call('/schedules', a.cookie, 'POST', {
name: 'Empty account expense',
operation: 'expense',
sourceId,
targetId: null,
amount: '2',
received: '0',
nextAt: minute(-60000),
intervalDays: 0,
notes: '',
});
assert.equal(plan.status, 201);
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
const today = minute().slice(0, 10);
const calendar = (await call('/calendar?month=' + today.slice(0, 7), a.cookie)).data;
assert.equal(
calendar.items.find((v: any) => v.date === minute(-60000).slice(0, 10)).expense,
'2.00',
);
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
sourceId,
targetId,
amount: '3',
received: '3',
fee: '0',
date: minute(),
})
).status,
201,
);
const emptyAccountBackup = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(emptyAccountBackup.status, 200);
const emptyAccountData: any = await readBackupZip(
Buffer.from(await emptyAccountBackup.arrayBuffer()),
);
assert.equal(
emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount,
'-5',
);
assert.equal(
(await call('/backup/preview-fixture', b.cookie, 'POST', emptyAccountData)).status,
409,
);
const fresh = await user();
assert.equal(
(await call('/backup/preview-fixture', fresh.cookie, 'POST', emptyAccountData)).status,
201,
);
} finally {
await db.user.deleteMany({ where: { id: { in: ids } } });
await db.$disconnect();
}
});