263 lines
8.2 KiB
TypeScript
263 lines
8.2 KiB
TypeScript
import { beforeEach, describe, expect, test } from "bun:test";
|
|
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();
|
|
}
|
|
|
|
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();
|
|
|
|
await db.insert(usersTable).values({
|
|
id,
|
|
email,
|
|
name: email.split("@")[0],
|
|
username: randomSlug("user"),
|
|
});
|
|
|
|
return id;
|
|
}
|
|
|
|
async function createOrganization(name: string) {
|
|
const id = randomId();
|
|
|
|
await db.insert(organization).values({
|
|
id,
|
|
name,
|
|
slug: randomSlug("org"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
return id;
|
|
}
|
|
|
|
async function createMembership({
|
|
userId,
|
|
organizationId,
|
|
role,
|
|
}: {
|
|
userId: string;
|
|
organizationId: string;
|
|
role: "owner" | "admin" | "member";
|
|
}) {
|
|
await db.insert(member).values({
|
|
id: randomId(),
|
|
userId,
|
|
organizationId,
|
|
role,
|
|
createdAt: new Date(),
|
|
});
|
|
}
|
|
|
|
async function createSession({
|
|
userId,
|
|
activeOrganizationId,
|
|
}: {
|
|
userId: string;
|
|
activeOrganizationId: string | null;
|
|
}) {
|
|
const id = randomId();
|
|
|
|
await db.insert(sessionsTable).values({
|
|
id,
|
|
userId,
|
|
token: randomSlug("token"),
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
activeOrganizationId,
|
|
});
|
|
|
|
return id;
|
|
}
|
|
|
|
describe("authService.cleanupUserOrganizations", () => {
|
|
beforeEach(async () => {
|
|
dropTrigger(CLEANUP_USER_ORGS_ROLLBACK_TRIGGER);
|
|
await db.delete(member);
|
|
await db.delete(sessionsTable);
|
|
await db.delete(organization);
|
|
await db.delete(usersTable);
|
|
});
|
|
|
|
test("reassigns active organization for members whose active org gets deleted", async () => {
|
|
const adminUserId = await createUser(`${randomSlug("admin")}@example.com`);
|
|
const deletedUserId = await createUser(`${randomSlug("deleted")}@example.com`);
|
|
|
|
const adminWorkspaceId = await createOrganization("Admin Workspace");
|
|
const deletedWorkspaceId = await createOrganization("Deleted Workspace");
|
|
|
|
await createMembership({ userId: adminUserId, organizationId: adminWorkspaceId, role: "owner" });
|
|
await createMembership({ userId: deletedUserId, organizationId: deletedWorkspaceId, role: "owner" });
|
|
await createMembership({ userId: adminUserId, organizationId: deletedWorkspaceId, role: "member" });
|
|
|
|
const adminSessionId = await createSession({
|
|
userId: adminUserId,
|
|
activeOrganizationId: deletedWorkspaceId,
|
|
});
|
|
|
|
await authService.cleanupUserOrganizations(deletedUserId);
|
|
|
|
const remainingSession = await db.query.sessionsTable.findFirst({
|
|
where: { id: adminSessionId },
|
|
columns: { activeOrganizationId: true },
|
|
});
|
|
const deletedWorkspace = await db.query.organization.findFirst({
|
|
where: { id: deletedWorkspaceId },
|
|
columns: { id: true },
|
|
});
|
|
|
|
expect(deletedWorkspace).toBeUndefined();
|
|
expect(remainingSession?.activeOrganizationId).toBe(adminWorkspaceId);
|
|
|
|
const deletedMembership = await db.query.member.findFirst({
|
|
where: { AND: [{ organizationId: deletedWorkspaceId }, { userId: adminUserId }] },
|
|
columns: { id: true },
|
|
});
|
|
|
|
expect(deletedMembership).toBeUndefined();
|
|
});
|
|
|
|
test("sets active organization to null when user has no other memberships", async () => {
|
|
const deletedOnlyUserId = await createUser(`${randomSlug("deleted")}@example.com`);
|
|
|
|
const deletedWorkspaceId = await createOrganization("Deleted Workspace");
|
|
|
|
await createMembership({
|
|
userId: deletedOnlyUserId,
|
|
organizationId: deletedWorkspaceId,
|
|
role: "owner",
|
|
});
|
|
|
|
const userSessionId = await createSession({
|
|
userId: deletedOnlyUserId,
|
|
activeOrganizationId: deletedWorkspaceId,
|
|
});
|
|
|
|
await authService.cleanupUserOrganizations(deletedOnlyUserId);
|
|
|
|
const deletedWorkspace = await db.query.organization.findFirst({
|
|
where: { id: deletedWorkspaceId },
|
|
columns: { id: true },
|
|
});
|
|
const updatedSession = await db.query.sessionsTable.findFirst({
|
|
where: { id: userSessionId },
|
|
columns: { activeOrganizationId: true },
|
|
});
|
|
|
|
expect(deletedWorkspace).toBeUndefined();
|
|
expect(updatedSession?.activeOrganizationId).toBeNull();
|
|
|
|
const membership = await db.query.member.findFirst({
|
|
where: { userId: deletedOnlyUserId },
|
|
columns: { id: true },
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|