Files
publisher-dashboard/apps/api-server/src/__tests__/e2e/me.test.ts
igm 665092464a Fix all linter errors
- Remove unused biome suppression comment in completions.ts
- Remove unnecessary if condition in execute-bootstrap.test.ts
- Add eslint-disable comments for any type assertions in client.test.ts
- Add eslint-disable comments for expect().rejects patterns
- Fix template literal number expression with toString()
- Fix error handling in test-db.ts to avoid object stringify

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:30:00 +08:00

2359 lines
74 KiB
TypeScript

/**
* End-to-end tests for Me procedures (user profile and account management)
*
* These tests use a real PostgreSQL database to test:
* - me.get - get user profile
* - me.authStatus - get authentication status
* - me.setupProfile - initial profile setup
* - me.updateProfile - update profile fields
* - me.setPassword - set/change password
* - me.delete - delete account
* - me.sessions.list - list all sessions
* - me.sessions.revoke - revoke a session
* - me.sessions.revokeAll - revoke all sessions except current
* - me.devices.getInfo - get current device info
* - me.devices.trust - trust current device
* - me.devices.listTrusted - list trusted devices
* - me.devices.untrust - untrust a device
* - me.devices.revokeAll - revoke all trusted devices
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
uniqueTestId,
withTestTransaction,
} from "@reviq/test-helpers";
import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js";
/** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
/** API token expiry duration: 1 year in milliseconds */
const API_TOKEN_EXPIRY_MS = 365 * 24 * 60 * 60 * 1000;
/** API token expiry duration in cascade test: 1 day in milliseconds */
const ONE_DAY_MS = 86400000;
/**
* Create an API context with optional authentication
*/
function createAPIContext(
db: Kysely<Database>,
options?: {
sessionToken?: string;
apiKey?: string;
deviceFingerprint?: string;
},
): APIContext {
const reqHeaders = new Headers();
const cookies: string[] = [];
if (options?.sessionToken) {
cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`);
}
if (options?.deviceFingerprint) {
cookies.push(
`${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`,
);
}
if (cookies.length > 0) {
reqHeaders.set("cookie", cookies.join("; "));
}
if (options?.apiKey) {
reqHeaders.set("x-api-key", options.apiKey);
}
return {
db,
origin: TEST_RP.origin,
allowedOrigins: [...TEST_RP.allowedOrigins],
rpName: TEST_RP.rpName,
reqHeaders,
resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
};
}
/**
* Create a real session in the database and return the token and session ID
*/
async function createSession(
db: Kysely<Database>,
userId: number,
options?: { ipAddress?: string; userAgent?: string },
): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
const result = await db
.insertInto("sessions")
.values({
user_id: userId,
token_hash: tokenHashValue,
ip_address: options?.ipAddress ?? "127.0.0.1",
user_agent: options?.userAgent ?? "test-agent",
expires_at: expiresAt,
trusted_mode: false,
})
.returning("id")
.executeTakeFirstOrThrow();
return { token, sessionId: Number(result.id) };
}
/**
* Create a device in the database and return the fingerprint
*/
async function createDevice(
db: Kysely<Database>,
userId: number,
options?: {
fingerprint?: string;
isTrusted?: boolean;
name?: string;
userAgent?: string;
},
): Promise<{ fingerprint: string; deviceId: number }> {
const fingerprint = options?.fingerprint ?? `test-fp-${uniqueTestId()}`;
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: fingerprint,
is_trusted: options?.isTrusted ?? false,
name: options?.name ?? null,
user_agent: options?.userAgent ?? "Mozilla/5.0 Test Browser",
ip_address: "127.0.0.1",
last_used_at: new Date(),
})
.returning("id")
.executeTakeFirstOrThrow();
return { fingerprint, deviceId: Number(result.id) };
}
/**
* Create an API token in the database and return the token
*/
async function createApiToken(
db: Kysely<Database>,
userId: number,
): Promise<{ token: string; name: string }> {
const token = `test-api-token-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
await db
.insertInto("api_tokens")
.values({
user_id: userId,
token_hash: tokenHashValue,
name: "Test API Token",
expires_at: expiresAt,
})
.execute();
return { token, name: "Test API Token" };
}
describeE2E("me", () => {
beforeAll(async () => {
await initTestDb();
});
// =============================================================================
// authMiddleware tests (base.ts)
// =============================================================================
describe("authMiddleware", () => {
test("rejects request with no session or API key", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const context = createAPIContext(db); // No auth
await expect(
call(router.me.get, undefined, { context }),
).rejects.toThrow("No session or API key");
});
});
test("rejects request with invalid session token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Use a token that doesn't exist in the database
const context = createAPIContext(db, {
sessionToken: "invalid-token-xyz",
});
await expect(
call(router.me.get, undefined, { context }),
).rejects.toThrow("Invalid or expired token");
});
});
test("rejects request with invalid API key", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Use an API key that doesn't exist in the database
const context = createAPIContext(db, { apiKey: "invalid-api-key-xyz" });
await expect(
call(router.me.get, undefined, { context }),
).rejects.toThrow("Invalid or expired token");
});
});
// Note: "user not found after session lookup" (lines 100-102, 144-147 in base.ts)
// cannot be tested due to FK cascade constraints - deleting a user cascades to
// delete their sessions/api_tokens, making orphaned sessions impossible.
// This is defensive code that protects against data inconsistencies.
test("rejects request with expired session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "expired@example.com" });
// Create an expired session
const token = `expired-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
await db
.insertInto("sessions")
.values({
user_id: user.id,
token_hash: tokenHashValue,
expires_at: new Date(Date.now() - 1000), // Already expired
trusted_mode: false,
})
.execute();
const context = createAPIContext(db, { sessionToken: token });
await expect(
call(router.me.get, undefined, { context }),
).rejects.toThrow("Invalid or expired token");
});
});
test("rejects request with revoked session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "revoked@example.com" });
// Create a revoked session
const token = `revoked-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
await db
.insertInto("sessions")
.values({
user_id: user.id,
token_hash: tokenHashValue,
expires_at: new Date(Date.now() + SESSION_EXPIRY_MS),
revoked_at: new Date(), // Revoked
trusted_mode: false,
})
.execute();
const context = createAPIContext(db, { sessionToken: token });
await expect(
call(router.me.get, undefined, { context }),
).rejects.toThrow("Invalid or expired token");
});
});
});
describe("me.get", () => {
test("returns user profile with all fields", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "test@example.com",
displayName: "Test User",
fullName: "Test Full Name",
emailVerifiedAt: new Date(),
});
// Update with phone number
await db
.updateTable("users")
.set({ phone_number: "+1234567890" })
.where("id", "=", user.id)
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(router.me.get, undefined, { context });
expect(result.id).toBe(user.id);
expect(result.email).toBe("test@example.com");
expect(result.displayName).toBe("Test User");
expect(result.fullName).toBe("Test Full Name");
expect(result.phoneNumber).toBe("+1234567890");
expect(result.emailVerified).toBe(true);
expect(result.needsSetup).toBe(false);
expect(result.isSuperuser).toBe(false);
expect(result.hasPassword).toBe(false);
});
});
test("returns needsSetup=true when displayName is null", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "newuser@example.com",
displayName: undefined,
});
// Set display_name to null explicitly
await db
.updateTable("users")
.set({ display_name: null })
.where("id", "=", user.id)
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(router.me.get, undefined, { context });
expect(result.needsSetup).toBe(true);
expect(result.displayName).toBeNull();
});
});
test("returns hasPassword=true when user has password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const passwordHash = await hashPassword("securePassword123!");
const user = await createTestUser(db, {
email: "withpassword@example.com",
passwordHash,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(router.me.get, undefined, { context });
expect(result.hasPassword).toBe(true);
});
});
test("returns isSuperuser=true for superuser", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "admin@example.com",
isSuperuser: true,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(router.me.get, undefined, { context });
expect(result.isSuperuser).toBe(true);
});
});
});
describe("me.authStatus", () => {
test("returns session auth info", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "session@example.com",
displayName: "Session User",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(router.me.authStatus, undefined, { context });
expect(result.user.email).toBe("session@example.com");
expect(result.user.displayName).toBe("Session User");
expect(result.auth.method).toBe("session");
if (result.auth.method === "session") {
expect(result.auth.expiresAt).toBeInstanceOf(Date);
}
});
});
test("returns api_token auth info", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "apitoken@example.com",
});
const { token } = await createApiToken(db, user.id);
const context = createAPIContext(db, { apiKey: token });
const result = await call(router.me.authStatus, undefined, { context });
expect(result.user.email).toBe("apitoken@example.com");
expect(result.auth.method).toBe("api_token");
if (result.auth.method === "api_token") {
expect(result.auth.tokenName).toBe("Test API Token");
expect(result.auth.expiresAt).toBeInstanceOf(Date);
}
});
});
});
describe("me.setupProfile", () => {
test("sets up profile with required fields", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "setup@example.com",
displayName: undefined,
});
// Clear display_name
await db
.updateTable("users")
.set({ display_name: null })
.where("id", "=", user.id)
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(
router.me.setupProfile,
{
displayName: "New Display Name",
fullName: "John Doe",
phoneNumber: "+12025551234",
},
{ context },
);
// Verify changes
const updated = await db
.selectFrom("users")
.select(["display_name", "full_name", "phone_number"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.display_name).toBe("New Display Name");
expect(updated.full_name).toBe("John Doe");
expect(updated.phone_number).toBe("+12025551234");
});
});
test("sets up profile with only required displayName", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "minimal@example.com",
});
await db
.updateTable("users")
.set({ display_name: null })
.where("id", "=", user.id)
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(
router.me.setupProfile,
{
displayName: "Minimal User",
},
{ context },
);
const updated = await db
.selectFrom("users")
.select(["display_name", "full_name", "phone_number"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.display_name).toBe("Minimal User");
expect(updated.full_name).toBeNull();
expect(updated.phone_number).toBeNull();
});
});
});
describe("me.updateProfile", () => {
test("updates displayName only", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "update@example.com",
displayName: "Original Name",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(
router.me.updateProfile,
{
displayName: "Updated Name",
},
{ context },
);
const updated = await db
.selectFrom("users")
.select(["display_name"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.display_name).toBe("Updated Name");
});
});
test("updates multiple fields at once", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "multi@example.com",
displayName: "Original",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(
router.me.updateProfile,
{
displayName: "New Display",
fullName: "Full Name Here",
phoneNumber: "+12025551234",
},
{ context },
);
const updated = await db
.selectFrom("users")
.select(["display_name", "full_name", "phone_number"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.display_name).toBe("New Display");
expect(updated.full_name).toBe("Full Name Here");
expect(updated.phone_number).toBe("+12025551234");
});
});
test("empty strings in optional fields are treated as no-op", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Empty strings in optionalString fields are transformed to undefined,
// which means no update happens - fields keep their existing values
const user = await createTestUser(db, {
email: "clear@example.com",
displayName: "Keep Me",
fullName: "Keep This Too",
});
await db
.updateTable("users")
.set({ phone_number: "+12025551234" })
.where("id", "=", user.id)
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(
router.me.updateProfile,
{
fullName: "",
phoneNumber: "",
},
{ context },
);
const updated = await db
.selectFrom("users")
.select(["display_name", "full_name", "phone_number"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
// Empty strings are transformed to undefined by optionalString,
// so no update happens - fields keep their existing values
expect(updated.display_name).toBe("Keep Me");
expect(updated.full_name).toBe("Keep This Too");
expect(updated.phone_number).toBe("+12025551234");
});
});
test("does nothing when no fields provided", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "noop@example.com",
displayName: "Stay Same",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(router.me.updateProfile, {}, { context });
const updated = await db
.selectFrom("users")
.select(["display_name"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.display_name).toBe("Stay Same");
});
});
});
describe("me.setPassword", () => {
test("sets password for user without password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "nopass@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
// Use a strong password
await call(
router.me.setPassword,
{
newPassword: "SuperSecure123!@#$%",
},
{ context },
);
const updated = await db
.selectFrom("users")
.select(["password_hash"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.password_hash).not.toBeNull();
});
});
test("changes password with correct current password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const oldPassword = "OldPassword123!@#";
const oldHash = await hashPassword(oldPassword);
const user = await createTestUser(db, {
email: "changepass@example.com",
passwordHash: oldHash,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(
router.me.setPassword,
{
currentPassword: oldPassword,
newPassword: "NewSecurePassword456!@#",
},
{ context },
);
const updated = await db
.selectFrom("users")
.select(["password_hash"])
.where("id", "=", user.id)
.executeTakeFirstOrThrow();
expect(updated.password_hash).not.toBe(oldHash);
});
});
test("fails without current password when user has password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const oldHash = await hashPassword("ExistingPass123!");
const user = await createTestUser(db, {
email: "haspass@example.com",
passwordHash: oldHash,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(
router.me.setPassword,
{
newPassword: "NewPassword123!@#",
},
{ context },
),
).rejects.toThrow("Current password required");
});
});
test("fails with incorrect current password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const oldHash = await hashPassword("CorrectPassword123!");
const user = await createTestUser(db, {
email: "wrongpass@example.com",
passwordHash: oldHash,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(
router.me.setPassword,
{
currentPassword: "WrongPassword123!",
newPassword: "NewPassword456!@#",
},
{ context },
),
).rejects.toThrow("Current password is incorrect");
});
});
test("fails with weak password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "weak@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
// Password must be at least 8 chars to pass schema validation
// "password" passes length check but fails zxcvbn strength check
// zxcvbn provides feedback like "This is a top-10 common password"
await expect(
call(
router.me.setPassword,
{
newPassword: "password", // 8 chars but extremely common
},
{ context },
),
).rejects.toThrow(/common|top|weak|guess/i);
});
});
});
describe("me.delete", () => {
test("deletes account with correct password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const password = "DeleteMe123!@#";
const passwordHash = await hashPassword(password);
const user = await createTestUser(db, {
email: "delete@example.com",
passwordHash,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(router.me.delete, { password }, { context });
// Verify user is deleted
const deleted = await db
.selectFrom("users")
.where("id", "=", user.id)
.selectAll()
.executeTakeFirst();
expect(deleted).toBeUndefined();
});
});
test("fails without password set", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "nopassdelete@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.delete, { password: "anything" }, { context }),
).rejects.toThrow("Cannot delete account without a password");
});
});
test("fails with incorrect password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const passwordHash = await hashPassword("CorrectPassword123!");
const user = await createTestUser(db, {
email: "wrongdelete@example.com",
passwordHash,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(
router.me.delete,
{ password: "WrongPassword123!" },
{ context },
),
).rejects.toThrow("Incorrect password");
});
});
test("cascades deletion to related records", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const password = "CascadeDelete123!@#";
const passwordHash = await hashPassword(password);
const user = await createTestUser(db, {
email: "cascade@example.com",
passwordHash,
});
// Create related records
await db
.insertInto("api_tokens")
.values({
user_id: user.id,
token_hash: "test-hash",
name: "Test Token",
expires_at: new Date(Date.now() + ONE_DAY_MS),
})
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(router.me.delete, { password }, { context });
// Verify cascaded deletion
const tokens = await db
.selectFrom("api_tokens")
.where("user_id", "=", user.id)
.selectAll()
.execute();
expect(tokens).toHaveLength(0);
});
});
});
// ===== Session Management Tests =====
describe("me.sessions.list", () => {
test("returns all sessions for user", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "sessions@example.com",
});
// Create multiple sessions
const { token: sessionToken1 } = await createSession(db, user.id, {
ipAddress: "192.168.1.1",
userAgent: "Chrome/1.0",
});
await createSession(db, user.id, {
ipAddress: "192.168.1.2",
userAgent: "Firefox/1.0",
});
await createSession(db, user.id, {
ipAddress: "192.168.1.3",
userAgent: "Safari/1.0",
});
const context = createAPIContext(db, { sessionToken: sessionToken1 });
const sessions = await call(router.me.sessions.list, undefined, {
context,
});
expect(sessions).toHaveLength(3);
// Verify all sessions exist (order not guaranteed when created simultaneously)
const userAgents = sessions.map((s) => s.userAgent).sort();
expect(userAgents).toEqual(["Chrome/1.0", "Firefox/1.0", "Safari/1.0"]);
});
});
test("marks current session with isCurrent flag", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "current@example.com",
});
const { token: sessionToken1, sessionId: id1 } = await createSession(
db,
user.id,
);
const { sessionId: id2 } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken: sessionToken1 });
const sessions = await call(router.me.sessions.list, undefined, {
context,
});
expect(sessions).toHaveLength(2);
const current = sessions.find((s) => s.id === id1);
const other = sessions.find((s) => s.id === id2);
expect(current?.isCurrent).toBe(true);
expect(other?.isCurrent).toBe(false);
});
});
test("returns session metadata correctly", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "metadata@example.com",
});
// Create session and update with location data
const { token: sessionToken, sessionId } = await createSession(
db,
user.id,
{
ipAddress: "8.8.8.8",
userAgent: "TestAgent/1.0",
},
);
await db
.updateTable("sessions")
.set({
city: "San Francisco",
region: "CA",
country: "US",
trusted_mode: true,
})
.where("id", "=", sessionId.toString())
.execute();
const context = createAPIContext(db, { sessionToken });
const sessions = await call(router.me.sessions.list, undefined, {
context,
});
expect(sessions).toHaveLength(1);
const session = sessions[0];
expect(session?.ip).toBe("8.8.8.8");
expect(session?.userAgent).toBe("TestAgent/1.0");
expect(session?.city).toBe("San Francisco");
expect(session?.region).toBe("CA");
expect(session?.country).toBe("US");
expect(session?.trustedMode).toBe(true);
expect(session?.createdAt).toBeInstanceOf(Date);
expect(session?.revokedAt).toBeNull();
});
});
});
describe("me.sessions.revoke", () => {
test("revokes another session successfully", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revoke@example.com",
});
const { token: sessionToken1 } = await createSession(db, user.id);
const { sessionId: sessionId2 } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken: sessionToken1 });
await call(
router.me.sessions.revoke,
{ sessionId: sessionId2 },
{ context },
);
// Verify session is revoked
const session = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("id", "=", sessionId2.toString())
.executeTakeFirstOrThrow();
expect(session.revoked_at).not.toBeNull();
});
});
test("fails to revoke current session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revokecurrent@example.com",
});
const { token: sessionToken, sessionId } = await createSession(
db,
user.id,
);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.sessions.revoke, { sessionId }, { context }),
).rejects.toThrow("Cannot revoke current session");
});
});
test("fails to revoke non-existent session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revokenotfound@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.sessions.revoke, { sessionId: 999999 }, { context }),
).rejects.toThrow("Session not found");
});
});
test("fails to revoke already revoked session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revokeagain@example.com",
});
const { token: sessionToken1 } = await createSession(db, user.id);
const { sessionId: sessionId2 } = await createSession(db, user.id);
// Revoke the session directly
await db
.updateTable("sessions")
.set({ revoked_at: new Date() })
.where("id", "=", sessionId2.toString())
.execute();
const context = createAPIContext(db, { sessionToken: sessionToken1 });
await expect(
call(
router.me.sessions.revoke,
{ sessionId: sessionId2 },
{ context },
),
).rejects.toThrow("Session not found");
});
});
test("fails to revoke another user's session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user1 = await createTestUser(db, {
email: "user1@example.com",
});
const user2 = await createTestUser(db, {
email: "user2@example.com",
});
const { token: sessionToken1 } = await createSession(db, user1.id);
const { sessionId: sessionId2 } = await createSession(db, user2.id);
const context = createAPIContext(db, { sessionToken: sessionToken1 });
await expect(
call(
router.me.sessions.revoke,
{ sessionId: sessionId2 },
{ context },
),
).rejects.toThrow("Session not found");
});
});
});
describe("me.sessions.revokeAll", () => {
test("revokes all sessions except current", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revokeall@example.com",
});
const { token: sessionToken1, sessionId: id1 } = await createSession(
db,
user.id,
);
const { sessionId: id2 } = await createSession(db, user.id);
const { sessionId: id3 } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken: sessionToken1 });
await call(router.me.sessions.revokeAll, undefined, { context });
// Verify current session is NOT revoked
const currentSession = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("id", "=", id1.toString())
.executeTakeFirstOrThrow();
expect(currentSession.revoked_at).toBeNull();
// Verify other sessions ARE revoked
const otherSessions = await db
.selectFrom("sessions")
.select(["id", "revoked_at"])
.where("id", "in", [id2.toString(), id3.toString()])
.execute();
for (const session of otherSessions) {
expect(session.revoked_at).not.toBeNull();
}
});
});
test("does nothing when only current session exists", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "onlyone@example.com",
});
const { token: sessionToken, sessionId } = await createSession(
db,
user.id,
);
const context = createAPIContext(db, { sessionToken });
// Should not throw
await call(router.me.sessions.revokeAll, undefined, { context });
// Current session should still be valid
const session = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("id", "=", sessionId.toString())
.executeTakeFirstOrThrow();
expect(session.revoked_at).toBeNull();
});
});
});
// ===== Device Management Tests =====
describe("me.devices.getInfo", () => {
test("returns device info for current device", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "deviceinfo@example.com",
});
const { fingerprint, deviceId } = await createDevice(db, user.id, {
name: "My MacBook",
isTrusted: true,
userAgent: "Safari/17.0",
});
// Update with location data
await db
.updateTable("user_devices")
.set({
ip_address: "1.2.3.4",
city: "New York",
region: "NY",
country: "US",
})
.where("id", "=", deviceId.toString())
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, {
sessionToken,
deviceFingerprint: fingerprint,
});
const info = await call(router.me.devices.getInfo, undefined, {
context,
});
expect(info.id).toBe(deviceId);
expect(info.name).toBe("My MacBook");
expect(info.ip).toBe("1.2.3.4");
expect(info.city).toBe("New York");
expect(info.region).toBe("NY");
expect(info.country).toBe("US");
expect(info.isTrusted).toBe(true);
expect(info.lastUsedAt).toBeInstanceOf(Date);
});
});
test("returns default name from user agent when name is null", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "defaultname@example.com",
});
const { fingerprint } = await createDevice(db, user.id, {
userAgent: "Mozilla/5.0 (Macintosh)",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, {
sessionToken,
deviceFingerprint: fingerprint,
});
const info = await call(router.me.devices.getInfo, undefined, {
context,
});
expect(info.name).toBe("Mozilla device");
});
});
test("fails without device fingerprint", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "nofingerprint@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.devices.getInfo, undefined, { context }),
).rejects.toThrow("No device fingerprint found");
});
});
test("fails when device does not exist", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "nodevice@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, {
sessionToken,
deviceFingerprint: "nonexistent-fingerprint",
});
await expect(
call(router.me.devices.getInfo, undefined, { context }),
).rejects.toThrow("Device not found");
});
});
});
describe("me.devices.trust", () => {
test("trusts current device with name", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "trustdevice@example.com",
});
const { fingerprint, deviceId } = await createDevice(db, user.id, {
isTrusted: false,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, {
sessionToken,
deviceFingerprint: fingerprint,
});
await call(
router.me.devices.trust,
{ name: "My Work Laptop" },
{ context },
);
// Verify device is trusted with the new name
const device = await db
.selectFrom("user_devices")
.select(["is_trusted", "name"])
.where("id", "=", deviceId.toString())
.executeTakeFirstOrThrow();
expect(device.is_trusted).toBe(true);
expect(device.name).toBe("My Work Laptop");
});
});
test("fails without device fingerprint", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "trustnofp@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.devices.trust, { name: "Test" }, { context }),
).rejects.toThrow("No device fingerprint found");
});
});
test("fails when device does not exist", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "trustnodevice@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, {
sessionToken,
deviceFingerprint: "nonexistent",
});
await expect(
call(router.me.devices.trust, { name: "Test" }, { context }),
).rejects.toThrow("Device not found");
});
});
});
describe("me.devices.listTrusted", () => {
test("returns only trusted devices", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "listtrusted@example.com",
});
// Create trusted and untrusted devices
await createDevice(db, user.id, { isTrusted: true, name: "Trusted 1" });
await createDevice(db, user.id, { isTrusted: true, name: "Trusted 2" });
await createDevice(db, user.id, {
isTrusted: false,
name: "Untrusted",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const devices = await call(router.me.devices.listTrusted, undefined, {
context,
});
expect(devices).toHaveLength(2);
expect(devices.map((d) => d.name).sort()).toEqual([
"Trusted 1",
"Trusted 2",
]);
expect(devices.every((d) => d.isTrusted)).toBe(true);
});
});
test("returns empty list when no trusted devices", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "notrusted@example.com",
});
await createDevice(db, user.id, { isTrusted: false });
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const devices = await call(router.me.devices.listTrusted, undefined, {
context,
});
expect(devices).toHaveLength(0);
});
});
test("returns default name when device name is null", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "defaulttrusted@example.com",
});
await createDevice(db, user.id, {
isTrusted: true,
name: undefined,
userAgent: "Chrome/120",
});
// Set name to null explicitly
await db
.updateTable("user_devices")
.set({ name: null })
.where("user_id", "=", user.id)
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const devices = await call(router.me.devices.listTrusted, undefined, {
context,
});
expect(devices).toHaveLength(1);
expect(devices[0]?.name).toBe("Unknown device");
});
});
});
describe("me.devices.untrust", () => {
test("untrusts device by ID", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "untrust@example.com",
});
const { deviceId } = await createDevice(db, user.id, {
isTrusted: true,
name: "Trusted Device",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(router.me.devices.untrust, { deviceId }, { context });
// Verify device is untrusted
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("id", "=", deviceId.toString())
.executeTakeFirstOrThrow();
expect(device.is_trusted).toBe(false);
});
});
test("fails to untrust non-existent device", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "untrustnotfound@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.devices.untrust, { deviceId: 999999 }, { context }),
).rejects.toThrow("Device not found");
});
});
test("fails to untrust another user's device", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user1 = await createTestUser(db, {
email: "untrustuser1@example.com",
});
const user2 = await createTestUser(db, {
email: "untrustuser2@example.com",
});
const { deviceId } = await createDevice(db, user2.id, {
isTrusted: true,
});
const { token: sessionToken } = await createSession(db, user1.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.devices.untrust, { deviceId }, { context }),
).rejects.toThrow("Device not found");
});
});
});
describe("me.devices.revokeAll", () => {
test("untrusts all devices", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revokealldevices@example.com",
});
await createDevice(db, user.id, { isTrusted: true });
await createDevice(db, user.id, { isTrusted: true });
await createDevice(db, user.id, { isTrusted: false });
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await call(router.me.devices.revokeAll, undefined, { context });
// All devices should be untrusted
const devices = await db
.selectFrom("user_devices")
.select(["id", "is_trusted"])
.where("user_id", "=", user.id)
.execute();
expect(devices).toHaveLength(3);
expect(devices.every((d) => !d.is_trusted)).toBe(true);
});
});
test("works when no devices exist", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "revokenodevices@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
// Should not throw
await call(router.me.devices.revokeAll, undefined, { context });
});
});
});
}); // Close outer describeE2E
// =============================================================================
// me.apiTokens and me.invites tests
// =============================================================================
/**
* Create a trusted session for testing API token creation
*/
async function createTrustedSession(
db: Kysely<Database>,
userId: number,
): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
const result = await db
.insertInto("sessions")
.values({
user_id: userId,
token_hash: tokenHashValue,
ip_address: "127.0.0.1",
user_agent: "test-agent",
expires_at: expiresAt,
trusted_mode: true,
})
.returning("id")
.executeTakeFirstOrThrow();
return { token, sessionId: Number(result.id) };
}
/**
* Create an organization for testing
*/
async function createOrg(
db: Kysely<Database>,
data: { slug: string; displayName?: string },
): Promise<{ id: number; slug: string }> {
const result = await db
.insertInto("orgs")
.values({
slug: data.slug,
display_name: data.displayName ?? data.slug,
})
.returning(["id", "slug"])
.executeTakeFirstOrThrow();
return { id: result.id, slug: result.slug };
}
/**
* Add a member to an org
*/
async function addOrgMember(
db: Kysely<Database>,
orgId: number,
userId: number,
role: "owner" | "admin" | "member" = "member",
): Promise<void> {
await db
.insertInto("org_members")
.values({ org_id: orgId, user_id: userId, role })
.execute();
}
/**
* Create an org invite for testing
*/
async function createOrgInvite(
db: Kysely<Database>,
data: {
orgId: number;
email: string;
invitedBy: number;
role?: "owner" | "admin" | "member";
expiresAt?: Date;
},
): Promise<{ id: number }> {
const token = `invite-token-${uniqueTestId()}-${Math.random().toString(36).slice(2)}`;
const result = await db
.insertInto("org_invites")
.values({
org_id: data.orgId,
email: data.email.toLowerCase(),
invited_by: data.invitedBy,
role: data.role ?? "member",
token,
expires_at:
data.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
})
.returning("id")
.executeTakeFirstOrThrow();
return { id: result.id };
}
describeE2E("me.apiTokens and me.invites", () => {
describe("me.apiTokens.list", () => {
test("returns empty list for user without tokens", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "notokens@example.com",
isSuperuser: true,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const tokens = await call(router.me.apiTokens.list, undefined, {
context,
});
expect(tokens).toHaveLength(0);
});
});
test("returns tokens for user with tokens", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "hastokens@example.com",
isSuperuser: true,
});
// Create some API tokens directly in DB
const tokenHash1 = await hashToken("token1");
const tokenHash2 = await hashToken("token2");
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
await db
.insertInto("api_tokens")
.values([
{
user_id: user.id,
token_hash: tokenHash1,
name: "Token One",
expires_at: expiresAt,
},
{
user_id: user.id,
token_hash: tokenHash2,
name: "Token Two",
expires_at: expiresAt,
},
])
.execute();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const tokens = await call(router.me.apiTokens.list, undefined, {
context,
});
expect(tokens).toHaveLength(2);
const names = tokens.map((t) => t.name).sort();
expect(names).toEqual(["Token One", "Token Two"]);
expect(tokens[0]).toHaveProperty("id");
expect(tokens[0]).toHaveProperty("createdAt");
expect(tokens[0]).toHaveProperty("expiresAt");
});
});
test("only returns current user tokens", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user1 = await createTestUser(db, {
email: "user1@example.com",
isSuperuser: true,
});
const user2 = await createTestUser(db, {
email: "user2@example.com",
isSuperuser: true,
});
const tokenHash1 = await hashToken("token1");
const tokenHash2 = await hashToken("token2");
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
await db
.insertInto("api_tokens")
.values([
{
user_id: user1.id,
token_hash: tokenHash1,
name: "User1 Token",
expires_at: expiresAt,
},
{
user_id: user2.id,
token_hash: tokenHash2,
name: "User2 Token",
expires_at: expiresAt,
},
])
.execute();
const { token: sessionToken } = await createSession(db, user1.id);
const context = createAPIContext(db, { sessionToken });
const tokens = await call(router.me.apiTokens.list, undefined, {
context,
});
expect(tokens).toHaveLength(1);
expect(tokens[0]?.name).toBe("User1 Token");
});
});
});
describe("me.apiTokens.create", () => {
test("creates token for superuser with trusted session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "superuser@example.com",
isSuperuser: true,
});
const { token: sessionToken } = await createTrustedSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(
router.me.apiTokens.create,
{ name: "My New Token" },
{ context },
);
expect(result.token).toBeDefined();
expect(result.token.startsWith("reviq_")).toBe(true);
expect(result.expiresAt).toBeDefined();
// Verify token was created in DB
const tokens = await db
.selectFrom("api_tokens")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(tokens).toHaveLength(1);
expect(tokens[0]?.name).toBe("My New Token");
});
});
test("rejects non-superuser", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "regular@example.com",
isSuperuser: false,
});
const { token: sessionToken } = await createTrustedSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.apiTokens.create, { name: "Test Token" }, { context }),
).rejects.toThrow("Only superusers can create API tokens");
});
});
test("rejects untrusted session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "superuser2@example.com",
isSuperuser: true,
});
// Use regular session (not trusted)
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.apiTokens.create, { name: "Test Token" }, { context }),
).rejects.toThrow("Creating API tokens requires a trusted session");
});
});
});
describe("me.apiTokens.delete", () => {
test("deletes own token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "deletetoken@example.com",
isSuperuser: true,
});
const tokenHash = await hashToken("token-to-delete");
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
const insertResult = await db
.insertInto("api_tokens")
.values({
user_id: user.id,
token_hash: tokenHash,
name: "To Delete",
expires_at: expiresAt,
})
.returning("id")
.executeTakeFirstOrThrow();
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(
router.me.apiTokens.delete,
{ tokenId: Number(insertResult.id) },
{ context },
);
expect(result.success).toBe(true);
// Verify token was deleted
const tokens = await db
.selectFrom("api_tokens")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(tokens).toHaveLength(0);
});
});
test("cannot delete other user token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user1 = await createTestUser(db, {
email: "owner@example.com",
isSuperuser: true,
});
const user2 = await createTestUser(db, {
email: "other@example.com",
isSuperuser: true,
});
const tokenHash = await hashToken("other-token");
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
const insertResult = await db
.insertInto("api_tokens")
.values({
user_id: user1.id,
token_hash: tokenHash,
name: "User1 Token",
expires_at: expiresAt,
})
.returning("id")
.executeTakeFirstOrThrow();
const { token: sessionToken } = await createSession(db, user2.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(
router.me.apiTokens.delete,
{ tokenId: Number(insertResult.id) },
{ context },
),
).rejects.toThrow("API token not found");
});
});
test("returns error for non-existent token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "notoken@example.com",
isSuperuser: true,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.apiTokens.delete, { tokenId: 99999 }, { context }),
).rejects.toThrow("API token not found");
});
});
});
// =============================================================================
// me.invites tests
// =============================================================================
describe("me.invites.list", () => {
test("returns empty list when email not verified", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, {
slug: "test-org",
displayName: "Test Org",
});
await addOrgMember(db, org.id, inviter.id, "owner");
// User without verified email
const user = await createTestUser(db, {
email: "unverified@example.com",
});
// Create an invite for the unverified user
await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const invites = await call(router.me.invites.list, undefined, {
context,
});
expect(invites).toHaveLength(0);
});
});
test("returns pending invites for verified user", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter2@example.com",
emailVerifiedAt: new Date(),
displayName: "Inviter Person",
});
const org = await createOrg(db, {
slug: "invite-org",
displayName: "Invite Org",
});
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: "verified@example.com",
emailVerifiedAt: new Date(),
});
await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
role: "admin",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const invites = await call(router.me.invites.list, undefined, {
context,
});
expect(invites).toHaveLength(1);
expect(invites[0]?.org.slug).toBe("invite-org");
expect(invites[0]?.org.displayName).toBe("Invite Org");
expect(invites[0]?.role).toBe("admin");
expect(invites[0]?.invitedBy).toBe("Inviter Person");
});
});
test("does not return expired invites", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter3@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: "expired-org" });
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: "verified2@example.com",
emailVerifiedAt: new Date(),
});
// Create an expired invite
await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
expiresAt: new Date(Date.now() - 1000), // Already expired
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const invites = await call(router.me.invites.list, undefined, {
context,
});
expect(invites).toHaveLength(0);
});
});
});
describe("me.invites.get", () => {
test("returns invite details", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter4@example.com",
emailVerifiedAt: new Date(),
displayName: "The Inviter",
});
const org = await createOrg(db, {
slug: "get-invite-org",
displayName: "Get Invite Org",
});
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: "getinvite@example.com",
emailVerifiedAt: new Date(),
});
const invite = await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
role: "member",
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(
router.me.invites.get,
{ inviteId: invite.id },
{ context },
);
expect(result.id).toBe(invite.id);
expect(result.org.slug).toBe("get-invite-org");
expect(result.role).toBe("member");
expect(result.invitedBy).toBe("The Inviter");
});
});
test("rejects if email not verified", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter5@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: "unverified-get-org" });
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: "unverified2@example.com",
});
const invite = await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.get, { inviteId: invite.id }, { context }),
).rejects.toThrow("Please verify your email to view invitations");
});
});
test("returns not found for other user invite", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter6@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: "other-user-org" });
await addOrgMember(db, org.id, inviter.id, "owner");
const otherUser = await createTestUser(db, {
email: "other@example.com",
emailVerifiedAt: new Date(),
});
const user = await createTestUser(db, {
email: "requestor@example.com",
emailVerifiedAt: new Date(),
});
// Invite is for otherUser, not user
const invite = await createOrgInvite(db, {
orgId: org.id,
email: otherUser.email,
invitedBy: inviter.id,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.get, { inviteId: invite.id }, { context }),
).rejects.toThrow("Invitation not found or expired");
});
});
});
describe("me.invites.accept", () => {
test("accepts invite and adds user to org", async () => {
const db = getSharedDb();
const uniqueId = uniqueTestId();
const inviter = await createTestUser(db, {
email: `inviter-accept-${uniqueId}@example.com`,
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: `accept-org-${uniqueId}` });
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: `accepter-${uniqueId}@example.com`,
emailVerifiedAt: new Date(),
});
const invite = await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
role: "admin",
});
try {
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(
router.me.invites.accept,
{ inviteId: invite.id },
{ context },
);
expect(result.success).toBe(true);
// Verify user is now a member
const membership = await db
.selectFrom("org_members")
.selectAll()
.where("org_id", "=", org.id)
.where("user_id", "=", user.id)
.executeTakeFirst();
expect(membership).toBeDefined();
expect(membership?.role).toBe("admin");
// Verify invite was deleted
const inviteCheck = await db
.selectFrom("org_invites")
.selectAll()
.where("id", "=", invite.id)
.executeTakeFirst();
expect(inviteCheck).toBeUndefined();
} finally {
// Cleanup
await db
.deleteFrom("org_members")
.where("org_id", "=", org.id)
.execute();
await db
.deleteFrom("org_invites")
.where("org_id", "=", org.id)
.execute();
await db
.deleteFrom("sessions")
.where("user_id", "=", user.id)
.execute();
await db.deleteFrom("orgs").where("id", "=", org.id).execute();
await db.deleteFrom("users").where("id", "=", user.id).execute();
await db.deleteFrom("users").where("id", "=", inviter.id).execute();
}
});
test("rejects if email not verified", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter7@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: "unverified-accept-org" });
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: "unverified3@example.com",
});
const invite = await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.accept, { inviteId: invite.id }, { context }),
).rejects.toThrow("Please verify your email to accept invitations");
});
});
test("returns error if already a member", async () => {
const db = getSharedDb();
const uniqueId = uniqueTestId();
const inviter = await createTestUser(db, {
email: `inviter-already-${uniqueId}@example.com`,
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, {
slug: `already-member-org-${uniqueId}`,
});
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: `already-member-${uniqueId}@example.com`,
emailVerifiedAt: new Date(),
});
// User is already a member
await addOrgMember(db, org.id, user.id, "member");
const invite = await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
role: "admin",
});
try {
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.accept, { inviteId: invite.id }, { context }),
).rejects.toThrow("You are already a member of this organization");
} finally {
// Cleanup
await db
.deleteFrom("org_members")
.where("org_id", "=", org.id)
.execute();
await db
.deleteFrom("org_invites")
.where("org_id", "=", org.id)
.execute();
await db
.deleteFrom("sessions")
.where("user_id", "=", user.id)
.execute();
await db.deleteFrom("orgs").where("id", "=", org.id).execute();
await db.deleteFrom("users").where("id", "=", user.id).execute();
await db.deleteFrom("users").where("id", "=", inviter.id).execute();
}
});
test("returns not found for non-existent invite", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "acceptnonexistent@example.com",
emailVerifiedAt: new Date(),
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.accept, { inviteId: 99999 }, { context }),
).rejects.toThrow("Invitation not found or expired");
});
});
});
describe("me.invites.decline", () => {
test("declines invite and deletes it", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter8@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: "decline-org" });
await addOrgMember(db, org.id, inviter.id, "owner");
const user = await createTestUser(db, {
email: "decliner@example.com",
emailVerifiedAt: new Date(),
});
const invite = await createOrgInvite(db, {
orgId: org.id,
email: user.email,
invitedBy: inviter.id,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
const result = await call(
router.me.invites.decline,
{ inviteId: invite.id },
{ context },
);
expect(result.success).toBe(true);
// Verify invite was deleted
const inviteCheck = await db
.selectFrom("org_invites")
.selectAll()
.where("id", "=", invite.id)
.executeTakeFirst();
expect(inviteCheck).toBeUndefined();
});
});
test("returns not found for other user invite", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const inviter = await createTestUser(db, {
email: "inviter9@example.com",
emailVerifiedAt: new Date(),
});
const org = await createOrg(db, { slug: "other-decline-org" });
await addOrgMember(db, org.id, inviter.id, "owner");
const otherUser = await createTestUser(db, {
email: "otherinvited@example.com",
emailVerifiedAt: new Date(),
});
const user = await createTestUser(db, {
email: "wrongdecliner@example.com",
emailVerifiedAt: new Date(),
});
const invite = await createOrgInvite(db, {
orgId: org.id,
email: otherUser.email,
invitedBy: inviter.id,
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.decline, { inviteId: invite.id }, { context }),
).rejects.toThrow("Invitation not found");
});
});
test("returns not found for non-existent invite", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "noinvite@example.com",
emailVerifiedAt: new Date(),
});
const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken });
await expect(
call(router.me.invites.decline, { inviteId: 99999 }, { context }),
).rejects.toThrow("Invitation not found");
});
});
}); // Close describe for me.invites.decline
}); // Close describeE2E for me.apiTokens and me.invites