feat(api): implement secure portfolio history, daily FX and atomic backups

This commit is contained in:
陈煜 committed 2026-10-01 16:21:16 +08:00
1 parent 67220e38ed
commit 99174a3da5
25 files changed
+1750 -28

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { history, overview, totals, type Holding, type Rate } from '../src/calculation';
import { positionInput, date, amount } from '../src/validation';
import { validateBackup } from '../src/backup';
import { RatesService } from '../src/rates';
import { Database } from '../src/database';
const rev = (amount: string, day: string) => ({
id: randomUUID(),
amount,
effectiveDate: new Date(day),
notes: '',
reason: 'balance',
});
const p = (side = 'asset', currency = 'CNY'): Holding => ({
id: randomUUID(),
name: 'test',
kind: 'account',
side,
currency,
revisions: [rev('100.1', '2026-09-01')],
});
const rate = (value: string, day: string): Rate => ({
currency: 'USD',
baseCurrency: 'CNY',
date: new Date(day),
rate: value,
source: 'manual',
});
test('decimal totals and liability sign', () => {
const a = p(),
b = p('liability');
b.revisions[0].amount = '0.2';
assert.equal(totals([a, b], [], 'CNY', '2026-09-01').net, '99.90');
});
test('missing FX explicitly incomplete', () => {
const v = totals([p('asset', 'USD')], [], 'CNY', '2026-09-01');
assert.equal(v.complete, false);
assert.deepEqual(v.missing, ['USD']);
assert.equal(v.items[0].converted, null);
});
test('correction recalculates later delta', () => {
const a = p();
a.revisions = [rev('90.1', '2026-09-01'), rev('110.1', '2026-09-02')];
assert.equal(history(a)[1].delta, '20');
assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '110.10');
});
test('FX and actual changes separated', () => {
const a = p('asset', 'USD');
a.revisions = [rev('100', '2026-09-01'), rev('110', '2026-09-02')];
const o = overview([a], [rate('7', '2026-09-01'), rate('8', '2026-09-02')], 'CNY', '2026-09-02');
assert.equal(o.trend[1].fxChange, '100.00');
assert.equal(o.trend[1].balanceChange, '80.00');
assert.equal(o.net, '880.00');
});
test('large monetary strings stay exact', () => {
const a = p();
a.revisions = [rev('9999999999999999.98765432', '2026-09-01')];
assert.equal(history(a)[0].after, '9999999999999999.98765432');
assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '9999999999999999.99');
});
test('reject invalid dates, negative values and credit card assets', () => {
assert.equal(date.safeParse('2026-02-30').success, false);
assert.equal(amount.safeParse('-1').success, false);
assert.equal(
positionInput.safeParse({
name: 'Card',
kind: 'account',
side: 'asset',
category: 'credit_card',
currency: 'CNY',
amount: '1',
date: '2026-09-01',
}).success,
false,
);
});
test('backup rejects auth data and broken relations', () => {
const b = {
format: 'worthpath',
version: 1,
exportedAt: new Date().toISOString(),
baseCurrency: 'CNY',
currencies: ['CNY'],
positions: [],
rates: [],
links: [],
};
assert.doesNotThrow(() => validateBackup(b));
assert.throws(() => validateBackup({ ...b, passwordHash: 'forbidden' }));
assert.throws(() =>
validateBackup({ ...b, links: [{ sourceId: randomUUID(), targetId: randomUUID() }] }),
);
});
test('public FX uses a fixed request, preserves decimal tokens, manual rates and failure fallback', async () => {
const writes: any[] = [];
let manual = false;
const db = {
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
position: { findMany: async () => [{ currency: 'USD' }] },
$transaction: async (fn: any) =>
fn({
exchangeRate: {
findUnique: async () => (manual ? { source: 'manual' } : null),
upsert: async (v: any) => writes.push(v.create),
},
}),
} as unknown as Database;
const fx = new RatesService(db),
original = globalThis.fetch;
try {
globalThis.fetch = async (url) => {
assert.equal(
String(url),
'https://api.frankfurter.dev/v2/rates?base=USD&quotes=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD',
);
return new Response(
'[{"base":"USD","quote":"CNY","date":"2026-09-01","rate":7.987654321098}]',
);
};
await fx.refresh('test-owner');
assert.equal(writes[0].rate, '7.987654321098');
manual = true;
await fx.refresh('test-owner');
assert.equal(writes.length, 1);
globalThis.fetch = async () => {
throw Error('offline');
};
await assert.rejects(() => fx.refresh('test-owner'), /原币金额和已有汇率已保留/);
assert.equal(writes.length, 1);
assert.equal(fx.status('test-owner').state, 'error');
} finally {
globalThis.fetch = original;
}
});
+319
View File
@@ -0,0 +1,319 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { today } from '../src/validation';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api',
origin = process.env.WEB_ORIGIN!;
test('real MySQL: authentication, isolation, history, backup and atomic failures', async () => {
const db = new PrismaClient(),
created: { id: string; username: string }[] = [];
async function call(path: string, method = 'GET', body?: unknown, cookie = '') {
const res = await fetch(base + path, {
method,
headers: {
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(cookie ? { Cookie: cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return {
status: res.status,
data: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function account() {
const username = 'wp_test_' + randomUUID().slice(0, 12),
password = randomBytes(18).toString('hex');
const r = await call('/auth/register', 'POST', { username, password });
assert.equal(r.status, 201);
assert.ok(r.cookie);
const u = await db.user.findUniqueOrThrow({ where: { username } });
created.push({ id: u.id, username });
assert.notEqual(u.passwordHash, password);
assert.equal('passwordHash' in r.data, false);
return { ...r, password, username };
}
try {
assert.equal((await call('/positions')).status, 401);
const a = await account(),
b = await account();
assert.equal(
(
await fetch(base + '/settings', {
method: 'PATCH',
headers: {
Cookie: a.cookie,
'Content-Type': 'application/json',
Origin: 'https://untrusted.invalid',
},
body: JSON.stringify({ baseCurrency: 'USD' }),
})
).status,
403,
);
const make = async (
kind: string,
side: string,
currency: string,
value: string,
category = 'other',
) => {
const r = await call(
'/positions',
'POST',
{
name: kind + randomUUID().slice(0, 5),
kind,
side,
currency,
amount: value,
category,
date: '2026-09-01',
notes: '',
},
a.cookie,
);
assert.equal(r.status, 201);
return r.data.id as string;
};
const bank = await make('account', 'asset', 'CNY', '100.10'),
asset = await make('asset', 'asset', 'USD', '100'),
debt = await make('debt', 'liability', 'CNY', '200'),
card = await make('account', 'liability', 'CNY', '50', 'credit_card');
assert.equal((await call('/positions/' + bank, 'GET', undefined, b.cookie)).status, 404);
assert.equal(
(
await call(
'/positions/' + bank,
'PATCH',
{ name: 'hack', category: 'other', notes: '', archived: false },
b.cookie,
)
).status,
404,
);
assert.equal(
(
await call(
'/positions/' + bank + '/revisions',
'POST',
{ amount: '1', date: '2026-09-02' },
b.cookie,
)
).status,
404,
);
assert.deepEqual((await call('/positions', 'GET', undefined, b.cookie)).data, []);
assert.equal(
(
await call(
'/positions',
'POST',
{
name: 'hack',
kind: 'asset',
side: 'asset',
currency: 'CNY',
category: 'other',
amount: '1',
date: '2026-09-01',
userId: created[0].id,
},
b.cookie,
)
).status,
400,
);
assert.equal(
(
await call(
'/rates',
'PUT',
{ currency: 'USD', baseCurrency: 'CNY', rate: '7', date: '2026-09-01' },
a.cookie,
)
).status,
200,
);
assert.equal(
(
await call(
'/rates',
'PUT',
{ currency: 'USD', baseCurrency: 'CNY', rate: '7', date: today() },
a.cookie,
)
).status,
200,
);
let o = (await call('/overview', 'GET', undefined, a.cookie)).data;
assert.equal(o.complete, true);
assert.equal(o.net, '550.10');
assert.equal(
(await call('/positions/' + debt + '/links', 'PUT', { targetIds: [asset, bank] }, a.cookie))
.status,
200,
);
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '550.10');
assert.equal(
(
await call(
'/positions/' + bank + '/revisions',
'POST',
{ amount: '110.10', date: '2026-09-02' },
a.cookie,
)
).status,
201,
);
const d = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data;
assert.equal(
(
await call(
'/positions/' + bank + '/revisions/' + d.history[0].id,
'PUT',
{ amount: '90.10', date: '2026-09-01' },
b.cookie,
)
).status,
404,
);
assert.equal(
(
await call(
'/positions/' + bank + '/revisions/' + d.history[0].id,
'PUT',
{ amount: '90.10', date: '2026-09-01' },
a.cookie,
)
).status,
200,
);
assert.equal(
(await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history[1].delta,
'20',
);
assert.equal(
(
await call(
'/positions/' + card + '/revisions',
'POST',
{ amount: '40', date: '2026-09-02', reason: 'repayment' },
a.cookie,
)
).status,
201,
);
assert.equal(
(
await call(
'/positions/' + card,
'PATCH',
{ name: 'card', category: 'credit_card', notes: '', archived: true },
a.cookie,
)
).status,
200,
);
assert.equal(
(
await call(
'/positions/' + card + '/revisions',
'POST',
{ amount: '0', date: '2026-09-03' },
a.cookie,
)
).status,
409,
);
o = (await call('/overview', 'GET', undefined, a.cookie)).data;
assert.equal(o.net, '570.10');
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
assert.equal(backup.positions.length, 4);
assert.equal(backup.links.length, 2);
assert.doesNotMatch(JSON.stringify(backup), /password|token|userId|session/i);
assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409);
assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: false, backup }, b.cookie)).status,
400,
);
assert.equal(
(
await call(
'/backup/import',
'POST',
{
confirmed: true,
backup: { ...backup, links: [{ sourceId: randomUUID(), targetId: asset }] },
},
b.cookie,
)
).status,
400,
);
assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 0);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, o.net);
const restored = (await call('/positions', 'GET', undefined, b.cookie)).data;
assert.equal(restored.length, 4);
assert.ok(
restored.every(
(p: { id: string }) => !backup.positions.some((q: { id: string }) => q.id === p.id),
),
);
assert.equal(restored.find((p: { kind: string }) => p.kind === 'debt').outgoing.length, 2);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
409,
);
assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 4);
const restoredBank = restored.find(
(p: { kind: string; side: string; currency: string }) =>
p.kind === 'account' && p.side === 'asset' && p.currency === 'CNY',
);
await call(
'/positions/' + restoredBank.id,
'PATCH',
{
name: 'Edited imported project',
category: restoredBank.category,
notes: 'Edited after import',
archived: false,
},
b.cookie,
);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
409,
);
const c = await account();
const racing = await Promise.all([
call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie),
call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie),
]);
assert.deepEqual(racing.map((r) => r.status).sort(), [201, 409]);
assert.equal((await call('/positions', 'GET', undefined, c.cookie)).data.length, 4);
assert.equal((await call('/auth/logout', 'POST', undefined, a.cookie)).status, 201);
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401);
assert.equal((await call('/backup', 'GET', undefined, a.cookie)).status, 401);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, a.cookie)).status,
401,
);
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
assert.equal(login.status, 201);
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 4);
} finally {
for (const u of created)
await db.user.deleteMany({ where: { id: u.id, username: u.username } });
await db.$disconnect();
}
});