fix: account credentials, record deletion refresh and backup recovery
This commit is contained in:
1 parent
9adf7fdbc5
commit
e77650f0c0
22 files changed
+711
-115
No files matched your search
@@ -9,7 +9,7 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "node scripts/database.cjs deploy",
|
||||
"db:status": "node scripts/database.cjs status",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts",
|
||||
"test:performance": "tsx scripts/performance.ts after",
|
||||
"icons:seed": "node scripts/seed-icons.cjs"
|
||||
},
|
||||
|
||||
+45
-2
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
BadRequestException,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
@@ -18,7 +20,8 @@ import { Request, Response } from 'express';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import { Database } from './database';
|
||||
import { credentials } from './validation';
|
||||
import { credentials, credentialChange } from './validation';
|
||||
import { Prisma } from '@prisma/client';
|
||||
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
|
||||
const Public = () => SetMetadata('public', true);
|
||||
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
@@ -58,6 +61,9 @@ export class AuthService {
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
await this.db.session.create({ data: { id: digest(token), userId, expiresAt } });
|
||||
this.cookie(token, expiresAt, res);
|
||||
}
|
||||
cookie(token: string, expiresAt: Date, res: Response) {
|
||||
res.cookie('wp_session', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
@@ -166,7 +172,44 @@ export class AuthController {
|
||||
idleMinutes: true,
|
||||
},
|
||||
});
|
||||
return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) };
|
||||
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
|
||||
return {
|
||||
...user,
|
||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||
revealed: req.revealed,
|
||||
revealUntil: session.revealUntil,
|
||||
lastActivity: session.lastActivity,
|
||||
};
|
||||
}
|
||||
@Patch('auth/credentials') async changeCredentials(
|
||||
@Req() r: UserRequest,
|
||||
@Body() body: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
this.auth.limit(r);
|
||||
const v = credentialChange.parse(body);
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(v.currentPassword, user.passwordHash)))
|
||||
throw new ForbiddenException('当前密码错误');
|
||||
if ((!v.username || v.username === user.username) && !v.newPassword)
|
||||
throw new BadRequestException('请填写新的账号或密码');
|
||||
const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash;
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (current.passwordHash !== user.passwordHash || current.username !== user.username)
|
||||
throw new ForbiddenException('账号已变更,请重新登录后操作');
|
||||
await tx.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { username: v.username, passwordHash },
|
||||
});
|
||||
await tx.session.deleteMany({ where: { userId: r.userId } });
|
||||
await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } });
|
||||
});
|
||||
this.auth.cookie(token, expiresAt, res);
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||
await this.db.session.update({
|
||||
|
||||
+14
-15
@@ -56,16 +56,14 @@ const record = positionMeta
|
||||
importedFromId: z.string().uuid().nullable().optional(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
revisions: z
|
||||
.array(
|
||||
revisionInput.extend({
|
||||
id: z.string().uuid(),
|
||||
sequence: z.number().int().positive().max(2147483647).optional(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
revisions: z.array(
|
||||
revisionInput.extend({
|
||||
id: z.string().uuid(),
|
||||
sequence: z.number().int().positive().max(2147483647).optional(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
const backupSchema = z
|
||||
@@ -151,7 +149,7 @@ export function validateBackup(raw: unknown) {
|
||||
archived: p.archived,
|
||||
hidden: p.hidden,
|
||||
amount: '0',
|
||||
date: p.revisions[0].date,
|
||||
date: p.revisions[0]?.date || '1900-01-01',
|
||||
});
|
||||
const sequences = new Set<number>();
|
||||
for (const r of p.revisions) {
|
||||
@@ -225,11 +223,11 @@ export function validateBackup(raw: unknown) {
|
||||
current = ordered[index];
|
||||
if (
|
||||
usedRevisions.has(revId) ||
|
||||
index < 1 ||
|
||||
index < 0 ||
|
||||
!current ||
|
||||
current.reason !== reason ||
|
||||
current.date !== t.date ||
|
||||
!new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta)
|
||||
!new Decimal(current.amount).minus(ordered[index - 1]?.amount || '0').eq(delta)
|
||||
)
|
||||
throw new BadRequestException('转账历史与双方金额不一致');
|
||||
usedRevisions.add(revId);
|
||||
@@ -296,7 +294,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
}
|
||||
return prefix.toString() === 'PK'
|
||||
? readBackupZip(path)
|
||||
: JSON.parse(await readFile(path, 'utf8'));
|
||||
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
|
||||
}
|
||||
constructor(private db: Database) {}
|
||||
private async data(
|
||||
@@ -420,7 +418,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
});
|
||||
return b;
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
@@ -494,6 +492,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
);
|
||||
data.currencies.sort();
|
||||
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
|
||||
data.schedules?.sort((a, b) => a.id.localeCompare(b.id));
|
||||
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||
}
|
||||
|
||||
@@ -71,9 +71,10 @@ export class CalendarController {
|
||||
name: p.name,
|
||||
currency: p.currency,
|
||||
date: businessDay(v.effectiveDate),
|
||||
delta: hasBefore
|
||||
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
|
||||
: new Decimal(0),
|
||||
delta:
|
||||
hasBefore || v.reason === 'scheduled_expense'
|
||||
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
|
||||
: new Decimal(0),
|
||||
notes: details ? (v as typeof v & { notes?: string }).notes || '' : '',
|
||||
};
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export class Database extends PrismaClient implements OnModuleInit, OnModuleDest
|
||||
try {
|
||||
return await this.$transaction(work, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
timeout: 30000,
|
||||
});
|
||||
} catch (error) {
|
||||
// Serializable deadlock/write conflict; retry the entire atomic operation.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { scheduleInput } from './schedules';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
credentials,
|
||||
credentialChange,
|
||||
positionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
@@ -27,6 +28,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
const bodies: Record<string, z.ZodType> = {
|
||||
'POST /api/auth/register': credentials,
|
||||
'POST /api/auth/login': credentials,
|
||||
'PATCH /api/auth/credentials': credentialChange,
|
||||
'POST /api/auth/reveal': credentials.pick({ password: true }),
|
||||
'POST /api/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { IconsService } from './icons';
|
||||
import { RatesService } from './rates';
|
||||
import { captureReplay } from './replay';
|
||||
import { changeMovement } from './transfers';
|
||||
@Controller('api')
|
||||
export class PortfolioController {
|
||||
constructor(
|
||||
@@ -229,7 +230,6 @@ export class PortfolioController {
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
return this.db.serial(async (tx) => {
|
||||
const replay = await captureReplay(tx, r.userId, [id]);
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
@@ -237,8 +237,17 @@ export class PortfolioController {
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } });
|
||||
if (!row) throw new NotFoundException('历史记录不存在');
|
||||
if (pairedReasons.includes(row.reason))
|
||||
throw new ConflictException('请在资金往来中删除完整配对记录');
|
||||
if (pairedReasons.includes(row.reason)) {
|
||||
const movement = await tx.transfer.findFirst({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }],
|
||||
},
|
||||
});
|
||||
if (!movement) throw new ConflictException('配对记录不完整,无法删除');
|
||||
return changeMovement(tx, r, movement.id);
|
||||
}
|
||||
const replay = await captureReplay(tx, r.userId, [id]);
|
||||
await tx.revision.delete({ where: { id: revisionId } });
|
||||
await replay();
|
||||
return { ok: true };
|
||||
|
||||
@@ -51,10 +51,9 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
|
||||
: undefined;
|
||||
if (pairedReasons.includes(row.reason) && !delta)
|
||||
throw new ConflictException('配对记录不完整,无法重算');
|
||||
if (delta && !balances.has(row.positionId))
|
||||
throw new ConflictException('资金操作之前必须保留一条余额记录');
|
||||
// With the initial observation deleted, remaining movements start at zero.
|
||||
const amount = delta
|
||||
? balances.get(row.positionId)!.plus(delta)
|
||||
? (balances.get(row.positionId) || new Decimal(0)).plus(delta)
|
||||
: new Decimal(row.amount.toString());
|
||||
if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
|
||||
throw new BadRequestException('修改后债务或资产金额不能为负数');
|
||||
|
||||
@@ -222,8 +222,8 @@ export class SchedulesController {
|
||||
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||
},
|
||||
});
|
||||
if (!account?.revisions[0]) throw new BadRequestException('计划账户已归档或不可用');
|
||||
const after = new Decimal(account.revisions[0].amount.toString()).minus(
|
||||
if (!account) throw new BadRequestException('计划账户已归档或不可用');
|
||||
const after = new Decimal(account.revisions[0]?.amount.toString() || '0').minus(
|
||||
plan.amount.toString(),
|
||||
);
|
||||
if (after.abs().gte('10000000000000000'))
|
||||
|
||||
+69
-47
@@ -48,6 +48,25 @@ export class TransfersController {
|
||||
revealed: r.revealed,
|
||||
};
|
||||
}
|
||||
@Get('revision/:revisionId') async byRevision(
|
||||
@Req() r: UserRequest,
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
const row = await this.db.transfer.findFirst({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }],
|
||||
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
|
||||
},
|
||||
include: {
|
||||
source: { select: { name: true, kind: true } },
|
||||
target: { select: { name: true, kind: true } },
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('资金往来记录不存在');
|
||||
const { userId, importedFromId, effectiveDate, ...v } = row;
|
||||
return { ...v, date: businessTime(effectiveDate) };
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
const v = transferInput.parse(body);
|
||||
return this.change(r, id, v);
|
||||
@@ -57,50 +76,7 @@ export class TransfersController {
|
||||
}
|
||||
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
|
||||
return this.db.serial(async (tx) => {
|
||||
const row = await tx.transfer.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: r.userId,
|
||||
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
|
||||
},
|
||||
include: { source: true, target: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('资金往来记录不存在');
|
||||
if (row.source.archived || row.target.archived)
|
||||
throw new ConflictException('请先恢复归档项目');
|
||||
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
|
||||
if (v) {
|
||||
if (
|
||||
v.sourceId !== row.sourceId ||
|
||||
v.targetId !== row.targetId ||
|
||||
v.operation !== row.operation
|
||||
)
|
||||
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
|
||||
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const effectiveDate = toBusinessDate(v.date);
|
||||
await tx.transfer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
notes: v.notes,
|
||||
effectiveDate,
|
||||
},
|
||||
});
|
||||
await tx.revision.updateMany({
|
||||
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
|
||||
data: { effectiveDate, notes: v.notes },
|
||||
});
|
||||
} else {
|
||||
await tx.transfer.delete({ where: { id } });
|
||||
await tx.revision.deleteMany({
|
||||
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
|
||||
});
|
||||
}
|
||||
await replay();
|
||||
return { ok: true };
|
||||
return changeMovement(tx, r, id, v);
|
||||
});
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
@@ -108,6 +84,52 @@ export class TransfersController {
|
||||
return this.db.serial((tx) => executeMovement(tx, r, v));
|
||||
}
|
||||
}
|
||||
export async function changeMovement(
|
||||
tx: Prisma.TransactionClient,
|
||||
r: Pick<UserRequest, 'userId' | 'revealed'>,
|
||||
id: string,
|
||||
v?: ReturnType<typeof transferInput.parse>,
|
||||
) {
|
||||
const row = await tx.transfer.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: r.userId,
|
||||
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
|
||||
},
|
||||
include: { source: true, target: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('资金往来记录不存在');
|
||||
if (row.source.archived || row.target.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
|
||||
if (v) {
|
||||
if (v.sourceId !== row.sourceId || v.targetId !== row.targetId || v.operation !== row.operation)
|
||||
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
|
||||
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const effectiveDate = toBusinessDate(v.date);
|
||||
await tx.transfer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
notes: v.notes,
|
||||
effectiveDate,
|
||||
},
|
||||
});
|
||||
await tx.revision.updateMany({
|
||||
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
|
||||
data: { effectiveDate, notes: v.notes },
|
||||
});
|
||||
} else {
|
||||
await tx.transfer.delete({ where: { id } });
|
||||
await tx.revision.deleteMany({
|
||||
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
|
||||
});
|
||||
}
|
||||
await replay();
|
||||
return { ok: true };
|
||||
}
|
||||
export async function executeMovement(
|
||||
tx: Prisma.TransactionClient,
|
||||
r: Pick<UserRequest, 'userId' | 'revealed'>,
|
||||
@@ -166,14 +188,14 @@ export async function executeMovement(
|
||||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
||||
)
|
||||
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
|
||||
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||
if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when))
|
||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
|
||||
const before = new Decimal(source.revisions[0].amount.toString());
|
||||
const before = new Decimal(source.revisions[0]?.amount.toString() || '0');
|
||||
const sourceAfter = before.plus(deltas.source);
|
||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
|
||||
const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target);
|
||||
if (target.kind === 'debt' && after.isNegative())
|
||||
throw new BadRequestException('收款或还款不能超过剩余债务');
|
||||
if (after.abs().gte('10000000000000000') || sourceAfter.abs().gte('10000000000000000'))
|
||||
|
||||
@@ -173,3 +173,12 @@ export const pairedReasons = [
|
||||
'loan_collect',
|
||||
'loan_repay',
|
||||
];
|
||||
|
||||
export const credentialChange = z
|
||||
.object({
|
||||
currentPassword: credentials.shape.password,
|
||||
username: credentials.shape.username.optional(),
|
||||
newPassword: credentials.shape.password.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => !!v.username || !!v.newPassword, '请填写新的账号或密码');
|
||||
@@ -119,7 +119,24 @@ test('record edits replay paired movements, expense deltas, anchors and calendar
|
||||
await portfolio.deleteRevision(r, a.id, expense.id);
|
||||
assert.equal(await balance(a.id), '190');
|
||||
assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0);
|
||||
await assert.rejects(portfolio.deleteRevision(r, a.id, a.revisions[0].id));
|
||||
await portfolio.deleteRevision(r, a.id, a.revisions[0].id);
|
||||
assert.equal(await balance(a.id), '-10');
|
||||
const restoredInitial = await db.revision.create({
|
||||
data: {
|
||||
positionId: a.id,
|
||||
amount: '200',
|
||||
effectiveDate: toBusinessDate('2026-09-01T00:00'),
|
||||
reason: 'initial',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
a.revisions[0].id = restoredInitial.id;
|
||||
// Replay after restoring a baseline through the correction API.
|
||||
await portfolio.correct(r, a.id, restoredInitial.id, {
|
||||
amount: '200',
|
||||
date: '2026-09-01T00:00',
|
||||
notes: '',
|
||||
});
|
||||
assert.equal(await balance(a.id), '190');
|
||||
// An observed absolute balance is an anchor, including after a moved transfer.
|
||||
await portfolio.revise(r, a.id, {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { hash } from 'bcryptjs';
|
||||
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('credentials rotate sessions; deleting paired and empty histories preserves ZIP recovery', async () => {
|
||||
const db = new PrismaClient(),
|
||||
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, {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: origin,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
data: await res.json(),
|
||||
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
|
||||
};
|
||||
}
|
||||
async function session(userId: string) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(token).digest('hex'),
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
return 'wp_session=' + token;
|
||||
}
|
||||
async function user() {
|
||||
const username = 'wp_fix_' + randomUUID(),
|
||||
password = randomBytes(18).toString('hex');
|
||||
const user = await db.user.create({
|
||||
data: { username, passwordHash: await hash(password, 12) },
|
||||
});
|
||||
ids.push(user.id);
|
||||
const cookie = await session(user.id);
|
||||
return { id: user.id, username, password, cookie };
|
||||
}
|
||||
try {
|
||||
const a = await user(),
|
||||
b = await user();
|
||||
const other = { cookie: await session(a.id) };
|
||||
assert.equal(
|
||||
(
|
||||
await call('/auth/credentials', a.cookie, 'PATCH', {
|
||||
username: b.username,
|
||||
currentPassword: 'wrong-password',
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/auth/credentials', a.cookie, 'PATCH', {
|
||||
username: b.username,
|
||||
currentPassword: a.password,
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
assert.equal((await call('/auth/me', other.cookie)).status, 200);
|
||||
const username = 'wp_changed_' + randomUUID(),
|
||||
password = randomBytes(18).toString('hex');
|
||||
const changed = await call('/auth/credentials', a.cookie, 'PATCH', {
|
||||
username,
|
||||
currentPassword: a.password,
|
||||
newPassword: password,
|
||||
});
|
||||
assert.equal(changed.status, 200);
|
||||
a.cookie = changed.cookie;
|
||||
assert.equal((await call('/auth/me', other.cookie)).status, 401);
|
||||
assert.equal((await call('/auth/me', a.cookie)).data.username, username);
|
||||
assert.equal(
|
||||
(await call('/auth/login', '', 'POST', { username: a.username, password: a.password }))
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/auth/login', '', 'POST', { username, password: a.password })).status,
|
||||
401,
|
||||
);
|
||||
assert.equal((await call('/auth/login', '', 'POST', { username, password })).status, 201);
|
||||
await db.session.updateMany({
|
||||
where: { userId: a.id },
|
||||
data: { revealUntil: new Date(Date.now() + 300000) },
|
||||
});
|
||||
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
|
||||
async function create(name: string, amount: string) {
|
||||
const result = await call('/positions', a.cookie, 'POST', {
|
||||
name,
|
||||
amount,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
currency: 'CNY',
|
||||
date: '2026-09-01T00:00',
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
return result.data.id;
|
||||
}
|
||||
const sourceId = await create('source', '100'),
|
||||
targetId = await create('target', '0'),
|
||||
emptyId = await create('empty', '1');
|
||||
const rev = (id: string) =>
|
||||
db.revision.findFirstOrThrow({ where: { positionId: id }, orderBy: { sequence: 'asc' } });
|
||||
const remove = (id: string, revisionId: string, cookie = a.cookie) =>
|
||||
call('/positions/' + id + '/revisions/' + revisionId, cookie, 'DELETE');
|
||||
assert.equal((await remove(emptyId, (await rev(emptyId)).id)).status, 200);
|
||||
const movement = await call('/transfers', a.cookie, 'POST', {
|
||||
sourceId,
|
||||
targetId,
|
||||
amount: '10',
|
||||
received: '10',
|
||||
fee: '0',
|
||||
date: '2026-09-02T00:00',
|
||||
});
|
||||
assert.equal(movement.status, 201);
|
||||
const pair = await db.transfer.findUniqueOrThrow({ where: { id: movement.data.id } });
|
||||
assert.equal((await remove(sourceId, pair.sourceRevisionId, b.cookie)).status, 404);
|
||||
assert.equal((await remove(sourceId, (await rev(sourceId)).id)).status, 200);
|
||||
assert.equal((await remove(targetId, (await rev(targetId)).id)).status, 200);
|
||||
assert.equal(
|
||||
(await call('/transfers/revision/' + pair.sourceRevisionId, a.cookie)).data.id,
|
||||
pair.id,
|
||||
);
|
||||
const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
|
||||
assert.equal(download.status, 200);
|
||||
assert.equal(download.headers.get('content-type')?.includes('application/zip'), true);
|
||||
const bytes = Buffer.from(await download.arrayBuffer());
|
||||
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 form = new FormData();
|
||||
form.append('file', new Blob([bytes]), 'backup.zip');
|
||||
const upload = await fetch(base + '/backup/upload', {
|
||||
method: 'POST',
|
||||
headers: { Cookie: b.cookie, Origin: origin },
|
||||
body: form,
|
||||
});
|
||||
assert.equal(upload.status, 201);
|
||||
const preview: any = await upload.json();
|
||||
assert.equal(
|
||||
(
|
||||
await call('/backup/import-file', a.cookie, 'POST', {
|
||||
token: preview.token,
|
||||
confirmed: true,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/backup/import-file', b.cookie, 'POST', {
|
||||
token: preview.token,
|
||||
confirmed: true,
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const restored = (await call('/positions', b.cookie)).data;
|
||||
assert.equal(restored.find((p: any) => p.name === 'source').amount, '-10');
|
||||
assert.equal(restored.find((p: any) => p.name === 'target').amount, '10');
|
||||
assert.equal(restored.find((p: any) => p.name === 'empty').amount, '0');
|
||||
await db.position.update({ where: { id: targetId }, data: { hidden: true } });
|
||||
await call('/auth/lock', a.cookie, 'POST');
|
||||
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
|
||||
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 1);
|
||||
await db.session.updateMany({
|
||||
where: { userId: a.id },
|
||||
data: { revealUntil: new Date(Date.now() + 300000) },
|
||||
});
|
||||
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 200);
|
||||
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 0);
|
||||
assert.equal(
|
||||
await db.revision.count({
|
||||
where: { id: { in: [pair.sourceRevisionId, pair.targetRevisionId] } },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
assert.equal((await call('/positions/' + sourceId, a.cookie)).data.amount, '0');
|
||||
assert.equal((await call('/positions/' + targetId, a.cookie)).data.amount, '0');
|
||||
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
|
||||
const minute = (delta = 0) =>
|
||||
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
|
||||
const plan = await call('/schedules', a.cookie, 'POST', {
|
||||
name: 'Empty account expense',
|
||||
operation: 'expense',
|
||||
sourceId,
|
||||
targetId: null,
|
||||
amount: '2',
|
||||
received: '0',
|
||||
nextAt: minute(-60000),
|
||||
intervalDays: 0,
|
||||
notes: '',
|
||||
});
|
||||
assert.equal(plan.status, 201);
|
||||
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
|
||||
const today = minute().slice(0, 10);
|
||||
const calendar = (await call('/calendar?month=' + today.slice(0, 7), a.cookie)).data;
|
||||
assert.equal(
|
||||
calendar.items.find((v: any) => v.date === minute(-60000).slice(0, 10)).expense,
|
||||
'2.00',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/transfers', a.cookie, 'POST', {
|
||||
sourceId,
|
||||
targetId,
|
||||
amount: '3',
|
||||
received: '3',
|
||||
fee: '0',
|
||||
date: minute(),
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const emptyAccountBackup = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
|
||||
assert.equal(emptyAccountBackup.status, 200);
|
||||
const emptyAccountData: any = await readBackupZip(
|
||||
Buffer.from(await emptyAccountBackup.arrayBuffer()),
|
||||
);
|
||||
assert.equal(
|
||||
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);
|
||||
const fresh = await user();
|
||||
assert.equal(
|
||||
(await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status,
|
||||
201,
|
||||
);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: ids } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user