- 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>
2359 lines
74 KiB
TypeScript
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
|