236 lines
9.2 KiB
TypeScript
236 lines
9.2 KiB
TypeScript
import { provisionTestUser } from './user-fixture';
|
|
import { fixtureFetch } from './backup-fixture';
|
|
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 sharp from 'sharp';
|
|
import { readBackupZip } from '../src/zip';
|
|
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
|
test('private and shared icons, account reuse and complete ZIP restoration preserve isolation', async () => {
|
|
const db = new PrismaClient(),
|
|
names: string[] = [],
|
|
publicIds: string[] = [];
|
|
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
|
|
const res = await fixtureFetch(base + path, {
|
|
method,
|
|
headers: {
|
|
Cookie: cookie,
|
|
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
|
|
...(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)
|
|
: res.headers.get('content-type')?.includes('application/json')
|
|
? await res.json()
|
|
: Buffer.from(await res.arrayBuffer()),
|
|
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
|
|
};
|
|
}
|
|
async function account() {
|
|
const username = 'wp_icons_' + randomUUID(),
|
|
password = randomBytes(18).toString('hex');
|
|
names.push(username);
|
|
await provisionTestUser({ username, password });
|
|
const r = await call('/auth/login', '', 'POST', {
|
|
username,
|
|
password,
|
|
});
|
|
assert.equal(r.status, 201);
|
|
return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id };
|
|
}
|
|
const image = await sharp({
|
|
create: { width: 400, height: 400, channels: 4, background: '#097c71' },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
async function upload(
|
|
cookie: string,
|
|
name: string,
|
|
shared = false,
|
|
confirmed = false,
|
|
content = image,
|
|
) {
|
|
const f = new FormData();
|
|
f.append('file', new Blob([new Uint8Array(content)], { type: 'image/png' }), 'icon.png');
|
|
f.append('name', name);
|
|
f.append('shared', String(shared));
|
|
if (confirmed) f.append('confirmed', 'true');
|
|
const r = await fetch(base + '/icons/upload', {
|
|
method: 'POST',
|
|
headers: { Cookie: cookie, Origin: 'http://localhost:5173' },
|
|
body: f,
|
|
});
|
|
return { status: r.status, data: await r.json() };
|
|
}
|
|
try {
|
|
const a = await account(),
|
|
b = await account();
|
|
assert.equal((await call('/icons')).status, 401);
|
|
const own = await upload(a.cookie, '我的银行');
|
|
assert.equal(own.status, 201);
|
|
const stored = await db.icon.findUniqueOrThrow({ where: { id: own.data.id } });
|
|
const persistedFile = process.env.ICON_STORAGE_DIR
|
|
? join(process.env.ICON_STORAGE_DIR, stored.hash + '.png')
|
|
: undefined;
|
|
if (process.env.TEST_ISOLATED === 'true') {
|
|
assert.ok(persistedFile);
|
|
assert.deepEqual(await readFile(persistedFile), Buffer.from(stored.data));
|
|
await unlink(persistedFile);
|
|
assert.equal((await call('/icons/' + own.data.id + '/image', a.cookie)).status, 200);
|
|
assert.deepEqual(await readFile(persistedFile), Buffer.from(stored.data));
|
|
await writeFile(persistedFile, 'corrupt file');
|
|
const repaired = await call('/icons/' + own.data.id + '/image', a.cookie);
|
|
assert.deepEqual(repaired.data, Buffer.from(stored.data));
|
|
assert.deepEqual(await readFile(persistedFile), Buffer.from(stored.data));
|
|
}
|
|
assert.equal('source' in own.data, false);
|
|
const ownList = await call('/icons?q=' + encodeURIComponent('我的银行'), a.cookie);
|
|
assert.equal('source' in ownList.data.items.find((i: any) => i.id === own.data.id), false);
|
|
const sourceColumns: any[] = await db.$queryRawUnsafe(
|
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Icon' AND COLUMN_NAME = 'source'",
|
|
);
|
|
assert.equal(sourceColumns.length, 0);
|
|
assert.equal((await upload(a.cookie, '同一图片')).data.id, own.data.id);
|
|
assert.equal((await call('/icons/' + own.data.id + '/image', b.cookie)).status, 404);
|
|
assert.equal(
|
|
(await call('/icons?q=' + encodeURIComponent('我的银行'), b.cookie)).data.total,
|
|
0,
|
|
);
|
|
const privateImage = await call('/icons/' + own.data.id + '/image', a.cookie);
|
|
assert.equal(privateImage.status, 200);
|
|
assert.equal((await sharp(privateImage.data).metadata()).width, 256);
|
|
assert.equal((await upload(a.cookie, 'English', true, true)).status, 400);
|
|
assert.equal((await upload(a.cookie, '共享银行', true)).status, 400);
|
|
assert.equal(
|
|
(await upload(a.cookie, '坏图标', false, false, Buffer.from('<svg onload="alert(1)"/>')))
|
|
.status,
|
|
400,
|
|
);
|
|
const shared = await upload(a.cookie, '共享测试银行', true, true);
|
|
assert.equal(shared.status, 201);
|
|
publicIds.push(shared.data.id);
|
|
assert.equal((await call('/icons/' + shared.data.id + '/image', b.cookie)).status, 200);
|
|
assert.equal(
|
|
(await call('/icons?q=' + encodeURIComponent('共享测试银行'), b.cookie)).data.items.some(
|
|
(i: any) => i.id === shared.data.id,
|
|
),
|
|
true,
|
|
);
|
|
const meta = {
|
|
kind: 'account',
|
|
side: 'asset',
|
|
category: 'bank',
|
|
name: '图标关联验收',
|
|
currency: 'CNY',
|
|
amount: '10',
|
|
date: '2026-09-01T10:35',
|
|
};
|
|
assert.equal(
|
|
(await call('/positions', b.cookie, 'POST', { ...meta, iconId: own.data.id })).status,
|
|
400,
|
|
);
|
|
const p = await call('/positions', a.cookie, 'POST', { ...meta, iconId: own.data.id });
|
|
assert.equal(p.status, 201);
|
|
assert.equal(
|
|
(await call('/positions', a.cookie, 'POST', { ...meta, iconId: own.data.id })).status,
|
|
201,
|
|
);
|
|
const other = await call('/positions', b.cookie, 'POST', { ...meta, iconId: shared.data.id });
|
|
assert.equal(other.status, 201);
|
|
assert.equal(
|
|
(
|
|
await call('/positions/' + other.data.id, b.cookie, 'PATCH', {
|
|
name: meta.name,
|
|
category: 'bank',
|
|
iconId: own.data.id,
|
|
})
|
|
).status,
|
|
400,
|
|
);
|
|
assert.equal(
|
|
(await db.position.findUniqueOrThrow({ where: { id: other.data.id } })).iconId,
|
|
shared.data.id,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await call('/positions/' + p.data.id, a.cookie, 'PATCH', {
|
|
name: meta.name,
|
|
category: 'bank',
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
assert.equal(
|
|
(await db.position.findUniqueOrThrow({ where: { id: p.data.id } })).iconId,
|
|
own.data.id,
|
|
);
|
|
const backup = await call('/backup', a.cookie);
|
|
assert.equal(backup.status, 200);
|
|
assert.equal(
|
|
backup.data.icons.some((i: any) => i.id === own.data.id),
|
|
true,
|
|
);
|
|
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-fixture', b.cookie, 'POST', broken)).status, 400);
|
|
const before = await db.icon.count();
|
|
assert.equal(
|
|
(await call('/backup/restore-fixture', b.cookie, 'POST', { confirmed: true, backup: broken }))
|
|
.status,
|
|
400,
|
|
);
|
|
assert.equal(await db.icon.count(), before);
|
|
if (process.env.TEST_ISOLATED === 'true') await unlink(persistedFile!);
|
|
assert.equal(
|
|
(
|
|
await call('/backup/restore-fixture', b.cookie, 'POST', {
|
|
confirmed: true,
|
|
backup: backup.data,
|
|
})
|
|
).status,
|
|
201,
|
|
);
|
|
if (process.env.TEST_ISOLATED === 'true')
|
|
assert.deepEqual(await readFile(persistedFile!), Buffer.from(stored.data));
|
|
const imported = await db.position.findMany({
|
|
where: { userId: b.id, importedFromId: { not: null } },
|
|
include: { icon: true },
|
|
});
|
|
assert.equal(imported.length, 2);
|
|
assert.equal(imported[0].iconId, imported[1].iconId);
|
|
assert.equal(imported[0].icon?.ownerId, b.id);
|
|
assert.equal(imported[0].icon?.shared, false);
|
|
// Backup receipt remains valid; only temporary account A is cleared.
|
|
assert.equal(
|
|
(await call('/backup/clear', a.cookie, 'POST', { confirmation: '确定清空' })).status,
|
|
201,
|
|
);
|
|
assert.equal(await db.icon.count({ where: { id: own.data.id } }), 0);
|
|
assert.equal((await call('/icons/' + shared.data.id + '/image', b.cookie)).status, 200);
|
|
assert.equal(
|
|
(await db.position.findUniqueOrThrow({ where: { id: other.data.id } })).iconId,
|
|
shared.data.id,
|
|
);
|
|
assert.equal((await db.icon.count({ where: { ownerId: b.id, shared: false } })) > 0, true);
|
|
} finally {
|
|
const users = await db.user.findMany({
|
|
where: { username: { in: names } },
|
|
select: { id: true },
|
|
});
|
|
await db.icon.deleteMany({
|
|
where: { OR: [{ ownerId: { in: users.map((u) => u.id) } }, { id: { in: publicIds } }] },
|
|
});
|
|
await db.user.deleteMany({ where: { username: { in: names } } });
|
|
await db.$disconnect();
|
|
}
|
|
});
|