ledgerone_backend/test/auth.service.spec.ts

303 lines
10 KiB
TypeScript

import { NotFoundException } from "@nestjs/common";
import { AuthService } from "../src/auth/auth.service";
const model = (order: string[]) => ({
findMany: jest.fn(),
findUnique: jest.fn(),
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
upsert: jest.fn(),
delete: jest.fn(async () => {
order.push("user.delete");
}),
deleteMany: jest.fn(async () => undefined),
});
const createService = () => {
const order: string[] = [];
const tx = {
account: model(order),
transactionRaw: model(order),
transactionDerived: model(order),
transactionComment: model(order),
rule: model(order),
ruleExecution: model(order),
exportLog: model(order),
exportDownloadToken: model(order),
auditLog: model(order),
apiKey: model(order),
csvImportMapping: model(order),
googleConnection: model(order),
notificationPreference: model(order),
notification: model(order),
pushSubscription: model(order),
billPayee: model(order),
bill: model(order),
billPayment: model(order),
creditScoreEntry: model(order),
household: model(order),
householdInvite: model(order),
householdGoal: model(order),
householdMember: model(order),
emailVerificationToken: model(order),
passwordResetToken: model(order),
refreshToken: model(order),
session: model(order),
socialAccount: model(order),
subscription: model(order),
abuseEvent: model(order),
taxReturn: model(order),
taxDocument: model(order),
user: model(order),
};
tx.taxDocument.deleteMany.mockImplementation(async () => {
order.push("taxDocument.deleteMany");
});
tx.ruleExecution.deleteMany.mockImplementation(async () => {
order.push("ruleExecution.deleteMany");
});
tx.transactionDerived.deleteMany.mockImplementation(async () => {
order.push("transactionDerived.deleteMany");
});
tx.transactionRaw.deleteMany.mockImplementation(async () => {
order.push("transactionRaw.deleteMany");
});
tx.account.deleteMany.mockImplementation(async () => {
order.push("account.deleteMany");
});
tx.rule.deleteMany.mockImplementation(async () => {
order.push("rule.deleteMany");
});
const prisma = {
user: { findUnique: jest.fn() },
account: model(order),
transactionRaw: model(order),
transactionComment: model(order),
rule: model(order),
taxReturn: model(order),
exportLog: model(order),
exportDownloadToken: model(order),
apiKey: model(order),
csvImportMapping: model(order),
notification: model(order),
pushSubscription: model(order),
bill: model(order),
billPayee: model(order),
billPayment: model(order),
creditScoreEntry: model(order),
householdMember: model(order),
householdInvite: model(order),
householdGoal: model(order),
$transaction: jest.fn((callback: (client: typeof tx) => Promise<void>) => callback(tx)),
};
const service = new AuthService(
prisma as any,
{ sign: jest.fn(), verify: jest.fn() } as any,
{ sendVerificationEmail: jest.fn(), sendPasswordResetEmail: jest.fn() } as any,
{ decrypt: jest.fn() } as any,
);
return { service, prisma, tx, order };
};
describe("AuthService account deletion", () => {
it("deletes dependent personal data before deleting the user", async () => {
const { service, prisma, tx, order } = createService();
prisma.user.findUnique.mockResolvedValue({ id: "user_1" });
tx.account.findMany.mockResolvedValue([{ id: "acct_1" }]);
tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]);
tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]);
tx.taxReturn.findMany.mockResolvedValue([{ id: "return_1" }]);
tx.bill.findMany.mockResolvedValue([{ id: "bill_1" }]);
tx.household.findMany.mockResolvedValue([{ id: "household_1" }]);
await expect(service.deleteAccount("user_1")).resolves.toEqual({
message: "Account and associated personal data deleted.",
});
expect(tx.ruleExecution.deleteMany).toHaveBeenCalledWith({
where: {
OR: [
{ ruleId: { in: ["rule_1"] } },
{ transactionId: { in: ["tx_1"] } },
],
},
});
expect(tx.transactionComment.deleteMany).toHaveBeenCalledWith({
where: { OR: [{ userId: "user_1" }, { rawTransactionId: { in: ["tx_1"] } }] },
});
expect(tx.exportDownloadToken.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
expect(tx.apiKey.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
expect(tx.csvImportMapping.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
expect(tx.billPayment.deleteMany).toHaveBeenCalledWith({
where: { OR: [{ userId: "user_1" }, { billId: { in: ["bill_1"] } }] },
});
expect(tx.householdInvite.deleteMany).toHaveBeenCalledWith({
where: {
OR: [
{ invitedById: "user_1" },
{ acceptedById: "user_1" },
{ householdId: { in: ["household_1"] } },
],
},
});
expect(tx.user.delete).toHaveBeenCalledWith({ where: { id: "user_1" } });
expect(order.slice(0, 6)).toEqual([
"taxDocument.deleteMany",
"ruleExecution.deleteMany",
"transactionDerived.deleteMany",
"transactionRaw.deleteMany",
"account.deleteMany",
"rule.deleteMany",
]);
expect(order[order.length - 1]).toBe("user.delete");
});
it("throws when the user no longer exists", async () => {
const { service, prisma } = createService();
prisma.user.findUnique.mockResolvedValue(null);
await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("returns privacy summary counts for user-owned data", async () => {
const { service, prisma } = createService();
prisma.user.findUnique.mockResolvedValue({
id: "user_1",
email: "owner@example.com",
emailVerified: true,
twoFactorEnabled: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
});
[
prisma.account,
prisma.transactionRaw,
prisma.transactionComment,
prisma.rule,
prisma.taxReturn,
prisma.exportLog,
prisma.exportDownloadToken,
prisma.apiKey,
prisma.csvImportMapping,
prisma.notification,
prisma.pushSubscription,
prisma.bill,
prisma.billPayee,
prisma.billPayment,
prisma.creditScoreEntry,
prisma.householdMember,
prisma.householdInvite,
prisma.householdGoal,
].forEach((entry, index) => entry.count.mockResolvedValue(index + 1));
await expect(service.getPrivacySummary("user_1")).resolves.toMatchObject({
subject: { id: "user_1", email: "owner@example.com" },
dataCategories: {
accounts: 1,
transactions: 2,
transactionComments: 3,
rules: 4,
taxReturns: 5,
exports: 6,
exportDownloadTokens: 7,
apiKeys: 8,
csvImportMappings: 9,
notifications: 10,
pushSubscriptions: 11,
bills: 12,
billPayees: 13,
billPayments: 14,
creditScores: 15,
householdMemberships: 16,
householdInvites: 17,
householdGoals: 18,
},
controls: {
exportEndpoint: "/api/auth/me/data-export",
deletionEndpoint: "/api/auth/me",
},
});
});
it("exports personal data without secrets or raw bank payloads", async () => {
const { service, prisma } = createService();
prisma.user.findUnique.mockResolvedValue({
id: "user_1",
email: "owner@example.com",
fullName: "Owner",
phone: null,
companyName: null,
addressLine1: null,
addressLine2: null,
city: null,
state: null,
postalCode: null,
country: null,
role: "user",
emailVerified: true,
twoFactorEnabled: true,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
});
[
prisma.account,
prisma.transactionRaw,
prisma.rule,
prisma.exportLog,
prisma.apiKey,
prisma.taxReturn,
].forEach((entry) => entry.findMany.mockResolvedValue([]));
const result = await service.exportPersonalData("user_1");
expect(result.subject).not.toHaveProperty("passwordHash");
expect(result.subject).not.toHaveProperty("twoFactorSecret");
expect(prisma.account.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: expect.not.objectContaining({ plaidAccessToken: true, tellerAccessToken: true }),
}));
expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: expect.not.objectContaining({ id: true, rawPayload: true, bankTransactionId: true }),
}));
expect(prisma.apiKey.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: expect.not.objectContaining({ keyHash: true }),
}));
expect(result.minimizationNotes.join(" ")).toContain("raw bank payloads");
});
});
describe("AuthService session-bound tokens", () => {
it("creates a server-side session and binds tokens to it", async () => {
const { service, prisma } = createService();
(prisma as any).session = {
create: jest.fn().mockResolvedValue({ id: "session_1" }),
};
(prisma as any).refreshToken = {
create: jest.fn().mockResolvedValue({ id: "refresh_1" }),
};
const jwt = (service as any).jwtService;
jwt.sign.mockReturnValue("access_token");
const result = await service.issueTokensForUser("user_1", {
ipAddress: "127.0.0.1",
userAgent: "jest",
});
expect(result).toEqual({ accessToken: "access_token", refreshToken: expect.any(String), sessionNonce: expect.any(String) });
expect((prisma as any).session.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user_1",
ipHash: expect.any(String),
userAgentHash: expect.any(String),
nonceHash: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
});
expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });
expect((prisma as any).refreshToken.create).toHaveBeenCalledWith({
data: expect.objectContaining({ userId: "user_1", sessionId: "session_1" }),
});
});
});