mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-05-24 08:28:00 -04:00
486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, test } from "bun:test";
|
|
import type { GenericEndpointContext } from "better-auth";
|
|
import { eq } from "drizzle-orm";
|
|
import { db } from "~/server/db/db";
|
|
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
|
import { ssoIntegration } from "../sso.integration";
|
|
import { ensureDefaultOrg } from "~/server/lib/auth/helpers/create-default-org";
|
|
|
|
function createMockSsoCallbackContext(providerId: string): GenericEndpointContext {
|
|
return {
|
|
path: `/sso/callback/${providerId}`,
|
|
body: {},
|
|
query: {},
|
|
headers: new Headers(),
|
|
request: new Request(`http://localhost:3000/sso/callback/${providerId}`),
|
|
params: { providerId },
|
|
method: "POST",
|
|
context: {} as GenericEndpointContext["context"],
|
|
} as unknown as GenericEndpointContext;
|
|
}
|
|
|
|
function randomId() {
|
|
return Bun.randomUUIDv7();
|
|
}
|
|
|
|
function randomSlug(prefix: string) {
|
|
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
async function createUser(email: string, username: string) {
|
|
const userId = randomId();
|
|
await db.insert(usersTable).values({
|
|
id: userId,
|
|
email,
|
|
name: username,
|
|
username,
|
|
});
|
|
return userId;
|
|
}
|
|
|
|
async function createUserWithCredentialAccount(email: string, username: string) {
|
|
const userId = await createUser(email, username);
|
|
|
|
await db.insert(account).values({
|
|
id: randomId(),
|
|
accountId: username,
|
|
providerId: "credential",
|
|
userId,
|
|
password: "test-password-hash",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
return userId;
|
|
}
|
|
|
|
describe("ssoIntegration.resolveOrgMembership", () => {
|
|
beforeEach(async () => {
|
|
await db.delete(member);
|
|
await db.delete(account);
|
|
await db.delete(invitation);
|
|
await db.delete(ssoProvider);
|
|
await db.delete(organization);
|
|
await db.delete(usersTable);
|
|
});
|
|
|
|
test("creates invited membership from SSO callback request context", async () => {
|
|
const invitedUserId = await createUser("invited@example.com", randomSlug("invited"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
const organizationId = randomId();
|
|
|
|
await db.insert(organization).values({
|
|
id: organizationId,
|
|
name: "Acme",
|
|
slug: randomSlug("acme"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-acme",
|
|
organizationId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId,
|
|
email: "invited@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId,
|
|
});
|
|
|
|
const ctx = createMockSsoCallbackContext("oidc-acme");
|
|
const membership = await ssoIntegration.resolveOrgMembership(invitedUserId, ctx);
|
|
|
|
expect(membership).not.toBeNull();
|
|
expect(membership?.organizationId).toBe(organizationId);
|
|
expect(membership?.role).toBe("member");
|
|
|
|
const updatedInvitations = await db.select().from(invitation).where(eq(invitation.organizationId, organizationId));
|
|
const updatedInvitation = updatedInvitations.find((i) => i.email === "invited@example.com");
|
|
expect(updatedInvitation?.status).toBe("accepted");
|
|
});
|
|
|
|
test("blocks SSO callback users without pending invitations", async () => {
|
|
const userId = await createUser("new-user@example.com", randomSlug("new-user"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
const organizationId = randomId();
|
|
|
|
await db.insert(organization).values({
|
|
id: organizationId,
|
|
name: "Acme",
|
|
slug: randomSlug("acme"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-acme",
|
|
organizationId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
const ctx = createMockSsoCallbackContext("oidc-acme");
|
|
await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
|
|
});
|
|
|
|
test("blocks existing users with a personal org from SSO orgs they were not invited to", async () => {
|
|
const userId = await createUser("alice@example.com", randomSlug("alice"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
|
|
const personalOrgId = randomId();
|
|
await db.insert(organization).values({
|
|
id: personalOrgId,
|
|
name: "Alice's Workspace",
|
|
slug: randomSlug("alice"),
|
|
createdAt: new Date(),
|
|
});
|
|
await db.insert(member).values({
|
|
id: randomId(),
|
|
userId,
|
|
organizationId: personalOrgId,
|
|
role: "owner",
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const ssoOrgId = randomId();
|
|
await db.insert(organization).values({
|
|
id: ssoOrgId,
|
|
name: "Acme Corp",
|
|
slug: randomSlug("acme"),
|
|
createdAt: new Date(),
|
|
});
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-acme",
|
|
organizationId: ssoOrgId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
const ctx = createMockSsoCallbackContext("oidc-acme");
|
|
await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
|
|
});
|
|
|
|
test("returns null when context is not an SSO callback", async () => {
|
|
const userId = await createUser("local-user@example.com", randomSlug("local-user"));
|
|
|
|
const result = await ssoIntegration.resolveOrgMembership(userId, null);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("blocks user whose invitation has expired", async () => {
|
|
const userId = await createUser("expired@example.com", randomSlug("expired"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
const organizationId = randomId();
|
|
|
|
await db.insert(organization).values({
|
|
id: organizationId,
|
|
name: "Acme",
|
|
slug: randomSlug("acme"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-acme",
|
|
organizationId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId,
|
|
email: "expired@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() - 60 * 60 * 1000), // expired 1 hour ago
|
|
createdAt: new Date(),
|
|
inviterId,
|
|
});
|
|
|
|
const ctx = createMockSsoCallbackContext("oidc-acme");
|
|
await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
|
|
});
|
|
|
|
test("blocks user whose invitation was already accepted", async () => {
|
|
const userId = await createUser("returning@example.com", randomSlug("returning"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
const organizationId = randomId();
|
|
|
|
await db.insert(organization).values({
|
|
id: organizationId,
|
|
name: "Acme",
|
|
slug: randomSlug("acme"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-acme",
|
|
organizationId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
// Invitation was already consumed (user was removed from org, invitation remains accepted)
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId,
|
|
email: "returning@example.com",
|
|
role: "member",
|
|
status: "accepted",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId,
|
|
});
|
|
|
|
const ctx = createMockSsoCallbackContext("oidc-acme");
|
|
await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
|
|
});
|
|
|
|
test("does not grant access to org B when invitation belongs to a different org A", async () => {
|
|
const userId = await createUser("alice@example.com", randomSlug("alice"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
|
|
const orgAId = randomId();
|
|
await db.insert(organization).values({
|
|
id: orgAId,
|
|
name: "Org A",
|
|
slug: randomSlug("org-a"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const orgBId = randomId();
|
|
await db.insert(organization).values({
|
|
id: orgBId,
|
|
name: "Org B",
|
|
slug: randomSlug("org-b"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
// SSO provider belongs to org B
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-org-b",
|
|
organizationId: orgBId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
// User has a valid pending invitation, but only for org A — not org B
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId: orgAId,
|
|
email: "alice@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId,
|
|
});
|
|
|
|
const ctx = createMockSsoCallbackContext("oidc-org-b");
|
|
await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
|
|
});
|
|
|
|
test("keeps invited org assignment when session context loses providerId after user creation", async () => {
|
|
const userId = await createUser("alice@example.com", randomSlug("alice"));
|
|
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
|
const invitedOrgId = randomId();
|
|
|
|
await db.insert(organization).values({
|
|
id: invitedOrgId,
|
|
name: "Acme Corp",
|
|
slug: randomSlug("acme"),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId: "oidc-acme",
|
|
organizationId: invitedOrgId,
|
|
userId: inviterId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
});
|
|
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId: invitedOrgId,
|
|
email: "alice@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId,
|
|
});
|
|
|
|
const user = await db.query.usersTable.findFirst({ where: { id: userId } });
|
|
if (!user) {
|
|
throw new Error("Expected user to exist");
|
|
}
|
|
|
|
await ssoIntegration.onUserCreated(user, createMockSsoCallbackContext("oidc-acme"));
|
|
|
|
const membership = await ensureDefaultOrg(userId);
|
|
|
|
expect(membership.organizationId).toBe(invitedOrgId);
|
|
|
|
const [updatedInvitation] = await db.select().from(invitation).where(eq(invitation.organizationId, invitedOrgId));
|
|
expect(updatedInvitation.status).toBe("accepted");
|
|
});
|
|
});
|
|
|
|
describe("ssoIntegration.canLinkSsoAccount", () => {
|
|
beforeEach(async () => {
|
|
await db.delete(member);
|
|
await db.delete(account);
|
|
await db.delete(invitation);
|
|
await db.delete(ssoProvider);
|
|
await db.delete(organization);
|
|
await db.delete(usersTable);
|
|
});
|
|
|
|
async function setupOrgWithProvider(providerId: string, autoLinkMatchingEmails = false) {
|
|
const orgId = randomId();
|
|
const ownerId = await createUser(`owner-${randomSlug("u")}@example.com`, randomSlug("owner"));
|
|
await db.insert(organization).values({ id: orgId, name: "Acme", slug: randomSlug("acme"), createdAt: new Date() });
|
|
await db.insert(ssoProvider).values({
|
|
id: randomId(),
|
|
providerId,
|
|
organizationId: orgId,
|
|
userId: ownerId,
|
|
issuer: "https://issuer.example.com",
|
|
domain: "example.com",
|
|
autoLinkMatchingEmails,
|
|
});
|
|
return { orgId, ownerId };
|
|
}
|
|
|
|
const autoLinkingStates = [false, true] as const;
|
|
|
|
test("allows linking for a user who is already a member of the org", async () => {
|
|
const { orgId } = await setupOrgWithProvider("oidc-acme");
|
|
const userId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
|
|
await db.insert(member).values({
|
|
id: randomId(),
|
|
userId,
|
|
organizationId: orgId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const allowed = await ssoIntegration.canLinkSsoAccount(userId, "oidc-acme");
|
|
|
|
expect(allowed).toBe(true);
|
|
});
|
|
|
|
for (const autoLinkingEnabled of autoLinkingStates) {
|
|
test(`allows linking for a new SSO user with a valid pending invitation (auto-linking ${autoLinkingEnabled ? "enabled" : "disabled"})`, async () => {
|
|
const providerId = `oidc-acme-${autoLinkingEnabled ? "on" : "off"}`;
|
|
const { orgId, ownerId } = await setupOrgWithProvider(providerId, autoLinkingEnabled);
|
|
const userId = await createUser("alice@example.com", randomSlug("alice"));
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId: orgId,
|
|
email: "alice@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId: ownerId,
|
|
});
|
|
|
|
const allowed = await ssoIntegration.canLinkSsoAccount(userId, providerId);
|
|
|
|
expect(allowed).toBe(true);
|
|
});
|
|
|
|
test(`blocks linking for an existing user account even with a pending invitation (auto-linking ${autoLinkingEnabled ? "enabled" : "disabled"})`, async () => {
|
|
const providerId = `oidc-acme-${autoLinkingEnabled ? "on" : "off"}`;
|
|
const { orgId, ownerId } = await setupOrgWithProvider(providerId, autoLinkingEnabled);
|
|
const userId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId: orgId,
|
|
email: "alice@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId: ownerId,
|
|
});
|
|
|
|
const allowed = await ssoIntegration.canLinkSsoAccount(userId, providerId);
|
|
|
|
expect(allowed).toBe(false);
|
|
});
|
|
}
|
|
|
|
test("blocks linking for a user with no membership and no invitation in the org", async () => {
|
|
await setupOrgWithProvider("oidc-acme");
|
|
const aliceId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
|
|
|
|
const allowed = await ssoIntegration.canLinkSsoAccount(aliceId, "oidc-acme");
|
|
|
|
expect(allowed).toBe(false);
|
|
});
|
|
|
|
test("blocks linking when the user only has an invitation to a different org", async () => {
|
|
const { ownerId } = await setupOrgWithProvider("oidc-acme");
|
|
const otherOrgId = randomId();
|
|
await db
|
|
.insert(organization)
|
|
.values({ id: otherOrgId, name: "Other", slug: randomSlug("other"), createdAt: new Date() });
|
|
const aliceId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId: otherOrgId,
|
|
email: "alice@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
inviterId: ownerId,
|
|
});
|
|
|
|
const allowed = await ssoIntegration.canLinkSsoAccount(aliceId, "oidc-acme");
|
|
|
|
expect(allowed).toBe(false);
|
|
});
|
|
|
|
test("blocks linking when the user's invitation to the org has expired", async () => {
|
|
const { orgId, ownerId } = await setupOrgWithProvider("oidc-acme");
|
|
const aliceId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
|
|
await db.insert(invitation).values({
|
|
id: randomId(),
|
|
organizationId: orgId,
|
|
email: "alice@example.com",
|
|
role: "member",
|
|
status: "pending",
|
|
expiresAt: new Date(Date.now() - 60 * 60 * 1000), // expired
|
|
createdAt: new Date(),
|
|
inviterId: ownerId,
|
|
});
|
|
|
|
const allowed = await ssoIntegration.canLinkSsoAccount(aliceId, "oidc-acme");
|
|
|
|
expect(allowed).toBe(false);
|
|
});
|
|
});
|