* feat: oidc feat: organization switcher refactor: org context feat: invitations GLM * feat: link current account * refactor: own page for sso registration * feat: per-user account management * refactor: code style * refactor: user existing check * refactor: restrict provider configuration to super admins only * refactor: cleanup / pr review * chore: fix lint issues * chore: pr feedbacks * test(e2e): automated tests for OIDC * fix: check url first for sso provider identification * fix: prevent oidc provider to be named "credential"
274 lines
7.9 KiB
TypeScript
274 lines
7.9 KiB
TypeScript
import { test, describe, beforeEach, expect } from "bun:test";
|
|
import { convertLegacyUserOnFirstLogin } from "../convert-legacy-user";
|
|
import { db } from "~/server/db/db";
|
|
import { usersTable, account, organization, member } from "~/server/db/schema";
|
|
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
|
|
|
describe("convertLegacyUserOnFirstLogin", () => {
|
|
beforeEach(async () => {
|
|
await db.delete(member);
|
|
await db.delete(account);
|
|
await db.delete(organization);
|
|
await db.delete(usersTable);
|
|
});
|
|
|
|
const createContext = (path: string, body: Record<string, string>) => ({ path, body }) as AuthMiddlewareContext;
|
|
|
|
test("should return early for non-sign-in paths", async () => {
|
|
const ctx = createContext("/sign-up", { username: "test", password: "test" });
|
|
const result = await convertLegacyUserOnFirstLogin(ctx);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
test("should do nothing when no legacy user exists", async () => {
|
|
await db.insert(usersTable).values({
|
|
id: crypto.randomUUID(),
|
|
username: "existing-user",
|
|
email: "existing@test.com",
|
|
name: "Existing User",
|
|
passwordHash: null,
|
|
});
|
|
|
|
const ctx = createContext("/sign-in/username", {
|
|
username: "existing-user",
|
|
password: "password123",
|
|
});
|
|
|
|
const result = await convertLegacyUserOnFirstLogin(ctx);
|
|
expect(result).toBeUndefined();
|
|
|
|
// Verify user still exists with no account
|
|
const user = await db.query.usersTable.findFirst({
|
|
where: { username: "existing-user" },
|
|
});
|
|
expect(user).toBeDefined();
|
|
expect(user?.passwordHash).toBeNull();
|
|
});
|
|
|
|
test("should throw UnauthorizedError for invalid password", async () => {
|
|
const hashedPassword = await Bun.password.hash("correct-password");
|
|
|
|
// Create a legacy user with a hashed password
|
|
const userId = crypto.randomUUID();
|
|
await db.insert(usersTable).values({
|
|
id: userId,
|
|
username: "legacy-user",
|
|
email: "legacy@test.com",
|
|
name: "Legacy User",
|
|
passwordHash: hashedPassword,
|
|
});
|
|
|
|
const ctx = createContext("/sign-in/username", {
|
|
username: "legacy-user",
|
|
password: "wrong-password",
|
|
});
|
|
|
|
await expect(convertLegacyUserOnFirstLogin(ctx)).rejects.toThrow("Invalid credentials");
|
|
|
|
// Verify user still exists (not migrated)
|
|
const user = await db.query.usersTable.findFirst({
|
|
where: { username: "legacy-user" },
|
|
});
|
|
expect(user).toBeDefined();
|
|
expect(user?.passwordHash).toBe(hashedPassword);
|
|
});
|
|
|
|
test("should migrate legacy user with existing organization membership", async () => {
|
|
const password = "correct-password";
|
|
const hashedPassword = await Bun.password.hash(password);
|
|
|
|
// Create legacy user
|
|
const userId = crypto.randomUUID();
|
|
await db.insert(usersTable).values({
|
|
id: userId,
|
|
username: "legacy-with-org",
|
|
email: "legacy-org@test.com",
|
|
name: "Legacy With Org",
|
|
passwordHash: hashedPassword,
|
|
role: "admin",
|
|
});
|
|
|
|
// Create organization and membership
|
|
const orgId = crypto.randomUUID();
|
|
await db.insert(organization).values({
|
|
id: orgId,
|
|
name: "Legacy Org",
|
|
slug: "legacy-org",
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const membershipId = crypto.randomUUID();
|
|
await db.insert(member).values({
|
|
id: membershipId,
|
|
userId: userId,
|
|
organizationId: orgId,
|
|
role: "owner",
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const ctx = createContext("/sign-in/username", {
|
|
username: "legacy-with-org",
|
|
password,
|
|
});
|
|
|
|
await convertLegacyUserOnFirstLogin(ctx);
|
|
|
|
// Verify old user is deleted
|
|
const oldUser = await db.query.usersTable.findFirst({
|
|
where: { id: userId },
|
|
});
|
|
expect(oldUser).toBeUndefined();
|
|
|
|
// Verify new user exists
|
|
const newUser = await db.query.usersTable.findFirst({
|
|
where: { username: "legacy-with-org" },
|
|
});
|
|
expect(newUser).toBeDefined();
|
|
expect(newUser?.email).toBe("legacy-org@test.com");
|
|
expect(newUser?.name).toBe("Legacy With Org");
|
|
expect(newUser?.role).toBe("admin");
|
|
expect(newUser?.passwordHash).toBeNull();
|
|
expect(newUser?.id).not.toBe(userId);
|
|
|
|
// Verify account was created
|
|
const userAccount = await db.query.account.findFirst({
|
|
where: { userId: newUser?.id },
|
|
});
|
|
expect(userAccount).toBeDefined();
|
|
expect(userAccount?.providerId).toBe("credential");
|
|
expect(userAccount?.accountId).toBe("legacy-with-org");
|
|
expect(userAccount?.password).toBeDefined();
|
|
|
|
// Verify membership was migrated
|
|
const memberships = await db.query.member.findMany({
|
|
where: { userId: newUser?.id },
|
|
});
|
|
expect(memberships.length).toBe(1);
|
|
expect(memberships[0].organizationId).toBe(orgId);
|
|
expect(memberships[0].role).toBe("owner");
|
|
});
|
|
|
|
test("should migrate legacy user and create new organization when no membership exists", async () => {
|
|
const password = "correct-password";
|
|
const hashedPassword = await Bun.password.hash(password);
|
|
|
|
// Create legacy user without organization membership
|
|
const userId = crypto.randomUUID();
|
|
await db.insert(usersTable).values({
|
|
id: userId,
|
|
username: "legacy-no-org",
|
|
email: "legacy-noorg@test.com",
|
|
name: "Legacy No Org",
|
|
passwordHash: hashedPassword,
|
|
hasDownloadedResticPassword: true,
|
|
});
|
|
|
|
const ctx = createContext("/sign-in/username", {
|
|
username: "legacy-no-org",
|
|
password,
|
|
});
|
|
|
|
await convertLegacyUserOnFirstLogin(ctx);
|
|
|
|
// Verify old user is deleted
|
|
const oldUser = await db.query.usersTable.findFirst({
|
|
where: { id: userId },
|
|
});
|
|
expect(oldUser).toBeUndefined();
|
|
|
|
// Verify new user exists
|
|
const newUser = await db.query.usersTable.findFirst({
|
|
where: { username: "legacy-no-org" },
|
|
});
|
|
expect(newUser).toBeDefined();
|
|
expect(newUser?.email).toBe("legacy-noorg@test.com");
|
|
expect(newUser?.hasDownloadedResticPassword).toBe(true);
|
|
expect(newUser?.role).toBe("admin");
|
|
|
|
// Verify account was created
|
|
const userAccount = await db.query.account.findFirst({
|
|
where: { userId: newUser?.id },
|
|
});
|
|
expect(userAccount).toBeDefined();
|
|
|
|
// Verify new organization was created
|
|
const memberships = await db.query.member.findMany({
|
|
where: { userId: newUser?.id },
|
|
});
|
|
expect(memberships.length).toBe(1);
|
|
expect(memberships[0].role).toBe("owner");
|
|
|
|
const org = await db.query.organization.findFirst({
|
|
where: { id: memberships[0].organizationId },
|
|
});
|
|
expect(org).toBeDefined();
|
|
expect(org?.name).toBe("Legacy No Org's Workspace");
|
|
expect(org?.metadata).toBeDefined();
|
|
});
|
|
|
|
test("should be case-insensitive for username", async () => {
|
|
const password = "correct-password";
|
|
const hashedPassword = await Bun.password.hash(password);
|
|
|
|
const userId = crypto.randomUUID();
|
|
await db.insert(usersTable).values({
|
|
id: userId,
|
|
username: "legacy-user",
|
|
email: "legacy@test.com",
|
|
name: "Legacy User",
|
|
passwordHash: hashedPassword,
|
|
});
|
|
|
|
// Try login with uppercase username
|
|
const ctx = createContext("/sign-in/username", {
|
|
username: "LEGACY-USER",
|
|
password,
|
|
});
|
|
|
|
await convertLegacyUserOnFirstLogin(ctx);
|
|
|
|
// Verify migration happened
|
|
const oldUser = await db.query.usersTable.findFirst({
|
|
where: { id: userId },
|
|
});
|
|
expect(oldUser).toBeUndefined();
|
|
|
|
const newUser = await db.query.usersTable.findFirst({
|
|
where: { username: "legacy-user" },
|
|
});
|
|
expect(newUser).toBeDefined();
|
|
});
|
|
|
|
test("should trim whitespace from username", async () => {
|
|
const password = "correct-password";
|
|
const hashedPassword = await Bun.password.hash(password);
|
|
|
|
const userId = crypto.randomUUID();
|
|
await db.insert(usersTable).values({
|
|
id: userId,
|
|
username: "legacy-user",
|
|
email: "legacy@test.com",
|
|
name: "Legacy User",
|
|
passwordHash: hashedPassword,
|
|
});
|
|
|
|
// Try login with whitespace
|
|
const ctx = createContext("/sign-in/username", {
|
|
username: " legacy-user ",
|
|
password,
|
|
});
|
|
|
|
await convertLegacyUserOnFirstLogin(ctx);
|
|
|
|
// Verify migration happened
|
|
const oldUser = await db.query.usersTable.findFirst({
|
|
where: { id: userId },
|
|
});
|
|
expect(oldUser).toBeUndefined();
|
|
|
|
const newUser = await db.query.usersTable.findFirst({
|
|
where: { username: "legacy-user" },
|
|
});
|
|
expect(newUser).toBeDefined();
|
|
});
|
|
});
|