From 932f148463adbc03ca7b8218f83550f9daa84e00 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 5 Mar 2026 22:21:26 +0100 Subject: [PATCH] test: increase test coverage --- .../__tests__/create-default-org.test.ts | 38 ++++++- .../auth.account-and-membership.test.ts | 102 ++++++++++++++++- .../auth.cleanup-user-organizations.test.ts | 104 +++++++++++++++++- .../modules/sso/__tests__/sso.service.test.ts | 70 +++++++++++- 4 files changed, 310 insertions(+), 4 deletions(-) diff --git a/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts b/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts index fb93cd97..4c6a0dbc 100644 --- a/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts +++ b/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts @@ -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); + }); }); diff --git a/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts b/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts index f620fd68..f26b367b 100644 --- a/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts +++ b/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts @@ -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"); diff --git a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts index c41a2486..754945b6 100644 --- a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts +++ b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts @@ -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(); + }); }); diff --git a/app/server/modules/sso/__tests__/sso.service.test.ts b/app/server/modules/sso/__tests__/sso.service.test.ts index dd3ae8c4..126df6d3 100644 --- a/app/server/modules/sso/__tests__/sso.service.test.ts +++ b/app/server/modules/sso/__tests__/sso.service.test.ts @@ -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); + }); });