chore: bump drizzle to beta-16 (#622)
chore: bump drizzle to beta-16 test: increase test coverage
This commit is contained in:
parent
c2ed9e3693
commit
3e50e37e02
9 changed files with 387 additions and 67 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db/db";
|
||||
import { db, sqlite } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, usersTable } from "~/server/db/schema";
|
||||
import { ensureDefaultOrg } from "../create-default-org";
|
||||
|
||||
const CREATE_DEFAULT_ORG_ROLLBACK_TRIGGER = "create_default_org_member_abort";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
|
@ -12,6 +14,14 @@ function randomSlug(prefix: string) {
|
|||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function escapeSqlLiteral(value: string) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function dropTrigger(name: string) {
|
||||
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
|
||||
}
|
||||
|
||||
async function createUser(email: string, username: string) {
|
||||
const userId = randomId();
|
||||
await db.insert(usersTable).values({
|
||||
|
|
@ -25,6 +35,7 @@ async function createUser(email: string, username: string) {
|
|||
|
||||
describe("ensureDefaultOrg", () => {
|
||||
beforeEach(async () => {
|
||||
dropTrigger(CREATE_DEFAULT_ORG_ROLLBACK_TRIGGER);
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
|
|
@ -71,4 +82,29 @@ describe("ensureDefaultOrg", () => {
|
|||
expect(membership.role).toBe("owner");
|
||||
expect(membership.organization.name).toContain("Workspace");
|
||||
});
|
||||
|
||||
test("rolls back organization creation when membership insertion fails", async () => {
|
||||
const userId = await createUser("rollback-user@example.com", randomSlug("rollback-user"));
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TRIGGER ${CREATE_DEFAULT_ORG_ROLLBACK_TRIGGER}
|
||||
BEFORE INSERT ON member
|
||||
WHEN NEW.user_id = '${escapeSqlLiteral(userId)}'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced createDefaultOrg rollback');
|
||||
END;
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(ensureDefaultOrg(userId)).rejects.toThrow("forced createDefaultOrg rollback");
|
||||
} finally {
|
||||
dropTrigger(CREATE_DEFAULT_ORG_ROLLBACK_TRIGGER);
|
||||
}
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId));
|
||||
const organizations = await db.select().from(organization);
|
||||
|
||||
expect(memberships).toHaveLength(0);
|
||||
expect(organizations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ async function createDefaultOrganizationMembership(user: User) {
|
|||
logger.debug("Creating default organization for user", { userId: user.id });
|
||||
const organizationData = await buildDefaultOrganizationData(user);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(organization).values(organizationData).run();
|
||||
|
||||
tx.insert(member)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { db, sqlite } from "~/server/db/db";
|
||||
import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { authService } from "../auth.service";
|
||||
|
||||
const DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER = "delete_user_account_abort";
|
||||
const REMOVE_ORG_MEMBER_ROLLBACK_TRIGGER = "remove_org_member_reassign_abort";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
|
@ -11,6 +14,14 @@ function randomSlug(prefix: string) {
|
|||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function escapeSqlLiteral(value: string) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function dropTrigger(name: string) {
|
||||
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
|
||||
}
|
||||
|
||||
async function createUser(email: string) {
|
||||
const id = randomId();
|
||||
|
||||
|
|
@ -83,6 +94,8 @@ async function createAccount({ userId, providerId }: { userId: string; providerI
|
|||
|
||||
describe("authService account and membership management", () => {
|
||||
beforeEach(async () => {
|
||||
dropTrigger(DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER);
|
||||
dropTrigger(REMOVE_ORG_MEMBER_ROLLBACK_TRIGGER);
|
||||
await db.delete(account);
|
||||
await db.delete(member);
|
||||
await db.delete(sessionsTable);
|
||||
|
|
@ -145,6 +158,46 @@ describe("authService account and membership management", () => {
|
|||
expect(existingAccount).toEqual({ id: onlyAccountId });
|
||||
});
|
||||
|
||||
test("deleteUserAccount leaves accounts untouched when deletion fails", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const credentialAccountId = await createAccount({
|
||||
userId,
|
||||
providerId: "credential",
|
||||
});
|
||||
const oidcAccountId = await createAccount({ userId, providerId: "oidc-acme" });
|
||||
await createSession({ userId, activeOrganizationId: null });
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TRIGGER ${DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER}
|
||||
BEFORE DELETE ON account
|
||||
WHEN OLD.id = '${escapeSqlLiteral(credentialAccountId)}'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced deleteUserAccount rollback');
|
||||
END;
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(authService.deleteUserAccount(userId, credentialAccountId)).rejects.toThrow(
|
||||
"forced deleteUserAccount rollback",
|
||||
);
|
||||
} finally {
|
||||
dropTrigger(DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER);
|
||||
}
|
||||
|
||||
const remainingAccounts = await db.query.account.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingSessions = await db.query.sessionsTable.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingAccounts).toHaveLength(2);
|
||||
expect(remainingAccounts).toEqual(expect.arrayContaining([{ id: credentialAccountId }, { id: oidcAccountId }]));
|
||||
expect(remainingSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("removeOrgMember rehomes active sessions to another organization membership", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const removedOrgId = await createOrganization("Removed Org");
|
||||
|
|
@ -205,6 +258,53 @@ describe("authService account and membership management", () => {
|
|||
expect(removedMembership).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removeOrgMember rolls back membership removal when session reassignment fails", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const removedOrgId = await createOrganization("Removed Org");
|
||||
const fallbackOrgId = await createOrganization("Fallback Org");
|
||||
const membershipId = await createMembership({
|
||||
userId,
|
||||
organizationId: removedOrgId,
|
||||
role: "member",
|
||||
});
|
||||
await createMembership({
|
||||
userId,
|
||||
organizationId: fallbackOrgId,
|
||||
role: "member",
|
||||
});
|
||||
await createSession({ userId, activeOrganizationId: removedOrgId });
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TRIGGER ${REMOVE_ORG_MEMBER_ROLLBACK_TRIGGER}
|
||||
BEFORE UPDATE OF active_organization_id ON sessions_table
|
||||
WHEN OLD.user_id = '${escapeSqlLiteral(userId)}'
|
||||
AND NEW.active_organization_id = '${escapeSqlLiteral(fallbackOrgId)}'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced removeOrgMember rollback');
|
||||
END;
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(authService.removeOrgMember(membershipId, removedOrgId)).rejects.toThrow(
|
||||
"forced removeOrgMember rollback",
|
||||
);
|
||||
} finally {
|
||||
dropTrigger(REMOVE_ORG_MEMBER_ROLLBACK_TRIGGER);
|
||||
}
|
||||
|
||||
const session = await db.query.sessionsTable.findFirst({
|
||||
where: { userId },
|
||||
columns: { activeOrganizationId: true },
|
||||
});
|
||||
const removedMembership = await db.query.member.findFirst({
|
||||
where: { id: membershipId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(session?.activeOrganizationId).toBe(removedOrgId);
|
||||
expect(removedMembership).toEqual({ id: membershipId });
|
||||
});
|
||||
|
||||
test("removeOrgMember returns isOwner=true and does not remove owner membership", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const orgId = await createOrganization("Owner Org");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { db, sqlite } from "~/server/db/db";
|
||||
import { member, organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { authService } from "../auth.service";
|
||||
|
||||
const CLEANUP_USER_ORGS_ROLLBACK_TRIGGER = "cleanup_user_orgs_final_session_abort";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
|
@ -11,6 +13,14 @@ function randomSlug(prefix: string) {
|
|||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function escapeSqlLiteral(value: string) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function dropTrigger(name: string) {
|
||||
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
|
||||
}
|
||||
|
||||
async function createUser(email: string) {
|
||||
const id = randomId();
|
||||
|
||||
|
|
@ -77,6 +87,7 @@ async function createSession({
|
|||
|
||||
describe("authService.cleanupUserOrganizations", () => {
|
||||
beforeEach(async () => {
|
||||
dropTrigger(CLEANUP_USER_ORGS_ROLLBACK_TRIGGER);
|
||||
await db.delete(member);
|
||||
await db.delete(sessionsTable);
|
||||
await db.delete(organization);
|
||||
|
|
@ -158,4 +169,95 @@ describe("authService.cleanupUserOrganizations", () => {
|
|||
|
||||
expect(membership).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sets active organization to null for affected members without a fallback organization", async () => {
|
||||
const deletedUserId = await createUser(`${randomSlug("deleted")}@example.com`);
|
||||
const affectedUserId = await createUser(`${randomSlug("affected")}@example.com`);
|
||||
const deletedWorkspaceId = await createOrganization("Deleted Workspace");
|
||||
|
||||
await createMembership({ userId: deletedUserId, organizationId: deletedWorkspaceId, role: "owner" });
|
||||
await createMembership({ userId: affectedUserId, organizationId: deletedWorkspaceId, role: "member" });
|
||||
|
||||
const affectedSessionId = await createSession({
|
||||
userId: affectedUserId,
|
||||
activeOrganizationId: deletedWorkspaceId,
|
||||
});
|
||||
|
||||
await authService.cleanupUserOrganizations(deletedUserId);
|
||||
|
||||
const updatedSession = await db.query.sessionsTable.findFirst({
|
||||
where: { id: affectedSessionId },
|
||||
columns: { activeOrganizationId: true },
|
||||
});
|
||||
const deletedWorkspace = await db.query.organization.findFirst({
|
||||
where: { id: deletedWorkspaceId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const removedMembership = await db.query.member.findFirst({
|
||||
where: { AND: [{ organizationId: deletedWorkspaceId }, { userId: affectedUserId }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(deletedWorkspace).toBeUndefined();
|
||||
expect(updatedSession?.activeOrganizationId).toBeNull();
|
||||
expect(removedMembership).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rolls back organization cleanup when session nulling fails", async () => {
|
||||
const affectedUserId = await createUser(`${randomSlug("admin")}@example.com`);
|
||||
const deletedUserId = await createUser(`${randomSlug("deleted")}@example.com`);
|
||||
|
||||
const fallbackWorkspaceId = await createOrganization("Fallback Workspace");
|
||||
const deletedWorkspaceId = await createOrganization("Deleted Workspace");
|
||||
|
||||
await createMembership({ userId: affectedUserId, organizationId: fallbackWorkspaceId, role: "owner" });
|
||||
await createMembership({ userId: deletedUserId, organizationId: deletedWorkspaceId, role: "owner" });
|
||||
await createMembership({ userId: affectedUserId, organizationId: deletedWorkspaceId, role: "member" });
|
||||
|
||||
const affectedSessionId = await createSession({
|
||||
userId: affectedUserId,
|
||||
activeOrganizationId: deletedWorkspaceId,
|
||||
});
|
||||
const deletedUserSessionId = await createSession({
|
||||
userId: deletedUserId,
|
||||
activeOrganizationId: deletedWorkspaceId,
|
||||
});
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TRIGGER ${CLEANUP_USER_ORGS_ROLLBACK_TRIGGER}
|
||||
BEFORE UPDATE OF active_organization_id ON sessions_table
|
||||
WHEN OLD.user_id = '${escapeSqlLiteral(deletedUserId)}' AND NEW.active_organization_id IS NULL
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced cleanup rollback');
|
||||
END;
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(authService.cleanupUserOrganizations(deletedUserId)).rejects.toThrow("forced cleanup rollback");
|
||||
} finally {
|
||||
dropTrigger(CLEANUP_USER_ORGS_ROLLBACK_TRIGGER);
|
||||
}
|
||||
|
||||
const deletedWorkspace = await db.query.organization.findFirst({
|
||||
where: { id: deletedWorkspaceId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const affectedSession = await db.query.sessionsTable.findFirst({
|
||||
where: { id: affectedSessionId },
|
||||
columns: { activeOrganizationId: true },
|
||||
});
|
||||
const deletedUserSession = await db.query.sessionsTable.findFirst({
|
||||
where: { id: deletedUserSessionId },
|
||||
columns: { activeOrganizationId: true },
|
||||
});
|
||||
const affectedMembership = await db.query.member.findFirst({
|
||||
where: { AND: [{ organizationId: deletedWorkspaceId }, { userId: affectedUserId }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(deletedWorkspace).toEqual({ id: deletedWorkspaceId });
|
||||
expect(affectedSession?.activeOrganizationId).toBe(deletedWorkspaceId);
|
||||
expect(deletedUserSession?.activeOrganizationId).toBe(deletedWorkspaceId);
|
||||
expect(affectedMembership).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,22 +87,26 @@ export class AuthService {
|
|||
return;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const membersInDeletedOrgs = await tx
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(inArray(member.organizationId, orgIds), ne(member.userId, userId)));
|
||||
db.transaction((tx) => {
|
||||
const membersInDeletedOrgs = tx.query.member
|
||||
.findMany({
|
||||
where: {
|
||||
AND: [{ organizationId: { in: orgIds } }, { userId: { ne: userId } }],
|
||||
},
|
||||
columns: { userId: true },
|
||||
})
|
||||
.sync();
|
||||
|
||||
const affectedUserIds = [...new Set(membersInDeletedOrgs.map((r) => r.userId))];
|
||||
|
||||
if (affectedUserIds.length > 0) {
|
||||
const memberships = await tx
|
||||
.select({
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
const memberships = tx.query.member
|
||||
.findMany({
|
||||
where: {
|
||||
userId: { in: affectedUserIds },
|
||||
},
|
||||
})
|
||||
.from(member)
|
||||
.where(inArray(member.userId, affectedUserIds));
|
||||
.sync();
|
||||
|
||||
const orgIdSet = new Set(orgIds);
|
||||
const fallbackOrgByUser = new Map<string, string | null>(affectedUserIds.map((id) => [id, null]));
|
||||
|
|
@ -114,19 +118,19 @@ export class AuthService {
|
|||
}
|
||||
|
||||
for (const [affectedUserId, fallbackOrgId] of fallbackOrgByUser) {
|
||||
await tx
|
||||
.update(sessionsTable)
|
||||
tx.update(sessionsTable)
|
||||
.set({ activeOrganizationId: fallbackOrgId })
|
||||
.where(and(eq(sessionsTable.userId, affectedUserId), inArray(sessionsTable.activeOrganizationId, orgIds)));
|
||||
.where(and(eq(sessionsTable.userId, affectedUserId), inArray(sessionsTable.activeOrganizationId, orgIds)))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
await tx.delete(organization).where(inArray(organization.id, orgIds));
|
||||
tx.delete(organization).where(inArray(organization.id, orgIds)).run();
|
||||
|
||||
await tx
|
||||
.update(sessionsTable)
|
||||
tx.update(sessionsTable)
|
||||
.set({ activeOrganizationId: null })
|
||||
.where(inArray(sessionsTable.activeOrganizationId, orgIds));
|
||||
.where(inArray(sessionsTable.activeOrganizationId, orgIds))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -216,29 +220,31 @@ export class AuthService {
|
|||
return { found: true, isOwner: true } as const;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const fallbackMembership = await tx.query.member.findFirst({
|
||||
where: {
|
||||
AND: [{ userId: targetMember.userId }, { organizationId: { ne: organizationId } }],
|
||||
},
|
||||
columns: { organizationId: true },
|
||||
});
|
||||
db.transaction((tx) => {
|
||||
const fallbackMembership = tx.query.member
|
||||
.findFirst({
|
||||
where: {
|
||||
AND: [{ userId: targetMember.userId }, { organizationId: { ne: organizationId } }],
|
||||
},
|
||||
columns: { organizationId: true },
|
||||
})
|
||||
.sync();
|
||||
|
||||
await tx.delete(member).where(eq(member.id, memberId));
|
||||
tx.delete(member).where(eq(member.id, memberId)).run();
|
||||
|
||||
if (fallbackMembership?.organizationId) {
|
||||
await tx
|
||||
.update(sessionsTable)
|
||||
tx.update(sessionsTable)
|
||||
.set({
|
||||
activeOrganizationId: fallbackMembership.organizationId,
|
||||
})
|
||||
.where(
|
||||
and(eq(sessionsTable.userId, targetMember.userId), eq(sessionsTable.activeOrganizationId, organizationId)),
|
||||
);
|
||||
)
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, targetMember.userId));
|
||||
tx.delete(sessionsTable).where(eq(sessionsTable.userId, targetMember.userId)).run();
|
||||
});
|
||||
|
||||
return { found: true, isOwner: false } as const;
|
||||
|
|
@ -261,29 +267,33 @@ export class AuthService {
|
|||
* Delete a single account for a user, refusing if it is the last one
|
||||
*/
|
||||
async deleteUserAccount(userId: string, accountId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [targetAccount] = await tx
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
|
||||
.limit(1);
|
||||
return db.transaction((tx) => {
|
||||
const targetAccount = tx.query.account
|
||||
.findFirst({
|
||||
where: {
|
||||
AND: [{ id: accountId }, { userId }],
|
||||
},
|
||||
})
|
||||
.sync();
|
||||
|
||||
if (!targetAccount) {
|
||||
return { lastAccount: false, notFound: true };
|
||||
}
|
||||
|
||||
const userAccounts = await tx.query.account.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const userAccounts = tx.query.account
|
||||
.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
})
|
||||
.sync();
|
||||
|
||||
if (userAccounts.length <= 1) {
|
||||
return { lastAccount: true, notFound: false };
|
||||
}
|
||||
|
||||
await tx.delete(account).where(and(eq(account.id, accountId), eq(account.userId, userId)));
|
||||
// Sessions cannot be tied to a specific account/provider with the current schema,
|
||||
// so keep active sessions intact when unlinking a single account.
|
||||
tx.delete(account)
|
||||
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
|
||||
.run();
|
||||
|
||||
return { lastAccount: false, notFound: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { db, sqlite } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { ssoService } from "../sso.service";
|
||||
|
||||
const DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER = "delete_sso_provider_sessions_abort";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
|
@ -11,6 +13,14 @@ function randomSlug(prefix: string) {
|
|||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function escapeSqlLiteral(value: string) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function dropTrigger(name: string) {
|
||||
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
|
||||
}
|
||||
|
||||
async function createUser(email: string) {
|
||||
const id = randomId();
|
||||
|
||||
|
|
@ -48,6 +58,7 @@ async function createSession(userId: string) {
|
|||
|
||||
describe("ssoService.deleteSsoProvider", () => {
|
||||
beforeEach(async () => {
|
||||
dropTrigger(DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER);
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(sessionsTable);
|
||||
|
|
@ -191,4 +202,61 @@ describe("ssoService.deleteSsoProvider", () => {
|
|||
expect(remainingProvider).toBeUndefined();
|
||||
expect(remainingAccounts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("deleteSsoProvider rolls back account and provider deletion when session revocation fails", async () => {
|
||||
const org = await createOrganization("Org A");
|
||||
const providerOwner = await createUser(`${randomSlug("owner")}@example.com`);
|
||||
const accountUser = await createUser(`${randomSlug("member")}@example.com`);
|
||||
|
||||
const providerId = `oidc-${randomSlug("provider")}`;
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId,
|
||||
organizationId: org,
|
||||
userId: providerOwner,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
await db.insert(account).values({
|
||||
id: randomId(),
|
||||
accountId: randomSlug("acct"),
|
||||
providerId,
|
||||
userId: accountUser,
|
||||
});
|
||||
await createSession(accountUser);
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TRIGGER ${DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER}
|
||||
BEFORE DELETE ON sessions_table
|
||||
WHEN OLD.user_id = '${escapeSqlLiteral(accountUser)}'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced deleteSsoProvider rollback');
|
||||
END;
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(ssoService.deleteSsoProvider(providerId, org)).rejects.toThrow("forced deleteSsoProvider rollback");
|
||||
} finally {
|
||||
dropTrigger(DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER);
|
||||
}
|
||||
|
||||
const remainingProvider = await db.query.ssoProvider.findFirst({
|
||||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingAccounts = await db.query.account.findMany({
|
||||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingSessions = await db.query.sessionsTable.findMany({
|
||||
where: { userId: accountUser },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingProvider).not.toBeUndefined();
|
||||
expect(remainingAccounts).toHaveLength(1);
|
||||
expect(remainingSessions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,32 +68,36 @@ export class SsoService {
|
|||
* Delete an SSO provider and its associated accounts
|
||||
*/
|
||||
async deleteSsoProvider(providerId: string, organizationId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
const provider = await tx.query.ssoProvider.findFirst({
|
||||
where: { AND: [{ providerId }, { organizationId }] },
|
||||
columns: { id: true, providerId: true },
|
||||
});
|
||||
return db.transaction((tx) => {
|
||||
const provider = tx.query.ssoProvider
|
||||
.findFirst({
|
||||
where: { AND: [{ providerId }, { organizationId }] },
|
||||
columns: { id: true, providerId: true },
|
||||
})
|
||||
.sync();
|
||||
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isReservedSsoProviderId(provider.providerId)) {
|
||||
await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id));
|
||||
tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id)).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
const affectedAccounts = await tx.query.account.findMany({
|
||||
where: { providerId: provider.providerId },
|
||||
columns: { userId: true },
|
||||
});
|
||||
const affectedAccounts = tx.query.account
|
||||
.findMany({
|
||||
where: { providerId: provider.providerId },
|
||||
columns: { userId: true },
|
||||
})
|
||||
.sync();
|
||||
const affectedUserIds = [...new Set(affectedAccounts.map((row) => row.userId))];
|
||||
|
||||
await tx.delete(account).where(eq(account.providerId, provider.providerId));
|
||||
await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id));
|
||||
tx.delete(account).where(eq(account.providerId, provider.providerId)).run();
|
||||
tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id)).run();
|
||||
|
||||
if (affectedUserIds.length > 0) {
|
||||
await tx.delete(sessionsTable).where(inArray(sessionsTable.userId, affectedUserIds));
|
||||
tx.delete(sessionsTable).where(inArray(sessionsTable.userId, affectedUserIds)).run();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
|||
8
bun.lock
8
bun.lock
|
|
@ -45,7 +45,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dither-plugin": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "^1.0.0-beta.16-ea816b6",
|
||||
"es-toolkit": "^1.45.0",
|
||||
"hono": "^4.12.4",
|
||||
"hono-openapi": "^1.3.0",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
||||
"drizzle-kit": "^1.0.0-beta.16-ea816b6",
|
||||
"lefthook": "^2.1.2",
|
||||
"lightningcss": "^1.31.1",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
|
|
@ -1166,9 +1166,9 @@
|
|||
|
||||
"dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.9-e89174b", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-Xrw3k8E2CbSZr+kqe3k5W4oxd2fbEyczjKtyGIkAq0x9Wqpa/VtAT6Mkh83sIzqG4OSN7lOoUafsDxSE/AR7RA=="],
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.9-e89174b", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-B5KR/qYMZ0JMOurK+0xi1ObpOQcgrjaC9wHUiU2eTJjLemuh2CoQIw6yur68NxZG6Xcd0b9qghUNC/78/bEfbg=="],
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dither-plugin": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "^1.0.0-beta.16-ea816b6",
|
||||
"es-toolkit": "^1.45.0",
|
||||
"hono": "^4.12.4",
|
||||
"hono-openapi": "^1.3.0",
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
||||
"drizzle-kit": "^1.0.0-beta.16-ea816b6",
|
||||
"lefthook": "^2.1.2",
|
||||
"lightningcss": "^1.31.1",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
|
|
|
|||
Loading…
Reference in a new issue