276 lines
10 KiB
TypeScript
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();
|
|
}
|
|
});
|