Add comprehensive e2e tests for API procedures with 100% coverage
- Add admin.test.ts: Tests for superuser operations (users, orgs, sites) - Add orgs.test.ts: Tests for org management, members, invites, sites - Expand me.test.ts: Add API tokens, invites, authMiddleware error paths - Expand auth.test.ts: Add loginRequestMiddleware tests, weak password test fix Bug fixes: - Fix countOwners() in orgs/helpers.ts to convert PostgreSQL bigint to number - Fix signup race condition by handling unique constraint violations gracefully All 283 tests pass with 100% function coverage on procedures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1898
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
1898
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1595,10 +1595,11 @@ describe("auth.resetPassword", () => {
|
||||
|
||||
const ctx = createAPIContext(db);
|
||||
|
||||
// Password must be >=8 chars (schema) but weak enough to fail zxcvbn (score < 3)
|
||||
await expect(
|
||||
call(
|
||||
router.auth.resetPassword,
|
||||
{ token, newPassword: "weak" },
|
||||
{ token, newPassword: "password" },
|
||||
{ context: ctx },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
@@ -2105,3 +2106,61 @@ describe("End-to-end login scenarios", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// loginRequestMiddleware tests (base.ts)
|
||||
// =============================================================================
|
||||
|
||||
describe("loginRequestMiddleware", () => {
|
||||
test("rejects request with no login request cookie", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
// No login request token in context
|
||||
const ctx = createAPIContext(db);
|
||||
|
||||
await expect(
|
||||
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||
context: ctx,
|
||||
}),
|
||||
).rejects.toThrow("No login request found");
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects request with invalid login request token", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
// Invalid token that doesn't exist in DB
|
||||
const ctx = createAPIContext(db, {
|
||||
loginRequestToken: "invalid-login-request-token",
|
||||
});
|
||||
|
||||
await expect(
|
||||
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||
context: ctx,
|
||||
}),
|
||||
).rejects.toThrow("Login request expired or not found");
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects request with expired login request", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
email: "expiredloginreq@example.com",
|
||||
});
|
||||
|
||||
// Create an expired login request
|
||||
const { token: loginToken } = await createLoginRequest(
|
||||
db,
|
||||
user.id,
|
||||
user.email,
|
||||
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
|
||||
);
|
||||
|
||||
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
|
||||
|
||||
await expect(
|
||||
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||
context: ctx,
|
||||
}),
|
||||
).rejects.toThrow("Login request expired or not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +169,100 @@ 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-${Date.now()}`;
|
||||
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-${Date.now()}`;
|
||||
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) => {
|
||||
@@ -1371,3 +1465,769 @@ describe("me.devices.revokeAll", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// me.apiTokens 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-${String(Date.now())}${String(Math.random())}`;
|
||||
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-${String(Date.now())}-${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: Number(result.id) };
|
||||
}
|
||||
|
||||
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 = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
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 = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1568
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
1568
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,17 +52,26 @@ export async function signupWithPassword(
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
// Create user (handle race condition if concurrent signup with same email)
|
||||
try {
|
||||
const user = await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return user.id;
|
||||
return user.id;
|
||||
} catch (error) {
|
||||
// Handle duplicate email (unique constraint violation)
|
||||
// Use generic error to prevent email enumeration
|
||||
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,55 +155,64 @@ export async function signupWithPasskey(
|
||||
});
|
||||
}
|
||||
|
||||
// Create user and passkey in a transaction
|
||||
const result = await db.transaction().execute(async (trx) => {
|
||||
// Create user
|
||||
const user = await trx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||
try {
|
||||
const result = await db.transaction().execute(async (trx) => {
|
||||
// Create user
|
||||
const user = await trx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const newUserId = user.id;
|
||||
const newUserId = user.id;
|
||||
|
||||
// Get friendly name from AAGUID
|
||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||
const passkeyName = guidName ?? "Default";
|
||||
// Get friendly name from AAGUID
|
||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||
const passkeyName = guidName ?? "Default";
|
||||
|
||||
// Store the passkey
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
// Store the passkey
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
|
||||
await trx
|
||||
.insertInto("passkeys")
|
||||
.values({
|
||||
user_id: newUserId,
|
||||
credential_id: Buffer.from(credential.id, "base64url"),
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
webauthn_user_id: options.user.id,
|
||||
counter: BigInt(credential.counter),
|
||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||
backup_eligible: registrationInfo.credentialBackedUp,
|
||||
backup_status: credentialBackedUp,
|
||||
transports: JSON.stringify(response.response.transports ?? []),
|
||||
rpid: rpInfo.rpID,
|
||||
name: passkeyName,
|
||||
})
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("passkeys")
|
||||
.values({
|
||||
user_id: newUserId,
|
||||
credential_id: Buffer.from(credential.id, "base64url"),
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
webauthn_user_id: options.user.id,
|
||||
counter: BigInt(credential.counter),
|
||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||
backup_eligible: registrationInfo.credentialBackedUp,
|
||||
backup_status: credentialBackedUp,
|
||||
transports: JSON.stringify(response.response.transports ?? []),
|
||||
rpid: rpInfo.rpID,
|
||||
name: passkeyName,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Delete the challenge
|
||||
await trx
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.execute();
|
||||
// Delete the challenge
|
||||
await trx
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.execute();
|
||||
|
||||
return { userId: newUserId };
|
||||
});
|
||||
return { userId: newUserId };
|
||||
});
|
||||
|
||||
return result.userId;
|
||||
return result.userId;
|
||||
} catch (error) {
|
||||
// Handle duplicate email (unique constraint violation)
|
||||
// Use generic error to prevent email enumeration
|
||||
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,7 +259,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
);
|
||||
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
||||
} else {
|
||||
// Should never reach here due to schema validation
|
||||
// Unreachable - schema validation requires password or passkeyInfo
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Either password or passkeyInfo is required",
|
||||
});
|
||||
|
||||
@@ -120,5 +120,6 @@ export async function countOwners(
|
||||
.where("role", "=", "owner")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.count;
|
||||
// PostgreSQL COUNT returns bigint which may be a string; ensure numeric comparison works
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
||||
\restrict ib0L1tt0kcihJbP9aADEOXJtCsMf5T4lIJeG6jvjTT1gyQCoWtfbB5Qc1NLXCOA
|
||||
|
||||
-- Dumped from database version 17.7
|
||||
-- Dumped by pg_dump version 17.7
|
||||
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
||||
\unrestrict ib0L1tt0kcihJbP9aADEOXJtCsMf5T4lIJeG6jvjTT1gyQCoWtfbB5Qc1NLXCOA
|
||||
|
||||
|
||||
--
|
||||
|
||||
Reference in New Issue
Block a user