Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 10 additions & 60 deletions apps/backend/src/lib/external-db-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export async function recordExternalDbSyncDeletion(

if (target.tableName === "ProjectUser") {
assertUuid(target.projectUserId, "projectUserId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand All @@ -150,18 +150,13 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for ProjectUser, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "ContactChannel") {
assertUuid(target.projectUserId, "projectUserId");
assertUuid(target.contactChannelId, "contactChannelId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand Down Expand Up @@ -193,17 +188,12 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for ContactChannel, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "Team") {
assertUuid(target.teamId, "teamId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand All @@ -227,18 +217,13 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for Team, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "TeamMember") {
assertUuid(target.projectUserId, "projectUserId");
assertUuid(target.teamId, "teamId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand All @@ -263,17 +248,12 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for TeamMember, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "TeamMemberDirectPermission") {
assertUuid(target.permissionDbId, "permissionDbId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand Down Expand Up @@ -302,17 +282,12 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for TeamMemberDirectPermission, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "ProjectUserDirectPermission") {
assertUuid(target.permissionDbId, "permissionDbId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand Down Expand Up @@ -340,17 +315,12 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for ProjectUserDirectPermission, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "UserNotificationPreference") {
assertUuid(target.notificationPreferenceId, "notificationPreferenceId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand All @@ -377,17 +347,12 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for UserNotificationPreference, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "ProjectUserRefreshToken") {
assertUuid(target.refreshTokenId, "refreshTokenId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand All @@ -411,17 +376,12 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for ProjectUserRefreshToken, got ${insertedCount}.`
);
}
return;
}

if (target.tableName === "ProjectUserOAuthAccount") {
assertUuid(target.oauthAccountId, "oauthAccountId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand All @@ -445,11 +405,6 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for ProjectUserOAuthAccount, got ${insertedCount}.`
);
}
return;
}

Expand All @@ -458,7 +413,7 @@ export async function recordExternalDbSyncDeletion(
assertNonEmptyString(target.verificationCodeProjectId, "verificationCodeProjectId");
assertNonEmptyString(target.verificationCodeBranchId, "verificationCodeBranchId");
assertUuid(target.verificationCodeId, "verificationCodeId");
const insertedCount = await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DeletedRow" (
"id",
"tenancyId",
Expand Down Expand Up @@ -487,11 +442,6 @@ export async function recordExternalDbSyncDeletion(
FOR UPDATE OF "VerificationCode"
`);

if (insertedCount !== 1) {
throw new StackAssertionError(
`Expected to insert 1 DeletedRow entry for VerificationCode_TEAM_INVITATION, got ${insertedCount}.`
);
}
return;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,88 @@
import { it } from "../../../../../../../helpers";
import { Auth, niceBackendFetch } from "../../../../../../backend-helpers";
import { Auth, backendContext, niceBackendFetch } from "../../../../../../backend-helpers";

it("should not crash when signing out a session that was already deleted by a bulk operation", async ({ expect }) => {
// Reproduce: sign up, then admin-delete all refresh tokens (simulating a
// concurrent password change), then attempt sign-out with the stale access token.
// Before fix: 500 assertion error in recordExternalDbSyncDeletion.
// After fix: 401 REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED.
const signUpRes = await Auth.Password.signUpWithEmail({ noWaitForEmail: true });
const savedAuth = backendContext.value.userAuth ?? undefined;

// Admin updates the user's password, which bulk-deletes all refresh tokens
await niceBackendFetch(`/api/v1/users/${signUpRes.userId}`, {
accessType: "admin",
method: "PATCH",
body: { password: "completely-new-password-12345" },
});

// Try to sign out using the original access token (which still references the
// now-deleted refresh token). This should NOT throw a 500 assertion error.
const response = await niceBackendFetch("/api/v1/auth/sessions/current", {
method: "DELETE",
accessType: "client",
userAuth: savedAuth,
});
expect(response.status).not.toBe(500);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED",
"error": "Refresh token not found for this project, or the session has expired/been revoked.",
},
"headers": Headers {
"x-stack-known-error": "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED",
<some fields may have been hidden>,
},
}
`);
});

it("should not crash when deleting a session that was already deleted by a bulk operation", async ({ expect }) => {
// Same race condition but via the sessions CRUD DELETE endpoint
const signUpRes = await Auth.Password.signUpWithEmail({ noWaitForEmail: true });

// Create a second session
const newSessionRes = await niceBackendFetch("/api/v1/auth/sessions", {
accessType: "server",
method: "POST",
body: { user_id: signUpRes.userId },
});
expect(newSessionRes.status).toBe(200);

// List sessions to get the second session's ID
const listRes = await niceBackendFetch("/api/v1/auth/sessions", {
accessType: "client",
method: "GET",
query: { user_id: signUpRes.userId },
});
expect(listRes.status).toBe(200);
const nonCurrentSession = listRes.body.items.find((s: any) => !s.is_current_session);
expect(nonCurrentSession).toBeDefined();

// Admin-update user password → bulk-deletes all refresh tokens
await niceBackendFetch(`/api/v1/users/${signUpRes.userId}`, {
accessType: "admin",
method: "PATCH",
body: { password: "another-new-password-12345" },
});

// Try to delete the (now-deleted) session via CRUD endpoint
const deleteRes = await niceBackendFetch(`/api/v1/auth/sessions/${nonCurrentSession.id}`, {
accessType: "client",
method: "DELETE",
query: { user_id: signUpRes.userId },
});
expect(deleteRes.status).not.toBe(500);
expect(deleteRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": "Session not found.",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should sign out users", async ({ expect }) => {
await Auth.Password.signUpWithEmail();
Expand Down
Loading