Add GDPR privacy export and erasure coverage
This commit is contained in:
parent
e7549f3dde
commit
fabe6e341b
@ -85,6 +85,16 @@ export class AuthController {
|
|||||||
return ok(await this.authService.getProfile(userId));
|
return ok(await this.authService.getProfile(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("me/privacy-summary")
|
||||||
|
async privacySummary(@CurrentUser() userId: string) {
|
||||||
|
return ok(await this.authService.getPrivacySummary(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("me/data-export")
|
||||||
|
async dataExport(@CurrentUser() userId: string) {
|
||||||
|
return ok(await this.authService.exportPersonalData(userId));
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("profile")
|
@Patch("profile")
|
||||||
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
||||||
return ok(await this.authService.updateProfile(userId, payload));
|
return ok(await this.authService.updateProfile(userId, payload));
|
||||||
|
|||||||
@ -213,22 +213,208 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPrivacySummary(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, email: true, emailVerified: true, twoFactorEnabled: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException("User not found.");
|
||||||
|
|
||||||
|
const [
|
||||||
|
accounts,
|
||||||
|
transactions,
|
||||||
|
transactionComments,
|
||||||
|
rules,
|
||||||
|
taxReturns,
|
||||||
|
exports,
|
||||||
|
exportDownloadTokens,
|
||||||
|
apiKeys,
|
||||||
|
csvImportMappings,
|
||||||
|
notifications,
|
||||||
|
pushSubscriptions,
|
||||||
|
bills,
|
||||||
|
billPayees,
|
||||||
|
billPayments,
|
||||||
|
creditScores,
|
||||||
|
householdMemberships,
|
||||||
|
householdInvites,
|
||||||
|
householdGoals,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.account.count({ where: { userId } }),
|
||||||
|
this.prisma.transactionRaw.count({ where: { account: { userId } } }),
|
||||||
|
this.prisma.transactionComment.count({ where: { userId } }),
|
||||||
|
this.prisma.rule.count({ where: { userId } }),
|
||||||
|
this.prisma.taxReturn.count({ where: { userId } }),
|
||||||
|
this.prisma.exportLog.count({ where: { userId } }),
|
||||||
|
this.prisma.exportDownloadToken.count({ where: { userId } }),
|
||||||
|
this.prisma.apiKey.count({ where: { userId } }),
|
||||||
|
this.prisma.csvImportMapping.count({ where: { userId } }),
|
||||||
|
this.prisma.notification.count({ where: { userId } }),
|
||||||
|
this.prisma.pushSubscription.count({ where: { userId } }),
|
||||||
|
this.prisma.bill.count({ where: { userId } }),
|
||||||
|
this.prisma.billPayee.count({ where: { userId } }),
|
||||||
|
this.prisma.billPayment.count({ where: { userId } }),
|
||||||
|
this.prisma.creditScoreEntry.count({ where: { userId } }),
|
||||||
|
this.prisma.householdMember.count({ where: { userId } }),
|
||||||
|
this.prisma.householdInvite.count({ where: { OR: [{ invitedById: userId }, { acceptedById: userId }] } }),
|
||||||
|
this.prisma.householdGoal.count({ where: { createdByUserId: userId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: user,
|
||||||
|
dataCategories: {
|
||||||
|
accounts,
|
||||||
|
transactions,
|
||||||
|
transactionComments,
|
||||||
|
rules,
|
||||||
|
taxReturns,
|
||||||
|
exports,
|
||||||
|
exportDownloadTokens,
|
||||||
|
apiKeys,
|
||||||
|
csvImportMappings,
|
||||||
|
notifications,
|
||||||
|
pushSubscriptions,
|
||||||
|
bills,
|
||||||
|
billPayees,
|
||||||
|
billPayments,
|
||||||
|
creditScores,
|
||||||
|
householdMemberships,
|
||||||
|
householdInvites,
|
||||||
|
householdGoals,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
exportEndpoint: "/api/auth/me/data-export",
|
||||||
|
deletionEndpoint: "/api/auth/me",
|
||||||
|
excludedFromExport: [
|
||||||
|
"passwordHash",
|
||||||
|
"twoFactorSecret",
|
||||||
|
"session and refresh tokens",
|
||||||
|
"OAuth tokens",
|
||||||
|
"API key hashes",
|
||||||
|
"bank access tokens",
|
||||||
|
"raw bank payloads",
|
||||||
|
"stable internal account and transaction IDs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPersonalData(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
fullName: true,
|
||||||
|
phone: true,
|
||||||
|
companyName: true,
|
||||||
|
addressLine1: true,
|
||||||
|
addressLine2: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
postalCode: true,
|
||||||
|
country: true,
|
||||||
|
role: true,
|
||||||
|
emailVerified: true,
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException("User not found.");
|
||||||
|
|
||||||
|
const [accounts, transactions, rules, exports, apiKeys, taxReturns] = await Promise.all([
|
||||||
|
this.prisma.account.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
institutionName: true,
|
||||||
|
accountType: true,
|
||||||
|
mask: true,
|
||||||
|
ownershipType: true,
|
||||||
|
currentBalance: true,
|
||||||
|
availableBalance: true,
|
||||||
|
isoCurrencyCode: true,
|
||||||
|
syncStatus: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.transactionRaw.findMany({
|
||||||
|
where: { account: { userId } },
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
source: true,
|
||||||
|
ingestedAt: true,
|
||||||
|
derived: {
|
||||||
|
select: {
|
||||||
|
userCategory: true,
|
||||||
|
userNotes: true,
|
||||||
|
attribution: true,
|
||||||
|
splitMode: true,
|
||||||
|
splitMinePercent: true,
|
||||||
|
splitYoursPercent: true,
|
||||||
|
isHidden: true,
|
||||||
|
modifiedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
}),
|
||||||
|
this.prisma.rule.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { name: true, priority: true, conditions: true, actions: true, isActive: true, createdAt: true },
|
||||||
|
orderBy: { priority: "asc" },
|
||||||
|
}),
|
||||||
|
this.prisma.exportLog.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { format: true, destination: true, filters: true, rowCount: true, fileName: true, mimeType: true, fileHash: true, metadata: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
this.prisma.apiKey.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { name: true, prefix: true, scopes: true, lastUsedAt: true, revokedAt: true, expiresAt: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
this.prisma.taxReturn.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { taxYear: true, filingType: true, jurisdictions: true, status: true, summary: true, createdAt: true, updatedAt: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
subject: user,
|
||||||
|
minimizationNotes: [
|
||||||
|
"Secrets, raw bank payloads, access tokens, token hashes, and stable internal account/transaction IDs are excluded.",
|
||||||
|
],
|
||||||
|
data: { accounts, transactions, rules, exports, apiKeys, taxReturns },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async deleteAccount(userId: string) {
|
async deleteAccount(userId: string) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
|
||||||
if (!user) throw new NotFoundException("User not found.");
|
if (!user) throw new NotFoundException("User not found.");
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const [accounts, rawTransactions, rules, taxReturns] = await Promise.all([
|
const [accounts, rawTransactions, rules, taxReturns, bills, createdHouseholds] = await Promise.all([
|
||||||
tx.account.findMany({ where: { userId }, select: { id: true } }),
|
tx.account.findMany({ where: { userId }, select: { id: true } }),
|
||||||
tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }),
|
tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }),
|
||||||
tx.rule.findMany({ where: { userId }, select: { id: true } }),
|
tx.rule.findMany({ where: { userId }, select: { id: true } }),
|
||||||
tx.taxReturn.findMany({ where: { userId }, select: { id: true } }),
|
tx.taxReturn.findMany({ where: { userId }, select: { id: true } }),
|
||||||
|
tx.bill.findMany({ where: { userId }, select: { id: true } }),
|
||||||
|
tx.household.findMany({ where: { createdByUserId: userId }, select: { id: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accountIds = accounts.map((account) => account.id);
|
const accountIds = accounts.map((account) => account.id);
|
||||||
const transactionIds = rawTransactions.map((transaction) => transaction.id);
|
const transactionIds = rawTransactions.map((transaction) => transaction.id);
|
||||||
const ruleIds = rules.map((rule) => rule.id);
|
const ruleIds = rules.map((rule) => rule.id);
|
||||||
const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id);
|
const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id);
|
||||||
|
const billIds = bills.map((bill) => bill.id);
|
||||||
|
const createdHouseholdIds = createdHouseholds.map((household) => household.id);
|
||||||
|
|
||||||
await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
|
await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
|
||||||
await tx.ruleExecution.deleteMany({
|
await tx.ruleExecution.deleteMany({
|
||||||
@ -239,14 +425,55 @@ export class AuthService {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await tx.transactionComment.deleteMany({
|
||||||
|
where: { OR: [{ userId }, { rawTransactionId: { in: transactionIds } }] },
|
||||||
|
});
|
||||||
await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } });
|
await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } });
|
||||||
await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } });
|
await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } });
|
||||||
await tx.account.deleteMany({ where: { id: { in: accountIds } } });
|
await tx.account.deleteMany({ where: { id: { in: accountIds } } });
|
||||||
await tx.rule.deleteMany({ where: { userId } });
|
await tx.rule.deleteMany({ where: { userId } });
|
||||||
|
await tx.exportDownloadToken.deleteMany({ where: { userId } });
|
||||||
await tx.exportLog.deleteMany({ where: { userId } });
|
await tx.exportLog.deleteMany({ where: { userId } });
|
||||||
await tx.auditLog.deleteMany({ where: { userId } });
|
await tx.auditLog.deleteMany({ where: { userId } });
|
||||||
await tx.abuseEvent.deleteMany({ where: { userId } });
|
await tx.abuseEvent.deleteMany({ where: { userId } });
|
||||||
|
await tx.apiKey.deleteMany({ where: { userId } });
|
||||||
|
await tx.csvImportMapping.deleteMany({ where: { userId } });
|
||||||
await tx.googleConnection.deleteMany({ where: { userId } });
|
await tx.googleConnection.deleteMany({ where: { userId } });
|
||||||
|
await tx.notificationPreference.deleteMany({ where: { userId } });
|
||||||
|
await tx.notification.deleteMany({ where: { userId } });
|
||||||
|
await tx.pushSubscription.deleteMany({ where: { userId } });
|
||||||
|
await tx.billPayment.deleteMany({
|
||||||
|
where: { OR: [{ userId }, { billId: { in: billIds } }] },
|
||||||
|
});
|
||||||
|
await tx.bill.deleteMany({ where: { userId } });
|
||||||
|
await tx.billPayee.deleteMany({ where: { userId } });
|
||||||
|
await tx.creditScoreEntry.deleteMany({ where: { userId } });
|
||||||
|
await tx.householdInvite.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ invitedById: userId },
|
||||||
|
{ acceptedById: userId },
|
||||||
|
{ householdId: { in: createdHouseholdIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.householdGoal.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ createdByUserId: userId },
|
||||||
|
{ householdId: { in: createdHouseholdIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.householdMember.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId },
|
||||||
|
{ householdId: { in: createdHouseholdIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.household.deleteMany({ where: { id: { in: createdHouseholdIds } } });
|
||||||
await tx.emailVerificationToken.deleteMany({ where: { userId } });
|
await tx.emailVerificationToken.deleteMany({ where: { userId } });
|
||||||
await tx.passwordResetToken.deleteMany({ where: { userId } });
|
await tx.passwordResetToken.deleteMany({ where: { userId } });
|
||||||
await tx.refreshToken.deleteMany({ where: { userId } });
|
await tx.refreshToken.deleteMany({ where: { userId } });
|
||||||
|
|||||||
@ -88,9 +88,14 @@ export class ComplianceService {
|
|||||||
{
|
{
|
||||||
id: "P1.1",
|
id: "P1.1",
|
||||||
trustServiceCriterion: "Privacy",
|
trustServiceCriterion: "Privacy",
|
||||||
status: "partial",
|
status: "implemented",
|
||||||
control: "User erasure is available through the authenticated account deletion flow.",
|
control: "User erasure, privacy inventory, and minimized personal-data export are available through authenticated account privacy flows.",
|
||||||
evidence: ["GDPR-style account deletion endpoint is implemented; formal privacy notices and DSR operating procedures remain external controls."],
|
evidence: [
|
||||||
|
"DELETE /api/auth/me erases the account and associated user-owned application data in one transaction.",
|
||||||
|
"GET /api/auth/me/privacy-summary reports user-owned data categories and available controls.",
|
||||||
|
"GET /api/auth/me/data-export returns a minimized JSON export that excludes secrets, token hashes, raw bank payloads, and stable internal account/transaction IDs.",
|
||||||
|
"Formal privacy notices and DSR operating procedures remain external controls.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { AuthService } from "../src/auth/auth.service";
|
|||||||
const model = (order: string[]) => ({
|
const model = (order: string[]) => ({
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
updateMany: jest.fn(),
|
updateMany: jest.fn(),
|
||||||
@ -20,11 +21,26 @@ const createService = () => {
|
|||||||
account: model(order),
|
account: model(order),
|
||||||
transactionRaw: model(order),
|
transactionRaw: model(order),
|
||||||
transactionDerived: model(order),
|
transactionDerived: model(order),
|
||||||
|
transactionComment: model(order),
|
||||||
rule: model(order),
|
rule: model(order),
|
||||||
ruleExecution: model(order),
|
ruleExecution: model(order),
|
||||||
exportLog: model(order),
|
exportLog: model(order),
|
||||||
|
exportDownloadToken: model(order),
|
||||||
auditLog: model(order),
|
auditLog: model(order),
|
||||||
|
apiKey: model(order),
|
||||||
|
csvImportMapping: model(order),
|
||||||
googleConnection: 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),
|
emailVerificationToken: model(order),
|
||||||
passwordResetToken: model(order),
|
passwordResetToken: model(order),
|
||||||
refreshToken: model(order),
|
refreshToken: model(order),
|
||||||
@ -57,6 +73,24 @@ const createService = () => {
|
|||||||
|
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: { findUnique: jest.fn() },
|
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)),
|
$transaction: jest.fn((callback: (client: typeof tx) => Promise<void>) => callback(tx)),
|
||||||
};
|
};
|
||||||
const service = new AuthService(
|
const service = new AuthService(
|
||||||
@ -76,6 +110,8 @@ describe("AuthService account deletion", () => {
|
|||||||
tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]);
|
tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]);
|
||||||
tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]);
|
tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]);
|
||||||
tx.taxReturn.findMany.mockResolvedValue([{ id: "return_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({
|
await expect(service.deleteAccount("user_1")).resolves.toEqual({
|
||||||
message: "Account and associated personal data deleted.",
|
message: "Account and associated personal data deleted.",
|
||||||
@ -89,6 +125,24 @@ describe("AuthService account deletion", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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(tx.user.delete).toHaveBeenCalledWith({ where: { id: "user_1" } });
|
||||||
expect(order.slice(0, 6)).toEqual([
|
expect(order.slice(0, 6)).toEqual([
|
||||||
"taxDocument.deleteMany",
|
"taxDocument.deleteMany",
|
||||||
@ -108,6 +162,110 @@ describe("AuthService account deletion", () => {
|
|||||||
await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
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", () => {
|
describe("AuthService session-bound tokens", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user