fix: handle race condition in recordExternalDbSyncDeletion (#1466)

This commit is contained in:
Konsti Wohlwend 2026-05-21 17:02:20 -07:00 committed by GitHub
parent 03e7b61308
commit 600a0d6fcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 94 additions and 61 deletions

View File

@ -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",
@ -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",
@ -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",
@ -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",
@ -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",
@ -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",
@ -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",
@ -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",
@ -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",
@ -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;
}
@ -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",
@ -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;
}
}

View File

@ -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();