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:
igm
2026-01-12 12:53:19 +08:00
parent 44a480179b
commit ebc85af62c
7 changed files with 4461 additions and 57 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1595,10 +1595,11 @@ describe("auth.resetPassword", () => {
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
// Password must be >=8 chars (schema) but weak enough to fail zxcvbn (score < 3)
await expect( await expect(
call( call(
router.auth.resetPassword, router.auth.resetPassword,
{ token, newPassword: "weak" }, { token, newPassword: "password" },
{ context: ctx }, { context: ctx },
), ),
).rejects.toThrow(); ).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");
});
});
});

View File

@@ -169,6 +169,100 @@ beforeAll(async () => {
await initTestDb(); 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", () => { describe("me.get", () => {
test("returns user profile with all fields", async () => { test("returns user profile with all fields", async () => {
await withTestTransaction(getSharedDb(), async (db) => { 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");
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,8 @@ export async function signupWithPassword(
// Hash password // Hash password
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
// Create user // Create user (handle race condition if concurrent signup with same email)
try {
const user = await db const user = await db
.insertInto("users") .insertInto("users")
.values({ .values({
@@ -63,6 +64,14 @@ export async function signupWithPassword(
.executeTakeFirstOrThrow(); .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,7 +155,8 @@ export async function signupWithPasskey(
}); });
} }
// Create user and passkey in a transaction // Create user and passkey in a transaction (handle race condition if concurrent signup)
try {
const result = await db.transaction().execute(async (trx) => { const result = await db.transaction().execute(async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
@@ -195,6 +205,14 @@ export async function signupWithPasskey(
}); });
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); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else { } else {
// Should never reach here due to schema validation // Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required", message: "Either password or passkeyInfo is required",
}); });

View File

@@ -120,5 +120,6 @@ export async function countOwners(
.where("role", "=", "owner") .where("role", "=", "owner")
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return result.count; // PostgreSQL COUNT returns bigint which may be a string; ensure numeric comparison works
return Number(result.count);
} }

View File

@@ -1,4 +1,4 @@
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap \restrict ib0L1tt0kcihJbP9aADEOXJtCsMf5T4lIJeG6jvjTT1gyQCoWtfbB5Qc1NLXCOA
-- Dumped from database version 17.7 -- Dumped from database version 17.7
-- Dumped by pg_dump version 17.7 -- Dumped by pg_dump version 17.7
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap \unrestrict ib0L1tt0kcihJbP9aADEOXJtCsMf5T4lIJeG6jvjTT1gyQCoWtfbB5Qc1NLXCOA
-- --