feat: simplify agent permissions and require current ZIP backups

This commit is contained in:
陈煜 committed 2026-10-04 01:46:47 +08:00
1 parent efc2c7c734
commit 91c489513a
51 files changed
+1680 -1270

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { archiveBackup } from '../src/zip';
// Test-only aliases build ZIP fixtures and exercise the public upload/confirmation flow.
export async function fixtureFetch(url: string, init?: RequestInit): Promise<Response> {
if (!url.endsWith('/backup/restore-fixture') && !url.endsWith('/backup/preview-fixture'))
return fetch(url, init);
const restore = url.endsWith('/backup/restore-fixture');
const input = JSON.parse(String(init?.body));
const chunks: Buffer[] = [];
const archive = archiveBackup(restore ? input.backup : input);
const completed = new Promise<Buffer>((resolve, reject) => {
archive.on('data', (chunk) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', reject);
});
await archive.finalize();
const form = new FormData();
form.set(
'file',
new Blob([new Uint8Array(await completed)], { type: 'application/zip' }),
'fixture.zip',
);
const headers = new Headers(init?.headers);
headers.delete('Content-Type');
const base = url.slice(0, url.lastIndexOf('/backup/'));
const uploaded = await fetch(base + '/backup/upload', { method: 'POST', headers, body: form });
if (!uploaded.ok || !restore) return uploaded;
const preview = await uploaded.json();
headers.set('Content-Type', 'application/json');
return fetch(base + '/backup/import-file', {
method: 'POST',
headers,
body: JSON.stringify({ token: preview.token, confirmed: input.confirmed }),
});
}
+15 -1
View File
@@ -144,7 +144,21 @@ test('reject invalid dates, negative values and credit card assets', () => {
test('backup rejects auth data and broken relations', () => {
const b = {
format: 'worthpath',
version: 1,
version: 9,
preferences: {
hiddenMenus: [],
showNotes: true,
idleMinutes: 0,
accountGroupOrder: [],
sessionHours: 168,
requireHiddenPassword: true,
overviewCards: ['net'],
includeIndependentAssets: true,
},
icons: [],
transfers: [],
schedules: [],
metalPrices: [],
exportedAt: new Date().toISOString(),
baseCurrency: 'CNY',
currencies: ['CNY'],
+3 -2
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -25,7 +26,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri
return { id: u.id, cookie: 'wp_session=' + token };
}
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -163,7 +164,7 @@ test('liability account credit balances survive revisions, replay, calendar, pri
assert.equal(zip.status, 200);
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/overview', b.cookie)).data;
+4 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -25,7 +26,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
return { id: row.id, token };
}
async function call(token: string, path: string, method = 'GET', body?: unknown) {
const response = await fetch(base + path, {
const response = await fixtureFetch(base + path, {
method,
headers: {
Cookie: 'wp_session=' + token,
@@ -151,11 +152,11 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
200,
);
const backup = (await call(a.token, '/backup')).data;
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 8);
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 9);
assert.equal(backup.transfers.length, 5);
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
assert.equal(
(await call(b.token, '/backup/import', 'POST', { confirmed: true, backup })).status,
(await call(b.token, '/backup/restore-fixture', 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal(
+7 -6
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -5,7 +6,7 @@ import { randomUUID, randomBytes, 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('group order persists per user, validates input and survives ZIP and legacy imports', async () => {
test('group order persists per user, validates input and survives ZIP and reject incomplete imports', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
@@ -25,7 +26,7 @@ test('group order persists per user, validates input and survives ZIP and legacy
return { id: u.id, cookie: 'wp_session=' + token };
}
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -80,20 +81,20 @@ test('group order persists per user, validates input and survives ZIP and legacy
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
assert.deepEqual(backup.preferences.accountGroupOrder, order);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
409,
);
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
delete backup.preferences.accountGroupOrder;
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
400,
);
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
const comments: any[] = await db.$queryRawUnsafe(
+11 -5
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
names: string[] = [],
publicIds: string[] = [];
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -153,16 +154,21 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
assert.doesNotMatch(JSON.stringify(backup.data), /passwordHash|sessionId|ownerId|token/i);
const broken = structuredClone(backup.data);
broken.icons[0].image = 'invalid';
assert.equal((await call('/backup/preview', b.cookie, 'POST', broken)).status, 400);
assert.equal((await call('/backup/preview-fixture', b.cookie, 'POST', broken)).status, 400);
const before = await db.icon.count();
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken }))
.status,
400,
);
assert.equal(await db.icon.count(), before);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: backup.data }))
.status,
(
await call('/backup/restore-fixture', b.cookie, 'POST', {
confirmed: true,
backup: backup.data,
})
).status,
201,
);
const imported = await db.position.findMany({
+59 -38
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import { request } from 'node:http';
@@ -28,6 +29,14 @@ async function fixture() {
// Give this fixture suite its own loopback source address so independent auth
// scenarios do not consume the existing suite's per-IP production rate limit.
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
if (path.endsWith('-fixture')) {
const response = await fixtureFetch(base + path, {
method,
headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: response.status, data: await response.json(), cookie: null };
}
const data = body === undefined ? undefined : JSON.stringify(body);
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
const req = request(
@@ -200,7 +209,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await call('/metals/' + cash, a.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '10',
metalPurity: '0.999',
autoValuation: true,
})
).status,
@@ -211,7 +219,6 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await call('/metals/' + metal, b.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '10',
metalPurity: '0.999',
autoValuation: true,
})
).status,
@@ -222,24 +229,26 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await call('/metals/' + metal, a.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '10.86420978',
metalPurity: '0.999',
autoValuation: true,
})
).status,
200,
);
await db.position.update({ where: { id: metal }, data: { metalPurity: '0.999' } }); // Existing stored valuations are preserved, not an editable setting.
const d = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
assert.equal(
(
await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '700.864209789012',
date: d,
})
).status,
201,
);
assert.equal((await call('/metals/prices', a.cookie, 'POST', {})).status, 404);
await db.metalPrice.create({
data: {
userId: a.id,
metalType: 'gold',
currency: 'CNY',
price: '700.864209789012',
date: new Date(d),
source: 'manual',
quotedAt: new Date(),
},
});
await call('/metals/' + metal + '/value', a.cookie, 'POST', {});
const expected = (await import('../src/metals')).metalValue(
'10.86420978',
'0.999',
@@ -250,12 +259,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
expected.replace(/0+$/, '').replace(/\.$/, ''),
);
const count = await db.revision.count({ where: { positionId: metal } });
await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '700.864209789012',
date: d,
});
await call('/metals/' + metal + '/value', a.cookie, 'POST', {});
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
const backup = await (controller as any).data(a.id);
@@ -267,8 +271,12 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await done;
const restoredBackup = await readBackupZip(Buffer.concat(chunks));
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: restoredBackup }))
.status,
(
await call('/backup/restore-fixture', b.cookie, 'POST', {
confirmed: true,
backup: restoredBackup,
})
).status,
201,
);
assert.equal((await call('/auth/me', b.cookie)).data.includeIndependentAssets, false);
@@ -294,7 +302,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re
await db.$disconnect();
}
});
test('gram-only creation, optional exact purchase cost, pending totals and backward-compatible backups', async () => {
test('gram-only creation, optional exact purchase cost, pending totals and strict current backups', async () => {
const a = await fixture(),
b = await fixture();
try {
@@ -303,13 +311,22 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
currency: 'CNY',
metalType: 'gold',
metalGrams: '2.5',
metalPurity: '0.8',
autoValuation: true,
date: '2026-10-01T12:00',
metalCostPerGram: '12.8',
};
assert.equal(
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalPurity: '0.8' })).status,
400,
);
const created = await call('/metals/holdings', a.cookie, 'POST', input);
assert.equal(created.status, 201, JSON.stringify(created.data));
assert.equal(
(
await db.position.findUniqueOrThrow({ where: { id: created.data.id } })
).metalPurity.toString(),
'1',
);
assert.equal(created.data.valuationAvailable, false);
const id = created.data.id;
let p = (await call('/positions/' + id, a.cookie)).data;
@@ -326,36 +343,40 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
await call('/metals/' + id, b.cookie, 'PUT', {
metalType: 'gold',
metalGrams: '2.5',
metalPurity: '0.8',
autoValuation: true,
})
).status,
404,
);
const price = await call('/metals/prices', a.cookie, 'POST', {
metalType: 'gold',
currency: 'CNY',
price: '20',
date: '2026-10-01',
await db.metalPrice.create({
data: {
userId: a.id,
metalType: 'gold',
currency: 'CNY',
price: '20',
date: new Date('2026-10-01'),
source: 'goldapi',
quotedAt: new Date(),
},
});
assert.equal(price.status, 201, JSON.stringify(price.data));
assert.equal((await call('/metals/' + id + '/value', a.cookie, 'POST', {})).status, 201);
p = (await call('/positions/' + id, a.cookie)).data;
assert.equal(p.amount, '40');
assert.equal(p.metalProfit, '8.00000000');
assert.equal(p.amount, '50');
assert.equal(p.metalProfit, '18.00000000');
assert.equal(p.valuationAvailable, true);
const cfg = { metalType: 'gold', metalGrams: '2.5', metalPurity: '0.8', autoValuation: true };
const cfg = { metalType: 'gold', metalGrams: '2.5', autoValuation: true };
assert.equal((await call('/metals/' + id, a.cookie, 'PUT', cfg)).status, 200);
assert.equal((await call('/positions/' + id, a.cookie)).data.metalCostPerGram, '12.8');
const controller = new (await import('../src/backup')).BackupBusinessService(db as any);
const backup = await (controller as any).data(a.id);
assert.equal(backup.positions[0].metalCostPerGram, '12.8');
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/positions?kind=asset', b.cookie)).data[0];
assert.equal(restored.metalCostPerGram, '12.8');
assert.equal(restored.metalProfit, '8.00000000');
assert.equal(restored.metalProfit, '18.00000000');
const invalidCost = structuredClone(backup);
Object.assign(invalidCost.positions[0], {
metalType: null,
@@ -367,7 +388,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
);
const old = structuredClone(backup);
delete old.positions[0].metalCostPerGram;
assert.doesNotThrow(() =>
assert.throws(() =>
(require('../src/backup') as typeof import('../src/backup')).validateBackup(old),
);
assert.equal(
@@ -382,7 +403,7 @@ test('gram-only creation, optional exact purchase cost, pending totals and backw
});
assert.equal(next.status, 201);
assert.equal(next.data.valuationAvailable, true);
assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '40');
assert.equal((await call('/positions/' + next.data.id, a.cookie)).data.amount, '50');
assert.equal(
(await call('/metals/holdings', a.cookie, 'POST', { ...input, metalGrams: '0' })).status,
400,
+14 -12
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
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, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Origin: origin,
@@ -281,16 +282,17 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
JSON.stringify(backup),
/"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/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/preview-fixture', 'POST', backup, a.cookie)).status, 409);
assert.equal((await call('/backup/preview-fixture', 'POST', backup, b.cookie)).status, 201);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: false, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: false, backup }, b.cookie))
.status,
400,
);
assert.equal(
(
await call(
'/backup/import',
'/backup/restore-fixture',
'POST',
{
confirmed: true,
@@ -303,7 +305,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
);
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,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, o.net);
@@ -326,7 +328,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
sameDay.map((h: { after: string }) => h.after),
);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
409,
);
assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 4);
@@ -346,21 +348,21 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
b.cookie,
);
assert.equal(
(await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status,
(await call('/backup/restore-fixture', '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),
call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, c.cookie),
call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, c.cookie),
]);
assert.deepEqual(racing.map((r) => r.status).sort(), [201, 409]);
assert.deepEqual(racing.map((r) => r.status).sort(), [201, 400]);
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,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, a.cookie)).status,
401,
);
const login = await call('/auth/login', 'POST', { username: a.username, password: a.password });
+221 -135
View File
@@ -13,7 +13,7 @@ const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0.
const resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp';
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, files and sensitive confirmation', async () => {
test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, hidden permissions and removed capabilities', async () => {
const db = new PrismaClient(),
users: string[] = [],
clients: Client[] = [];
@@ -33,15 +33,22 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
cookie: response.headers.get('set-cookie')?.split(';')[0] || cookie,
};
}
async function fixture(mode = 'direct', selected = ['read', 'draft', 'write', 'sensitive']) {
async function fixture(
mode = 'direct',
selected = mode === 'direct'
? ['read', 'write']
: mode === 'draft'
? ['read', 'draft']
: ['read'],
) {
const username = 'mcp_test_' + randomUUID().slice(0, 12),
password = randomBytes(20).toString('hex');
const registered = await web('', '/auth/register', 'POST', { username, password });
assert.equal(registered.status, 201);
const user = await db.user.findUniqueOrThrow({ where: { username } });
users.push(user.id);
await db.agentPolicy.create({ data: { userId: user.id, mode: 'readonly' } }); // Historical policy must not override connection permissions.
const cookie = registered.cookie;
assert.equal((await web(cookie, '/agent/policy', 'PUT', { mode, password })).status, 200);
const token = await web(cookie, '/agent/tokens', 'POST', {
name: 'Official SDK integration',
days: 1,
@@ -82,7 +89,6 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
async function confirm(a: any, operation: any, extra: any = {}) {
const v = await web(a.cookie, '/agent/operations/' + operation.operationId, 'POST', {
approve: true,
password: a.password,
...extra,
});
assert.equal(v.status, 201, JSON.stringify(v.data));
@@ -113,11 +119,10 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
const a = await fixture(),
b = await fixture(),
d = await fixture('draft', ['read', 'draft']);
await write(a, 'rates_refresh');
await write(a, 'metals_refresh');
await call(a, 'connection_info');
const discovered = await a.client.listTools();
assert.equal(discovered.tools.length, 49);
assert.equal(discovered.tools.length, 39);
assert.ok(!discovered.tools.some((t) => t.name === 'metal_price_set'));
assert.equal(
discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint,
true,
@@ -310,15 +315,20 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
id: metal,
data: { name: 'gold', category: 'gold', notes: '', archived: false, hidden: false },
});
await write(a, 'metal_price_set', {
metalType: 'gold',
currency: 'CNY',
price: '10.876543210987',
date: day,
await db.metalPrice.create({
data: {
userId: a.id,
metalType: 'gold',
currency: 'CNY',
price: '10.876543210987',
date: new Date(day),
source: 'goldapi',
quotedAt: new Date(),
},
});
await write(a, 'metal_configure', {
id: metal,
data: { metalType: 'gold', metalGrams: '2', metalPurity: '1', autoValuation: true },
data: { metalType: 'gold', metalGrams: '2', autoValuation: true },
});
assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642');
await write(a, 'metal_value', { id: metal });
@@ -328,7 +338,6 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
currency: 'CNY',
metalType: 'gold',
metalGrams: '2',
metalPurity: '1',
autoValuation: true,
metalCostPerGram: '9',
date: day,
@@ -362,9 +371,8 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
assert.notEqual((await call(a, 'position_get', { id: cash })).amount, before);
assert.equal((await write(a, 'schedules_run')).result.executed, 0);
await write(a, 'schedule_delete', { id: plan });
const hidden = (
await write(a, 'position_create', { ...position, name: 'hidden', hidden: true })
).result.id;
const hidden = (await write(a, 'position_create', { ...position, name: 'hidden' })).result.id;
await db.position.update({ where: { id: hidden }, data: { hidden: true } });
await fail(a, 'position_get', { id: hidden });
await fail(a, 'debt_links_set', {
id: debt,
@@ -377,80 +385,45 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
expectedState: (await call(d, 'state_get')).state,
idempotencyKey: randomUUID(),
});
const unlock = await write(a, 'hidden_unlock_request');
await confirm(a, unlock);
for (const removed of [
'rates_refresh',
'metals_refresh',
'metal_price_set',
'backup_export',
'backup_import',
'credentials_change_request',
'hidden_unlock_request',
'hidden_lock',
'data_clear_request',
'import_preview',
]) {
assert.ok(!discovered.tools.some((t) => t.name === removed));
await fail(a, removed);
}
await db.agentGrant.update({
where: { id: a.grantId },
data: { scopes: ['read', 'write', 'hidden_read'] },
});
assert.equal((await call(a, 'position_get', { id: hidden })).id, hidden);
await write(a, 'hidden_lock');
await fail(a, 'position_get', { id: hidden });
const exported = await write(a, 'backup_export');
assert.equal(exported.status, 'pending');
const out = (await confirm(a, exported)).result;
assert.equal((await fetch(out.url)).status, 401);
assert.equal(
(await fetch(out.url, { headers: { Authorization: 'Bearer ' + b.token } })).status,
403,
);
const download = await fetch(out.url, { headers: { Authorization: 'Bearer ' + a.token } });
assert.equal(download.status, 200);
const zipped = Buffer.from(await download.arrayBuffer());
const backup: any = await readBackupZip(zipped);
assert.ok(backup.positions.find((p: any) => p.id === hidden));
assert.equal(JSON.stringify(backup).includes(a.token), false);
const target = await fixture('draft'),
upload = await call(target, 'file_upload_request', { kind: 'backup' }),
form = new FormData();
form.append('file', new Blob([zipped]), 'backup.zip');
const uploaded = await fetch(upload.url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + target.token },
body: form,
await fail(a, 'position_update', {
id: hidden,
data: { name: 'forbidden', category: 'cash', hidden: true },
expectedState: (await call(a, 'state_get')).state,
idempotencyKey: randomUUID(),
});
await db.agentGrant.update({
where: { id: a.grantId },
data: { scopes: ['read', 'write', 'hidden_read', 'hidden_write'] },
});
await write(a, 'position_update', {
id: hidden,
data: { name: 'authorized hidden', category: 'cash', hidden: true },
});
assert.equal(uploaded.status, 200);
const info = await uploaded.json();
assert.ok(info.token);
await call(target, 'file_status', { fileId: upload.fileId });
await call(target, 'import_preview', { token: info.token });
const imported = await write(target, 'backup_import', { token: info.token });
await confirm(target, imported);
assert.equal(
await db.position.count({ where: { userId: target.id } }),
backup.positions.length,
);
const retry = await write(target, 'backup_import', { token: info.token });
assert.equal(
(
await web(target.cookie, '/agent/operations/' + retry.operationId, 'POST', {
approve: true,
password: target.password,
})
).status,
409,
);
assert.equal(
await db.position.count({ where: { userId: target.id } }),
backup.positions.length,
);
const clear = await write(target, 'data_clear_request');
assert.equal(
(
await web(target.cookie, '/agent/operations/' + clear.operationId, 'POST', {
approve: true,
password: target.password,
confirmation: '确定清空',
})
).status,
400,
);
const save = await fetch(root + '/api/backup', { headers: { Cookie: target.cookie } });
assert.equal(save.status, 200);
await save.arrayBuffer();
await confirm(target, clear, { confirmation: '确定清空' });
assert.equal(await db.position.count({ where: { userId: target.id } }), 0);
assert.equal(
(await web(a.cookie, '/agent/policy', 'PUT', { mode: 'readonly', password: a.password }))
.status,
200,
(await db.position.findUniqueOrThrow({ where: { id: hidden } })).name,
'authorized hidden',
);
await db.agentGrant.update({ where: { id: a.grantId }, data: { scopes: ['read'] } });
await fail(a, 'position_create', {
...position,
expectedState: (await call(a, 'state_get')).state,
@@ -495,7 +468,7 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
}
});
test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback and credentials completion', async () => {
test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback', async () => {
const db = new PrismaClient();
const username = 'mcp_extra_' + randomUUID().slice(0, 10),
password = randomBytes(20).toString('hex');
@@ -534,12 +507,11 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
try {
assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201);
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
await web('/agent/policy', 'PUT', { mode: 'direct', password });
const grant = (
await web('/agent/tokens', 'POST', {
name: 'extra',
days: 1,
scopes: ['read', 'draft', 'write', 'sensitive'],
scopes: ['read', 'write'],
password,
})
).data;
@@ -602,18 +574,15 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
iconIds.push(published.id);
const image = await call(c, 'icon_image', { id: published.id });
assert.equal((await fetch(image.url, { headers })).status, 200);
const shared = await write(c, 'icon_publish', {
const forbiddenShared = await tool(c, 'icon_publish', {
fileId: upload.fileId,
name: '测试共享图标',
shared: true,
expectedState: (await call(c, 'state_get')).state,
idempotencyKey: randomUUID(),
});
assert.equal(shared.status, 'pending');
assert.equal(
(await web('/agent/operations/' + shared.operationId, 'POST', { approve: true, password }))
.status,
201,
);
iconIds.push((await call(c, 'operation_get', { operationId: shared.operationId })).result.id);
assert.equal(forbiddenShared.isError, true);
assert.equal(await db.icon.count({ where: { ownerId: userId, shared: true } }), 0);
// A failed paired transfer leaves neither side changed, including inside outer
// idempotency transaction and nested service savepoints.
const account = one.result.id,
@@ -670,36 +639,6 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb
const management = (await web('/agent')).data;
assert.ok(management.calls.some((v: any) => v.status === 'error'));
assert.equal(JSON.stringify(management).includes(grant.token), false);
const operation = await write(c, 'credentials_change_request');
const replacement = randomBytes(20).toString('hex');
assert.equal(
(
await web('/agent/operations/' + operation.operationId, 'POST', {
approve: true,
password,
newPassword: replacement,
})
).status,
201,
);
assert.equal(
(await call(c, 'operation_get', { operationId: operation.operationId })).status,
'completed',
);
assert.equal(
(
await tool(c, 'position_create', {
...position,
idempotencyKey: randomUUID(),
expectedState: (await call(c, 'state_get')).state,
})
).isError,
true,
);
assert.equal(
(await web('/auth/login', 'POST', { username, password: replacement })).status,
201,
);
} finally {
for (const c of clients) await c.close().catch(() => {});
await db.icon.deleteMany({ where: { id: { in: iconIds } } });
@@ -726,7 +665,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: 'read draft write sensitive',
scope: 'read write',
},
clientInformation: () => saved,
saveClientInformation: (v) => {
@@ -766,19 +705,22 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
const registered = await post('/api/auth/register', { username, password });
assert.equal(registered.status, 201);
userId = (await db.user.findUniqueOrThrow({ where: { username } })).id;
assert.equal(
await auth(provider, { serverUrl: resource, scope: 'read draft write sensitive' }),
'REDIRECT',
);
assert.equal(await auth(provider, { serverUrl: resource, scope: 'read write' }), 'REDIRECT');
assert.ok(authorization);
const redirected = await fetch(authorization!, { redirect: 'manual' });
assert.equal(redirected.status, 302);
const location = new URL(redirected.headers.get('location')!);
const id = location.searchParams.get('agent_authorization');
assert.ok(id);
const deniedEscalation = await post(
'/api/agent/authorizations/' + id,
{ approve: true, scopes: ['read', 'write', 'hidden_read'] },
registered.cookie,
);
assert.equal(deniedEscalation.status, 400);
const consent = await post(
'/api/agent/authorizations/' + id,
{ approve: true },
{ approve: true, scopes: ['read', 'write'] },
registered.cookie,
);
assert.equal(consent.status, 201);
@@ -795,7 +737,7 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
await client.connect(
new StreamableHTTPClientTransport(new URL(resource), { authProvider: provider }),
);
assert.ok((await client.listTools()).tools.length >= 40);
assert.ok((await client.listTools()).tools.length === 39);
await client.close();
async function exchange(params: Record<string, string>) {
const r = await fetch(root + '/token', {
@@ -888,3 +830,147 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation
await db.$disconnect();
}
});
test('fixed PAT durations, permanent session renewal, expiry, isolation and revocation are real', async () => {
const db = new PrismaClient(),
users: string[] = [],
clients: Client[] = [];
const { createHash } = await import('node:crypto'),
{ hash } = await import('bcryptjs');
const password = 'Temporary-pat-test-Only!';
async function fixture() {
const u = await db.user.create({
data: {
username: 'pat_' + randomUUID(),
passwordHash: await hash(password, 4),
idleMinutes: 0,
},
});
users.push(u.id);
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: u.id,
expiresAt: new Date(Date.now() + 86400000),
},
});
return { id: u.id, cookie: 'wp_session=' + token };
}
async function web(u: any, path: string, method = 'GET', body?: unknown) {
const r = await fetch(root + '/api' + path, {
method,
headers: {
Origin: origin,
Cookie: u.cookie,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, data: await r.json() };
}
try {
const a = await fixture(),
b = await fixture();
let permanent: any;
const issuedTokens = new Map<string, string>();
for (const days of [1, 3, 7, 30, 365, null]) {
const issued = await web(a, '/agent/tokens', 'POST', {
name: 'expiry fixture',
scopes: ['read'],
days,
password,
});
assert.equal(issued.status, 201, JSON.stringify(issued.data));
issuedTokens.set(issued.data.id, issued.data.token);
const row = await db.agentGrant.findUniqueOrThrow({ where: { id: issued.data.id } });
assert.equal(row.accessDigest, createHash('sha256').update(issued.data.token).digest('hex'));
if (days === null) {
assert.equal(row.expiresAt, null);
permanent = issued.data;
} else {
assert.ok(row.expiresAt);
assert.ok(Math.abs(+row.expiresAt! - Date.now() - days * 86400000) < 5000);
}
}
assert.equal(
(
await web(a, '/agent/tokens', 'POST', {
name: 'invalid duration',
scopes: ['read'],
days: 2,
password,
})
).status,
400,
);
for (const scopes of [
['read', 'sensitive'],
['read', 'draft', 'write'],
['read', 'hidden_write'],
]) {
assert.equal(
(
await web(a, '/agent/tokens', 'POST', {
name: 'invalid scopes',
scopes,
days: 1,
password,
})
).status,
400,
);
}
assert.equal((await web(a, '/agent/policy', 'PUT', { mode: 'direct', password })).status, 404);
const management = await web(a, '/agent');
assert.equal(management.data.capabilities.length, 39);
assert.ok(!management.data.capabilities.some((t: any) => t.name === 'metal_price_set'));
assert.equal((await web(b, '/agent')).data.grants.length, 0);
assert.equal((await web(b, '/agent/connections/' + permanent.id, 'DELETE')).status, 200);
assert.equal(
(await db.agentGrant.findUniqueOrThrow({ where: { id: permanent.id } })).revokedAt,
null,
);
const row = await db.agentGrant.findUniqueOrThrow({ where: { id: permanent.id } });
await db.session.delete({ where: { id: row.sessionId } });
const client = new Client({ name: 'Permanent PAT test', version: '1.31.0' });
clients.push(client);
await client.connect(
new StreamableHTTPClientTransport(new URL(resource), {
requestInit: { headers: { Authorization: 'Bearer ' + permanent.token } },
}),
);
const result: any = await client.callTool({ name: 'connection_info', arguments: {} });
assert.ok(!result.isError, JSON.stringify(result));
assert.equal(result.structuredContent.data.expiresAt, null);
const business: any = await client.callTool({
name: 'positions_list',
arguments: { limit: 1 },
});
assert.ok(!business.isError, JSON.stringify(business));
assert.ok(
(await db.session.findUniqueOrThrow({ where: { id: row.sessionId } })).expiresAt > new Date(),
);
assert.equal((await web(a, '/agent/connections/' + permanent.id, 'DELETE')).status, 200);
await assert.rejects(() => client.listTools());
const finite = management.data.grants.find((g: any) => g.expiresAt);
await db.agentGrant.update({ where: { id: finite.id }, data: { expiresAt: new Date(0) } });
const expiredClient = new Client({ name: 'Expired PAT test', version: '1.31.0' });
clients.push(expiredClient);
await assert.rejects(() =>
expiredClient.connect(
new StreamableHTTPClientTransport(new URL(resource), {
requestInit: { headers: { Authorization: 'Bearer ' + issuedTokens.get(finite.id) } },
}),
),
);
const expired = await web(a, '/agent');
assert.equal(
expired.data.grants.find((g: any) => g.id === finite.id).expiresAt,
'1970-01-01T00:00:00.000Z',
);
} finally {
for (const c of clients) await c.close().catch(() => {});
await db.user.deleteMany({ where: { id: { in: users } } });
await db.$disconnect();
}
});
+14 -7
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -9,7 +10,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
const db = new PrismaClient(),
names: string[] = [];
async function call(path: string, method = 'GET', data?: unknown, cookie = '') {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
@@ -118,7 +119,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
);
assert.equal(history[1].delta, '15');
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
assert.equal(backup.version, 2);
assert.equal(backup.version, 9);
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,
@@ -192,7 +193,7 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
);
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,
(await call('/backup/restore-fixture', 'POST', { confirmed: true, backup }, b.cookie)).status,
201,
);
assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '27.00');
@@ -267,11 +268,17 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
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,
(
await call(
'/backup/restore-fixture',
'POST',
{ confirmed: true, backup: legacy },
login.cookie,
)
).status,
400,
);
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 2);
assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 0);
} finally {
for (const username of names) await db.user.deleteMany({ where: { username } });
await db.$disconnect();
+6 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -15,7 +16,9 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
sessionId = createHash('sha256').update(token).digest('hex');
let restoredUserId: string | undefined;
async function call(path: string) {
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
const response = await fixtureFetch(base + path, {
headers: { Cookie: 'wp_session=' + token },
});
return { status: response.status, data: await response.json() };
}
try {
@@ -142,7 +145,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
date: '2026-09-03T00:01',
};
const post = async (path: string, body: unknown, cookie = token) => {
const response = await fetch(base + path, {
const response = await fixtureFetch(base + path, {
method: 'POST',
headers: {
Cookie: 'wp_session=' + cookie,
@@ -179,7 +182,7 @@ test('bounded APIs preserve seeds, predecessor balances, pagination, archive and
},
});
assert.equal(
(await post('/backup/import', { confirmed: true, backup }, restoredToken)).status,
(await post('/backup/restore-fixture', { confirmed: true, backup }, restoredToken)).status,
201,
);
const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } });
+31 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
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 fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -141,6 +142,30 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
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', {
@@ -234,10 +259,13 @@ test('credentials rotate sessions; deleting paired and empty histories preserves
emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount,
'-5',
);
assert.equal((await call('/backup/preview', b.cookie, 'POST', emptyAccountData)).status, 409);
assert.equal(
(await call('/backup/preview-fixture', b.cookie, 'POST', emptyAccountData)).status,
409,
);
const fresh = await user();
assert.equal(
(await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status,
(await call('/backup/preview-fixture', fresh.cookie, 'POST', emptyAccountData)).status,
201,
);
} finally {
+12 -3
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import { request } from 'node:http';
@@ -28,6 +29,14 @@ async function fixture() {
// Give this fixture suite its own loopback source address so independent auth
// scenarios do not consume the existing suite's per-IP production rate limit.
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
if (path.endsWith('-fixture')) {
const response = await fixtureFetch(base + path, {
method,
headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: response.status, data: await response.json(), cookie: null };
}
const data = body === undefined ? undefined : JSON.stringify(body);
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
const req = request(
@@ -127,7 +136,7 @@ test('session duration boundaries, privacy isolation and card settings survive b
assert.equal(backup.preferences.sessionHours, 720);
assert.equal(backup.preferences.requireHiddenPassword, false);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const imported = (await call('/settings', b.cookie)).data;
@@ -138,8 +147,8 @@ test('session duration boundaries, privacy isolation and card settings survive b
delete backup.preferences.requireHiddenPassword;
delete backup.preferences.overviewCards;
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
400,
);
const legacy = (await call('/settings', c.cookie)).data;
assert.equal(legacy.sessionHours, 168);
+6 -4
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
const db = new PrismaClient(),
names: string[] = [];
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
const r = await fetch(base + path, {
const r = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -227,12 +228,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
const broken = structuredClone(backup);
broken.transfers[0].amount = '999';
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken }))
.status,
400,
);
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/backup', b.cookie)).data;
@@ -241,7 +243,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
const c = await account();
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal((await call('/settings', c.cookie)).data.showNotes, false);
+3 -2
View File
@@ -1,3 +1,4 @@
import { fixtureFetch } from './backup-fixture';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -12,7 +13,7 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP
const minute = (delta = 0) =>
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
const res = await fixtureFetch(base + path, {
method,
headers: {
Cookie: cookie,
@@ -186,7 +187,7 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP
const backup = (await call('/backup', a.cookie)).data;
assert.equal(backup.schedules.length, 3);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
+50 -99
View File
@@ -7,14 +7,24 @@ import { packBackup, readBackupZip } from '../src/zip';
const empty = () =>
validateBackup({
format: 'worthpath',
version: 2,
version: 9,
exportedAt: new Date().toISOString(),
baseCurrency: 'CNY',
preferences: { hiddenMenus: ['asset'], showNotes: false, idleMinutes: 9 },
preferences: {
hiddenMenus: ['asset'],
showNotes: false,
idleMinutes: 9,
accountGroupOrder: [],
sessionHours: 168,
requireHiddenPassword: true,
overviewCards: ['net'],
includeIndependentAssets: true,
},
currencies: ['CNY'],
icons: [],
transfers: [],
schedules: [],
metalPrices: [],
positions: [],
rates: [],
links: [],
@@ -48,7 +58,10 @@ test('ZIP contains separate JSON files and restores settings without authenticat
'settings.json',
'transfers.json',
]);
assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i);
assert.doesNotMatch(
Object.values(contents).join('\n'),
/"(?:passwordHash|password|token|sessionId|userId|accessDigest|refreshDigest)"/i,
);
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
});
test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => {
@@ -69,6 +82,15 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000
const position = (count: number) => ({
id: randomUUID(),
name: 'count acceptance',
groupName: '',
iconId: null,
included: true,
importedFromId: null,
metalType: null,
metalGrams: null,
metalCostPerGram: null,
metalPurity: '1',
autoValuation: false,
kind: 'asset',
side: 'asset',
category: 'other',
@@ -100,102 +122,31 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000
);
});
test('legacy v3 ZIP remains readable without icons', async () => {
const contents = packBackup(empty());
delete contents['icons.json'];
delete contents['transfers.json'];
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 3;
manifest.files = manifest.files.filter(
(f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name),
);
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
assert.equal(restored.icons, undefined);
assert.deepEqual(restored.positions, []);
test('all historical ZIP versions and JSON formats are rejected', async () => {
for (const version of [3, 4, 5, 6, 7, 8]) {
const contents = packBackup(empty());
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = version;
contents['manifest.json'] = JSON.stringify(manifest);
await assert.rejects(async () => readBackupZip(await archive(contents)));
}
for (const version of [1, 2, 3]) assert.throws(() => validateBackup({ ...empty(), version }));
});
test('legacy v4 ZIP remains readable without transfers', async () => {
const contents = packBackup(empty());
delete contents['transfers.json'];
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 4;
manifest.files = manifest.files.filter(
(f: { name: string }) => !['transfers.json', 'schedules.json'].includes(f.name),
);
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
assert.deepEqual(restored.icons, []);
assert.equal(restored.transfers, undefined);
assert.equal(restored.preferences?.showNotes, false);
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
});
test('ZIP v7 restores groups and schedules while v6 remains readable', async () => {
const id = randomUUID(),
stamp = new Date().toISOString();
const b = validateBackup({
...empty(),
positions: [
{
id,
name: '账户',
groupName: '日常',
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
notes: '',
archived: false,
hidden: false,
createdAt: stamp,
updatedAt: stamp,
revisions: [
{
id: randomUUID(),
amount: '-10',
date: '2026-09-01T10:00',
notes: '',
reason: 'balance',
createdAt: stamp,
updatedAt: stamp,
},
],
},
],
schedules: [
{
id: randomUUID(),
name: '租金',
operation: 'expense',
sourceId: id,
targetId: null,
amount: '100',
received: '0',
nextAt: '2026-11-01T10:00',
intervalDays: 30,
enabled: true,
notes: '',
},
],
});
const contents = packBackup(b);
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 6;
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'schedules.json');
contents['manifest.json'] = JSON.stringify(manifest);
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
});
test('ZIP settings retain group order while missing legacy order remains optional', async () => {
test('current backups require complete settings and metadata, with no legacy defaults', async () => {
const b = empty();
b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常'];
const restored = validateBackup(await readBackupZip(await archive(packBackup(b))));
assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder);
const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty()))));
assert.equal(legacy.preferences!.accountGroupOrder, undefined);
b.preferences.accountGroupOrder = ['日常', ''];
assert.deepEqual(validateBackup(await readBackupZip(await archive(packBackup(b)))), b);
for (const key of Object.keys(b.preferences)) {
const incomplete = structuredClone(b);
delete (incomplete.preferences as any)[key];
assert.throws(() => validateBackup(incomplete));
}
for (const key of ['icons', 'transfers', 'schedules', 'metalPrices']) {
const incomplete = structuredClone(b);
delete (incomplete as any)[key];
assert.throws(() => validateBackup(incomplete));
}
assert.throws(() =>
validateBackup({ ...b, preferences: { ...b.preferences, showSidebar: false } }),
);
});