invites.test.ts•17.8 kB
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { invites, users } from "@karakeep/db/schema";
import type { CustomTestContext } from "../testUtils";
import { defaultBeforeEach, getApiCaller } from "../testUtils";
// Mock server config with email settings
vi.mock("@karakeep/shared/config", async (original) => {
const mod = (await original()) as typeof import("@karakeep/shared/config");
return {
...mod,
default: {
...mod.default,
email: {
smtp: {
host: "test-smtp.example.com",
port: 587,
secure: false,
user: "test@example.com",
password: "test-password",
from: "test@example.com",
},
},
},
};
});
// Mock email functions
vi.mock("../email", () => ({
sendInviteEmail: vi.fn().mockResolvedValue(undefined),
}));
beforeEach<CustomTestContext>(defaultBeforeEach(false));
describe("Invites Router", () => {
test<CustomTestContext>("admin can create invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
expect(invite.email).toBe("newuser@test.com");
expect(invite.id).toBeDefined();
// Verify the invite was created in the database
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
expect(dbInvite?.invitedBy).toBe(admin.id);
expect(dbInvite?.usedAt).toBeNull();
expect(dbInvite?.token).toBeDefined();
});
test<CustomTestContext>("non-admin cannot create invite", async ({
db,
unauthedAPICaller,
}) => {
await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const user = await unauthedAPICaller.users.create({
name: "Regular User",
email: "user@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const userCaller = getApiCaller(db, user.id, user.email);
await expect(() =>
userCaller.invites.create({
email: "newuser@test.com",
}),
).rejects.toThrow(/FORBIDDEN/);
});
test<CustomTestContext>("cannot invite existing user", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
await unauthedAPICaller.users.create({
name: "Existing User",
email: "existing@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
await expect(() =>
adminCaller.invites.create({
email: "existing@test.com",
}),
).rejects.toThrow(/User with this email already exists/);
});
test<CustomTestContext>("cannot create duplicate pending invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
await adminCaller.invites.create({
email: "newuser@test.com",
});
await expect(() =>
adminCaller.invites.create({
email: "newuser@test.com",
}),
).rejects.toThrow(/An active invite for this email already exists/);
});
test<CustomTestContext>("admin can list invites", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
await adminCaller.invites.create({
email: "user1@test.com",
});
await adminCaller.invites.create({
email: "user2@test.com",
});
const result = await adminCaller.invites.list();
expect(result.invites).toHaveLength(2);
expect(
result.invites.find((i) => i.email === "user1@test.com"),
).toBeTruthy();
expect(
result.invites.find((i) => i.email === "user2@test.com"),
).toBeTruthy();
});
test<CustomTestContext>("non-admin cannot list invites", async ({
db,
unauthedAPICaller,
}) => {
await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const user = await unauthedAPICaller.users.create({
name: "Regular User",
email: "user@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const userCaller = getApiCaller(db, user.id, user.email);
await expect(() => userCaller.invites.list()).rejects.toThrow(/FORBIDDEN/);
});
test<CustomTestContext>("can get invite by token", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
// Get the token from the database since it's not returned by create
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
const retrievedInvite = await unauthedAPICaller.invites.get({
token: dbInvite!.token,
});
expect(retrievedInvite.email).toBe("newuser@test.com");
expect(retrievedInvite.expired).toBe(false);
});
test<CustomTestContext>("cannot get invite with invalid token", async ({
unauthedAPICaller,
}) => {
await expect(() =>
unauthedAPICaller.invites.get({
token: "invalid-token",
}),
).rejects.toThrow(/Invite not found/);
});
test<CustomTestContext>("can get invite by token", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
const result = await unauthedAPICaller.invites.get({
token: dbInvite!.token,
});
expect(result.email).toBe("newuser@test.com");
expect(result.expired).toBe(false);
});
test<CustomTestContext>("cannot get used invite (deleted)", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
// Accept the invite (which deletes it)
await unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "New User",
password: "newpass123",
});
// Try to get the invite again - should fail
await expect(() =>
unauthedAPICaller.invites.get({
token: dbInvite!.token,
}),
).rejects.toThrow(/Invite not found or has been used/);
});
test<CustomTestContext>("can accept valid invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
const newUser = await unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "New User",
password: "newpass123",
});
expect(newUser.name).toBe("New User");
expect(newUser.email).toBe("newuser@test.com");
// Verify invite was deleted
const deletedInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
expect(deletedInvite).toBeUndefined();
});
test<CustomTestContext>("can accept valid invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
const result = await unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "New User",
password: "newpass123",
});
expect(result.email).toBe("newuser@test.com");
expect(result.name).toBe("New User");
});
test<CustomTestContext>("cannot accept used invite (deleted)", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
// Accept the invite first time
await unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "New User",
password: "newpass123",
});
// Try to accept again - should fail because invite is deleted
await expect(() =>
unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "Another User",
password: "anotherpass123",
}),
).rejects.toThrow(/Invite not found or has been used/);
});
test<CustomTestContext>("admin can revoke invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const result = await adminCaller.invites.revoke({
inviteId: invite.id,
});
expect(result.success).toBe(true);
// Verify the invite is deleted
const revokedInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
expect(revokedInvite).toBeUndefined();
});
test<CustomTestContext>("non-admin cannot revoke invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const user = await unauthedAPICaller.users.create({
name: "Regular User",
email: "user@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const userCaller = getApiCaller(db, user.id, user.email);
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
await expect(() =>
userCaller.invites.revoke({
inviteId: invite.id,
}),
).rejects.toThrow(/FORBIDDEN/);
});
test<CustomTestContext>("admin can resend invite", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const originalDbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
await new Promise((resolve) => setTimeout(resolve, 10));
const resentInvite = await adminCaller.invites.resend({
inviteId: invite.id,
});
expect(resentInvite.email).toBe("newuser@test.com");
expect(resentInvite.id).toBe(originalDbInvite!.id);
// Verify token was updated in database
const updatedDbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
expect(updatedDbInvite?.token).not.toBe(originalDbInvite?.token);
});
test<CustomTestContext>("cannot resend used invite (deleted)", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
// Accept the invite (which deletes it)
await unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "New User",
password: "newpass123",
});
await expect(() =>
adminCaller.invites.resend({
inviteId: invite.id,
}),
).rejects.toThrow(/Invite not found/);
});
test<CustomTestContext>("invite creation works without expiration", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
expect(invite.email).toBe("newuser@test.com");
expect(invite.id).toBeDefined();
});
test<CustomTestContext>("invite includes inviter information", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const result = await adminCaller.invites.list();
const createdInvite = result.invites.find((i) => i.id === invite.id);
expect(createdInvite?.invitedBy.id).toBe(admin.id);
expect(createdInvite?.invitedBy.name).toBe("Admin User");
expect(createdInvite?.invitedBy.email).toBe("admin@test.com");
});
test<CustomTestContext>("all invites create user role", async ({
db,
unauthedAPICaller,
}) => {
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
const invite = await adminCaller.invites.create({
email: "newuser@test.com",
});
const dbInvite = await db.query.invites.findFirst({
where: eq(invites.id, invite.id),
});
const newUser = await unauthedAPICaller.invites.accept({
token: dbInvite!.token,
name: "New User",
password: "userpass123",
});
const user = await db.query.users.findFirst({
where: eq(users.email, newUser.email),
});
expect(user?.role).toBe("user");
});
test<CustomTestContext>("email sending is called during invite creation", async ({
db,
unauthedAPICaller,
}) => {
// Mock the email module
const mockSendInviteEmail = vi.fn().mockResolvedValue(undefined);
vi.doMock("../email", () => ({
sendInviteEmail: mockSendInviteEmail,
}));
const admin = await unauthedAPICaller.users.create({
name: "Admin User",
email: "admin@test.com",
password: "pass1234",
confirmPassword: "pass1234",
});
const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
await adminCaller.invites.create({
email: "newuser@test.com",
});
// Note: In a real test environment, we'd need to properly mock the email module
// This test demonstrates the structure but may not actually verify the mock call
// due to how the module is imported in the router
});
});