feat(api): implement secure portfolio history, daily FX and atomic backups
This commit is contained in:
1 parent
67220e38ed
commit
99174a3da5
25 files changed
+1750
-28
No files matched your search
@@ -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"es=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;
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user