mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
fix: handle race condition in recordExternalDbSyncDeletion (#1466)
This commit is contained in:
parent
03e7b61308
commit
600a0d6fcf
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user