zerobyte/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts
Nico 3e50e37e02
chore: bump drizzle to beta-16 (#622)
chore: bump drizzle to beta-16

test: increase test coverage
2026-03-05 22:23:45 +01:00

375 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, test } from "bun:test";
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();
}
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";
}) {
const id = randomId();
await db.insert(member).values({ id, userId, organizationId, role, createdAt: new Date() });
return id;
}
async function createSession({
userId,
activeOrganizationId,
}: {
userId: string;
activeOrganizationId: string | null;
}) {
await db.insert(sessionsTable).values({
id: randomId(),
userId,
token: randomSlug("token"),
expiresAt: new Date(Date.now() + 60_000),
activeOrganizationId,
});
}
async function createAccount({ userId, providerId }: { userId: string; providerId: string }) {
const id = randomId();
await db.insert(account).values({
id,
accountId: randomSlug("account"),
providerId,
userId,
password: providerId === "credential" ? "password-hash" : null,
});
return id;
}
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);
await db.delete(organization);
await db.delete(usersTable);
});
test("deleteUserAccount removes the selected account and keeps existing sessions", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
const credentialAccountId = await createAccount({
userId,
providerId: "credential",
});
await createAccount({ userId, providerId: "oidc-acme" });
await createSession({ userId, activeOrganizationId: null });
const result = await authService.deleteUserAccount(userId, credentialAccountId);
expect(result).toEqual({ lastAccount: false, notFound: false });
const deletedAccount = await db.query.account.findFirst({
where: { id: credentialAccountId },
columns: { id: true },
});
const remainingSessions = await db.query.sessionsTable.findMany({
where: { userId },
columns: { id: true },
});
expect(deletedAccount).toBeUndefined();
expect(remainingSessions).toHaveLength(1);
});
test("deleteUserAccount returns notFound when the account does not belong to the user", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "credential" });
await createAccount({ userId, providerId: "oidc-acme" });
const result = await authService.deleteUserAccount(userId, randomId());
expect(result).toEqual({ lastAccount: false, notFound: true });
});
test("deleteUserAccount refuses to delete when it is the user's last account", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
const onlyAccountId = await createAccount({
userId,
providerId: "credential",
});
const result = await authService.deleteUserAccount(userId, onlyAccountId);
expect(result).toEqual({ lastAccount: true, notFound: false });
const existingAccount = await db.query.account.findFirst({
where: { id: onlyAccountId },
columns: { id: true },
});
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");
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 });
const result = await authService.removeOrgMember(membershipId, removedOrgId);
expect(result).toEqual({ found: true, isOwner: false });
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(fallbackOrgId);
expect(removedMembership).toBeUndefined();
});
test("removeOrgMember revokes sessions when the removed user has no fallback organization", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
const removedOrgId = await createOrganization("Removed Org");
const membershipId = await createMembership({
userId,
organizationId: removedOrgId,
role: "member",
});
await createSession({ userId, activeOrganizationId: removedOrgId });
const result = await authService.removeOrgMember(membershipId, removedOrgId);
expect(result).toEqual({ found: true, isOwner: false });
const remainingSessions = await db.query.sessionsTable.findMany({
where: { userId },
columns: { id: true },
});
const removedMembership = await db.query.member.findFirst({
where: { id: membershipId },
columns: { id: true },
});
expect(remainingSessions).toHaveLength(0);
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");
const membershipId = await createMembership({
userId,
organizationId: orgId,
role: "owner",
});
const result = await authService.removeOrgMember(membershipId, orgId);
expect(result).toEqual({ found: true, isOwner: true });
const existingMembership = await db.query.member.findFirst({
where: { id: membershipId },
columns: { id: true, role: true },
});
expect(existingMembership).toEqual({ id: membershipId, role: "owner" });
});
test("removeOrgMember returns found=false for missing membership and leaves members/sessions unchanged", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
const orgId = await createOrganization("Existing Org");
const otherOrgId = await createOrganization("Other Org");
const membershipId = await createMembership({
userId,
organizationId: orgId,
role: "member",
});
await createMembership({
userId,
organizationId: otherOrgId,
role: "member",
});
await createSession({ userId, activeOrganizationId: orgId });
const membersBefore = await db.query.member.findMany({
where: { userId },
columns: { id: true },
});
const sessionsBefore = await db.query.sessionsTable.findMany({
where: { userId },
columns: { id: true, activeOrganizationId: true },
});
const result = await authService.removeOrgMember(randomId(), orgId);
expect(result).toEqual({ found: false, isOwner: false });
const membersAfter = await db.query.member.findMany({
where: { userId },
columns: { id: true },
});
const sessionsAfter = await db.query.sessionsTable.findMany({
where: { userId },
columns: { id: true, activeOrganizationId: true },
});
const membershipStillExists = await db.query.member.findFirst({
where: { id: membershipId },
columns: { id: true },
});
expect(membersAfter).toEqual(membersBefore);
expect(sessionsAfter).toEqual(sessionsBefore);
expect(membershipStillExists).toEqual({ id: membershipId });
});
});