fix(settings): bank accounts saved blank — flatten payload + harden schema to strictObject
The bank form posted {id, bank:{...}} (nested) but the route/schema read fields
at the top level; lenient z.object silently stripped the wrapper, so every field
saved as null while returning success. Flatten the payload to top level and switch
the create/update schemas to strictObject so a future nesting 400s loudly instead
of saving a blank record. Affects both create and edit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/__tests__/bank-accounts.test.ts
Normal file
152
src/__tests__/bank-accounts.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import bankAccountsRoutes from "../routes/admin/bank-accounts";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
|
||||||
|
// Fixture marker so cleanup never touches real data.
|
||||||
|
const N = "bank_accounts_test_";
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let token: string;
|
||||||
|
const createdIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(bankAccountsRoutes, {
|
||||||
|
prefix: "/api/admin/bank-accounts",
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { username: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Admin role bypasses the settings.banking permission guard.
|
||||||
|
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||||
|
if (!adminRole)
|
||||||
|
throw new Error("Test setup: no admin role found — seed the database");
|
||||||
|
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user`,
|
||||||
|
email: `${N}user@test.local`,
|
||||||
|
password_hash: "x",
|
||||||
|
first_name: "Bank",
|
||||||
|
last_name: "Test",
|
||||||
|
is_active: true,
|
||||||
|
totp_enabled: false,
|
||||||
|
role_id: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
token = jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (createdIds.length) {
|
||||||
|
await prisma.bank_accounts
|
||||||
|
.deleteMany({ where: { id: { in: createdIds } } })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { username: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = { authorization: () => `Bearer ${token}` };
|
||||||
|
|
||||||
|
describe("bank accounts — flat-body create/update persists all fields", () => {
|
||||||
|
it("creates an account from a flat top-level body and persists every field", async () => {
|
||||||
|
const payload = {
|
||||||
|
account_name: `${N}Hlavní účet`,
|
||||||
|
bank_name: "Komerční banka",
|
||||||
|
account_number: "123456789/0100",
|
||||||
|
iban: "CZ6508000000192000145399",
|
||||||
|
bic: "KOMBCZPP",
|
||||||
|
currency: "CZK",
|
||||||
|
is_default: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const post = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/bank-accounts/",
|
||||||
|
headers: { authorization: auth.authorization() },
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(post.statusCode).toBe(201);
|
||||||
|
const id = post.json().data.id as number;
|
||||||
|
expect(typeof id).toBe("number");
|
||||||
|
createdIds.push(id);
|
||||||
|
|
||||||
|
// Read back via the list endpoint and confirm NONE of the fields are blank.
|
||||||
|
const row = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||||
|
expect(row?.account_name).toBe(payload.account_name);
|
||||||
|
expect(row?.bank_name).toBe(payload.bank_name);
|
||||||
|
expect(row?.account_number).toBe(payload.account_number);
|
||||||
|
expect(row?.iban).toBe(payload.iban);
|
||||||
|
expect(row?.bic).toBe(payload.bic);
|
||||||
|
expect(row?.currency).toBe("CZK");
|
||||||
|
expect(row?.is_default).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an account from a flat body that also carries the id", async () => {
|
||||||
|
const created = await prisma.bank_accounts.create({
|
||||||
|
data: { account_name: `${N}orig`, bank_name: "Orig", currency: "CZK" },
|
||||||
|
});
|
||||||
|
createdIds.push(created.id);
|
||||||
|
|
||||||
|
// Mirrors the client payload: spread fields + id (id routes the URL/method,
|
||||||
|
// is declared in the schema, and is ignored by the route which uses :id).
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/bank-accounts/${created.id}`,
|
||||||
|
headers: { authorization: auth.authorization() },
|
||||||
|
payload: {
|
||||||
|
id: created.id,
|
||||||
|
account_name: `${N}upraveno`,
|
||||||
|
bank_name: "Nová banka",
|
||||||
|
iban: "CZ9999",
|
||||||
|
currency: "EUR",
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const row = await prisma.bank_accounts.findUnique({
|
||||||
|
where: { id: created.id },
|
||||||
|
});
|
||||||
|
expect(row?.account_name).toBe(`${N}upraveno`);
|
||||||
|
expect(row?.bank_name).toBe("Nová banka");
|
||||||
|
expect(row?.iban).toBe("CZ9999");
|
||||||
|
expect(row?.currency).toBe("EUR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the old nested {bank:{...}} body shape with 400 (no silent blank save)", async () => {
|
||||||
|
const post = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/bank-accounts/",
|
||||||
|
headers: { authorization: auth.authorization() },
|
||||||
|
payload: { bank: { account_name: `${N}nested`, bank_name: "X" } },
|
||||||
|
});
|
||||||
|
expect(post.statusCode).toBe(400);
|
||||||
|
|
||||||
|
// And nothing was persisted under that name.
|
||||||
|
const leaked = await prisma.bank_accounts.findFirst({
|
||||||
|
where: { account_name: `${N}nested` },
|
||||||
|
});
|
||||||
|
expect(leaked).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/bank-accounts/",
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -241,7 +241,7 @@ export default function CompanySettings({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bankMutation = useApiMutation<
|
const bankMutation = useApiMutation<
|
||||||
{ id?: number; bank: BankForm },
|
BankForm & { id?: number },
|
||||||
{ message?: string }
|
{ message?: string }
|
||||||
>({
|
>({
|
||||||
url: (input) =>
|
url: (input) =>
|
||||||
@@ -432,8 +432,8 @@ export default function CompanySettings({
|
|||||||
setBankSaving(true);
|
setBankSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await bankMutation.mutateAsync({
|
const result = await bankMutation.mutateAsync({
|
||||||
|
...bankForm,
|
||||||
id: editingBank ?? undefined,
|
id: editingBank ?? undefined,
|
||||||
bank: bankForm,
|
|
||||||
});
|
});
|
||||||
alert.success(result?.message || "Uloženo");
|
alert.success(result?.message || "Uloženo");
|
||||||
resetBankForm();
|
resetBankForm();
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { booleanFromForm, numberFromForm } from "./common";
|
import { booleanFromForm, numberFromForm } from "./common";
|
||||||
|
|
||||||
export const CreateBankAccountSchema = z.object({
|
// `id` is declared (optional, ignored by the routes — they use the URL param)
|
||||||
|
// only so the client may send it in the body for URL/method routing without a
|
||||||
|
// strictObject rejection. strictObject is deliberate: a malformed/nested body
|
||||||
|
// (e.g. the old `{ id, bank: {...} }` shape) now 400s loudly instead of
|
||||||
|
// silently stripping every field and saving a blank record.
|
||||||
|
export const CreateBankAccountSchema = z.strictObject({
|
||||||
|
id: z.number().int().optional(),
|
||||||
account_name: z.string().max(255).nullish(),
|
account_name: z.string().max(255).nullish(),
|
||||||
bank_name: z.string().max(255).nullish(),
|
bank_name: z.string().max(255).nullish(),
|
||||||
account_number: z.string().max(255).nullish(),
|
account_number: z.string().max(255).nullish(),
|
||||||
@@ -12,7 +18,8 @@ export const CreateBankAccountSchema = z.object({
|
|||||||
position: numberFromForm.optional().default(0),
|
position: numberFromForm.optional().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateBankAccountSchema = z.object({
|
export const UpdateBankAccountSchema = z.strictObject({
|
||||||
|
id: z.number().int().optional(),
|
||||||
account_name: z.string().max(255).nullish(),
|
account_name: z.string().max(255).nullish(),
|
||||||
bank_name: z.string().max(255).nullish(),
|
bank_name: z.string().max(255).nullish(),
|
||||||
account_number: z.string().max(255).nullish(),
|
account_number: z.string().max(255).nullish(),
|
||||||
|
|||||||
Reference in New Issue
Block a user