feat: add privacy controls, minute history and ZIP backups
This commit is contained in:
1 parent
ba0d5201c9
commit
2a650853ee
21 files changed
+1729
-226
No files matched your search
@@ -2,7 +2,7 @@ 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 { positionInput, date, amount, businessDate, toBusinessDate } from '../src/validation';
|
||||
import { validateBackup } from '../src/backup';
|
||||
import { RatesService } from '../src/rates';
|
||||
import { Database } from '../src/database';
|
||||
@@ -34,6 +34,15 @@ test('decimal totals and liability sign', () => {
|
||||
b.revisions[0].amount = '0.2';
|
||||
assert.equal(totals([a, b], [], 'CNY', '2026-09-01').net, '99.90');
|
||||
});
|
||||
test('minute history uses Hong Kong day boundaries and rejects invalid local times', () => {
|
||||
const a = p();
|
||||
a.revisions = [{ ...rev('8', '2026-09-01'), effectiveDate: toBusinessDate('2026-09-02T00:01') }];
|
||||
assert.equal(history(a)[0].time, '2026-09-02T00:01');
|
||||
assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '0.00');
|
||||
assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '8.00');
|
||||
assert.equal(businessDate.safeParse('2026-02-30T09:17').success, false);
|
||||
assert.equal(businessDate.safeParse('2026-09-01T25:17').success, false);
|
||||
});
|
||||
test('missing FX explicitly incomplete', () => {
|
||||
const v = totals([p('asset', 'USD')], [], 'CNY', '2026-09-01');
|
||||
assert.equal(v.complete, false);
|
||||
@@ -118,6 +127,8 @@ test('public FX uses a fixed request, preserves decimal tokens, manual rates and
|
||||
position: { findMany: async () => [{ currency: 'USD' }] },
|
||||
$transaction: async (fn: any) =>
|
||||
fn({
|
||||
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
|
||||
position: { findMany: async () => [{ currency: 'USD' }] },
|
||||
exchangeRate: {
|
||||
findUnique: async () => (manual ? { source: 'manual' } : null),
|
||||
upsert: async (v: any) => writes.push(v.create),
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { today } from '../src/validation';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
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 () => {
|
||||
@@ -21,7 +22,9 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
data: await res.json(),
|
||||
data: res.headers.get('content-type')?.includes('application/zip')
|
||||
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
|
||||
: await res.json(),
|
||||
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
|
||||
};
|
||||
}
|
||||
@@ -137,19 +140,22 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
a.cookie,
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/rates',
|
||||
'PUT',
|
||||
{ currency: 'USD', baseCurrency: 'CNY', rate: '7', date: today() },
|
||||
a.cookie,
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
404,
|
||||
);
|
||||
const owner = await db.user.findUniqueOrThrow({ where: { username: a.username } });
|
||||
for (const businessDay of ['2026-09-01', today()]) {
|
||||
const key = {
|
||||
userId: owner.id,
|
||||
currency: 'USD',
|
||||
baseCurrency: 'CNY',
|
||||
date: new Date(businessDay),
|
||||
};
|
||||
await db.exchangeRate.upsert({
|
||||
where: { userId_currency_baseCurrency_date: key },
|
||||
create: { ...key, rate: '7', source: 'manual' },
|
||||
update: { rate: '7', source: 'manual' },
|
||||
});
|
||||
}
|
||||
let o = (await call('/overview', 'GET', undefined, a.cookie)).data;
|
||||
assert.equal(o.complete, true);
|
||||
assert.equal(o.net, '550.10');
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
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('privacy, minute history, backup-gated clear and idle sessions remain user scoped', async () => {
|
||||
const db = new PrismaClient(),
|
||||
names: string[] = [];
|
||||
async function call(path: string, method = 'GET', data?: unknown, cookie = '') {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Origin: process.env.WEB_ORIGIN!,
|
||||
Cookie: cookie,
|
||||
...(data ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
data: res.headers.get('content-type')?.includes('application/zip')
|
||||
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
|
||||
: await res.json(),
|
||||
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
|
||||
};
|
||||
}
|
||||
async function account() {
|
||||
const username = 'wp_privacy_' + randomUUID(),
|
||||
password = randomBytes(18).toString('hex');
|
||||
names.push(username);
|
||||
const r = await call('/auth/register', 'POST', { username, password });
|
||||
assert.equal(r.status, 201);
|
||||
const u = await db.user.findUniqueOrThrow({ where: { username } });
|
||||
return { ...r, username, password, id: u.id };
|
||||
}
|
||||
const sessionId = (cookie: string) =>
|
||||
createHash('sha256').update(cookie.split('=')[1]).digest('hex');
|
||||
try {
|
||||
const a = await account(),
|
||||
b = await account();
|
||||
async function position(cookie: string, hidden: boolean, amount: string) {
|
||||
const r = await call(
|
||||
'/positions',
|
||||
'POST',
|
||||
{
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
name: 'Temporary privacy acceptance',
|
||||
currency: 'CNY',
|
||||
amount,
|
||||
date: '2026-09-01T09:17',
|
||||
hidden,
|
||||
},
|
||||
cookie,
|
||||
);
|
||||
assert.equal(r.status, 201);
|
||||
return r.data.id as string;
|
||||
}
|
||||
const visible = await position(a.cookie, false, '20'),
|
||||
hidden = await position(a.cookie, true, '80');
|
||||
await position(b.cookie, false, '7');
|
||||
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '20.00');
|
||||
assert.equal((await call('/positions', 'GET', undefined, a.cookie)).data.length, 1);
|
||||
assert.equal((await call('/positions/' + hidden, 'GET', undefined, a.cookie)).status, 404);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/positions/' + hidden + '/revisions',
|
||||
'POST',
|
||||
{ amount: '90', date: '2026-09-01T09:18' },
|
||||
a.cookie,
|
||||
)
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/auth/reveal', 'POST', { password: b.password }, a.cookie)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/auth/reveal', 'POST', { password: a.password, userId: b.id }, a.cookie)).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/auth/reveal', 'POST', { password: a.password }, a.cookie)).status,
|
||||
201,
|
||||
);
|
||||
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '100.00');
|
||||
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '7.00');
|
||||
const otherSession = await call('/auth/login', 'POST', {
|
||||
username: a.username,
|
||||
password: a.password,
|
||||
});
|
||||
assert.equal(
|
||||
(await call('/overview', 'GET', undefined, otherSession.cookie)).data.net,
|
||||
'20.00',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/positions/' + hidden + '/revisions',
|
||||
'POST',
|
||||
{ amount: '95', date: '2026-09-01T09:18' },
|
||||
a.cookie,
|
||||
)
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const history = (await call('/positions/' + hidden, 'GET', undefined, a.cookie)).data.history;
|
||||
assert.deepEqual(
|
||||
history.map((h: { time: string }) => h.time),
|
||||
['2026-09-01T09:17', '2026-09-01T09:18'],
|
||||
);
|
||||
assert.equal(history[1].delta, '15');
|
||||
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
|
||||
assert.equal(backup.version, 2);
|
||||
assert.equal(backup.positions.find((p: { id: string }) => p.id === hidden).hidden, true);
|
||||
assert.equal(
|
||||
backup.positions.find((p: { id: string }) => p.id === hidden).revisions[0].date,
|
||||
'2026-09-01T09:17',
|
||||
);
|
||||
const c = await account();
|
||||
const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
|
||||
assert.match(download.headers.get('content-disposition')!, /\.zip/);
|
||||
const bytes = await download.arrayBuffer();
|
||||
async function upload(cookie: string, content: ArrayBuffer | string) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([content]), 'backup.zip');
|
||||
const res = await fetch(base + '/backup/upload', {
|
||||
method: 'POST',
|
||||
headers: { Origin: process.env.WEB_ORIGIN!, Cookie: cookie },
|
||||
body: form,
|
||||
});
|
||||
return { status: res.status, data: await res.json() };
|
||||
}
|
||||
assert.equal((await upload(c.cookie, 'invalid zip')).status, 400);
|
||||
assert.equal(await db.position.count({ where: { userId: c.id } }), 0);
|
||||
const uploaded = await upload(c.cookie, bytes);
|
||||
assert.equal(uploaded.status, 201);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/backup/import-file',
|
||||
'POST',
|
||||
{ confirmed: true, token: uploaded.data.token },
|
||||
b.cookie,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/backup/import-file',
|
||||
'POST',
|
||||
{ confirmed: false, token: uploaded.data.token },
|
||||
c.cookie,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/backup/import-file',
|
||||
'POST',
|
||||
{ confirmed: true, token: uploaded.data.token },
|
||||
c.cookie,
|
||||
)
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/backup/import-file',
|
||||
'POST',
|
||||
{ confirmed: true, token: uploaded.data.token },
|
||||
c.cookie,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await call('/overview', 'GET', undefined, c.cookie)).data.net, '20.00');
|
||||
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, '27.00');
|
||||
await call('/auth/reveal', 'POST', { password: b.password }, b.cookie);
|
||||
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '122.00');
|
||||
await db.session.update({
|
||||
where: { id: sessionId(a.cookie) },
|
||||
data: { revealUntil: new Date(Date.now() - 1000) },
|
||||
});
|
||||
assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '20.00');
|
||||
assert.equal(
|
||||
(await call('/backup/clear', 'POST', { confirmation: '确定清空' }, otherSession.cookie))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/backup/clear', 'POST', { confirmation: '清空' }, a.cookie)).status,
|
||||
400,
|
||||
);
|
||||
await call(
|
||||
'/positions/' + visible + '/revisions',
|
||||
'POST',
|
||||
{ amount: '21', date: '2026-09-01T10:12' },
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/backup/clear', 'POST', { confirmation: '确定清空' }, a.cookie)).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(await db.position.count({ where: { userId: a.id } }), 2);
|
||||
await call('/backup', 'GET', undefined, a.cookie);
|
||||
assert.equal(
|
||||
(await call('/backup/clear', 'POST', { confirmation: '确定清空', userId: b.id }, a.cookie))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/backup/clear', 'POST', { confirmation: '确定清空' }, a.cookie)).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(await db.position.count({ where: { userId: a.id } }), 0);
|
||||
assert.equal(await db.exchangeRate.count({ where: { userId: a.id } }), 0);
|
||||
assert.equal(await db.position.count({ where: { userId: b.id } }), 3);
|
||||
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 200);
|
||||
assert.equal(
|
||||
(await call('/settings', 'PATCH', { showSidebar: false, idleMinutes: 1 }, a.cookie)).status,
|
||||
200,
|
||||
);
|
||||
const prefs = (await call('/settings', 'GET', undefined, a.cookie)).data;
|
||||
assert.equal(prefs.showSidebar, false);
|
||||
assert.equal(prefs.idleMinutes, 1);
|
||||
assert.equal((await call('/settings', 'PATCH', { idleMinutes: -1 }, a.cookie)).status, 400);
|
||||
await db.session.update({
|
||||
where: { id: sessionId(a.cookie) },
|
||||
data: { lastActivity: new Date(Date.now() - 61000) },
|
||||
});
|
||||
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401);
|
||||
assert.equal((await call('/auth/activity', 'POST', undefined, a.cookie)).status, 401);
|
||||
assert.equal((await call('/auth/me', 'GET', undefined, b.cookie)).status, 200);
|
||||
const legacy = {
|
||||
...backup,
|
||||
version: 1,
|
||||
positions: backup.positions.map((p: any) => {
|
||||
const { hidden, ...rest } = p;
|
||||
return {
|
||||
...rest,
|
||||
revisions: p.revisions.map((r: any) => ({ ...r, date: r.date.slice(0, 10) })),
|
||||
};
|
||||
}),
|
||||
};
|
||||
assert.equal((await call('/auth/me', 'GET', undefined, otherSession.cookie)).status, 200);
|
||||
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
|
||||
assert.equal(
|
||||
(await call('/backup/import', 'POST', { confirmed: true, backup: legacy }, login.cookie))
|
||||
.status,
|
||||
201,
|
||||
);
|
||||
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 2);
|
||||
} finally {
|
||||
for (const username of names) await db.user.deleteMany({ where: { username } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import { validateBackup } from '../src/backup';
|
||||
import { packBackup, readBackupZip } from '../src/zip';
|
||||
const empty = () =>
|
||||
validateBackup({
|
||||
format: 'worthpath',
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
baseCurrency: 'CNY',
|
||||
preferences: { showSidebar: false, idleMinutes: 9 },
|
||||
currencies: ['CNY'],
|
||||
positions: [],
|
||||
rates: [],
|
||||
links: [],
|
||||
});
|
||||
async function archive(contents: Record<string, string>) {
|
||||
const zip = new ZipArchive({ zlib: { level: 1 } }),
|
||||
chunks: Buffer[] = [];
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
zip.on('data', (chunk) => chunks.push(chunk));
|
||||
zip.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
zip.on('error', reject);
|
||||
});
|
||||
for (const [name, data] of Object.entries(contents)) zip.append(data, { name });
|
||||
await zip.finalize();
|
||||
return done;
|
||||
}
|
||||
test('ZIP contains separate JSON files and restores settings without authentication data', async () => {
|
||||
const b = empty(),
|
||||
contents = packBackup(b);
|
||||
assert.deepEqual(Object.keys(contents).sort(), [
|
||||
'accounts.json',
|
||||
'assets.json',
|
||||
'currencies.json',
|
||||
'debts.json',
|
||||
'history.json',
|
||||
'links.json',
|
||||
'manifest.json',
|
||||
'rates.json',
|
||||
'settings.json',
|
||||
]);
|
||||
assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i);
|
||||
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
|
||||
});
|
||||
test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => {
|
||||
const contents = packBackup(empty());
|
||||
await assert.rejects(async () =>
|
||||
readBackupZip(await archive({ ...contents, 'settings.json': '{}' })),
|
||||
);
|
||||
const missing = { ...contents };
|
||||
delete missing['history.json'];
|
||||
await assert.rejects(async () => readBackupZip(await archive(missing)));
|
||||
await assert.rejects(async () =>
|
||||
readBackupZip(await archive({ ...contents, 'unexpected.json': '[]' })),
|
||||
);
|
||||
await assert.rejects(() => readBackupZip(Buffer.from('invalid zip')));
|
||||
});
|
||||
test('backup accepts over 1000 positions, 10000 revisions per position and 20000 total revisions', () => {
|
||||
const stamp = new Date().toISOString();
|
||||
const position = (count: number) => ({
|
||||
id: randomUUID(),
|
||||
name: 'count acceptance',
|
||||
kind: 'asset',
|
||||
side: 'asset',
|
||||
category: 'other',
|
||||
currency: 'CNY',
|
||||
notes: '',
|
||||
archived: false,
|
||||
hidden: false,
|
||||
createdAt: stamp,
|
||||
updatedAt: stamp,
|
||||
revisions: Array.from({ length: count }, (_, n) => ({
|
||||
id: randomUUID(),
|
||||
sequence: n + 1,
|
||||
amount: '1',
|
||||
date: '2026-09-01T09:17',
|
||||
notes: '',
|
||||
reason: 'valuation',
|
||||
createdAt: stamp,
|
||||
updatedAt: stamp,
|
||||
})),
|
||||
});
|
||||
const b = validateBackup({
|
||||
...empty(),
|
||||
positions: [position(10001), ...Array.from({ length: 1000 }, () => position(11))],
|
||||
});
|
||||
assert.equal(b.positions.length, 1001);
|
||||
assert.equal(
|
||||
b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
||||
21001,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user