303 lines
10 KiB
TypeScript
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" }),
|
|
});
|
|
});
|
|
});
|