diff --git a/api/src/index.test.ts b/api/src/index.test.ts index 2cd626e..7be1b27 100644 --- a/api/src/index.test.ts +++ b/api/src/index.test.ts @@ -1,80 +1,10 @@ -import { beforeAll, describe, expect, it } from "bun:test"; -import { migrate } from "drizzle-orm/bun-sql/migrator"; +import { describe, expect, it } from "bun:test"; import app from "."; -import { db } from "./db"; -import { auth } from "./lib/auth"; -import { buildHeaders } from "./test/utils"; -beforeAll(async () => { - await migrate(db, { migrationsFolder: "./drizzle" }); -}); - -describe("First Test", () => { +describe("API Health Check", () => { it("should return 200 Response", async () => { const req = new Request("http://localhost:3000/"); const res = await app.fetch(req); expect(res.status).toBe(200); }); }); - -describe("Auth", () => { - const user = { - email: "yadunand@yadunut.com", - password: "password123", - name: "Yadunand Prem", - username: "yadunut", - }; - - let authHeaders: Headers; - - it("creates a new user", async () => { - const res = await auth.api.signUpEmail({ - body: { - ...user, - }, - }); - expect(res.user.email).toBe("yadunand@yadunut.com"); - const foundUser = await db.query.user.findFirst(); - expect(foundUser).not.toBeNull(); - expect(foundUser?.email).toBe(user.email); - }); - - it("logs in user and stores auth headers", async () => { - const { headers: signInHeaders, response: signInRes } = - await auth.api.signInEmail({ - body: { - email: user.email, - password: user.password, - }, - returnHeaders: true, - }); - expect(signInRes.token).not.toBeNull(); - expect(signInRes.user.name).toBe(user.name); - - // Store the auth headers for subsequent tests - authHeaders = buildHeaders(signInHeaders); - - // Verify session works with stored headers - const res = await auth.api.getSession({ headers: authHeaders }); - expect(res?.user.username).toBe(user.username); - }); - - it("creates auth token using stored headers", async () => { - const data = await auth.api.createApiKey({ - headers: authHeaders, - body: {}, - }); - // Add expectations based on what createApiKey should return - expect(data).toBeDefined(); - }); - - it("deletes user using stored headers", async () => { - const deleteRes = await auth.api.deleteUser({ - headers: authHeaders, - body: { - password: user.password, - }, - }); - expect(deleteRes.success).toBe(true); - }); -}); diff --git a/api/src/index.ts b/api/src/index.ts index 93b7adb..6a042d9 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; -import { auth } from "./lib/auth"; +import { auth } from "@/lib/auth"; const app = new Hono(); diff --git a/api/src/test/auth/apikey.test.ts b/api/src/test/auth/apikey.test.ts new file mode 100644 index 0000000..99fc966 --- /dev/null +++ b/api/src/test/auth/apikey.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { auth } from "@/lib/auth"; +import { AuthTestHelper, buildHeaders } from "../utils"; + +describe("API Key Management", () => { + const user = { + email: "apikey-test@example.com", + password: "password123", + name: "API Key Test User", + username: "apikeyuser", + }; + + let authHeaders: Headers; + let userId: string; + + let authHelper: AuthTestHelper; + + beforeEach(async () => { + // Create user and sign in for each test + authHelper = new AuthTestHelper(); + const authUser = await authHelper.createAuthenticatedUser(); + authHeaders = authUser.authHeaders; + userId = authUser.userId; + }); + + afterEach(async () => { + // Clean up: delete user and associated data + authHelper.cleanupUser(authHeaders, user.password); + }); + + describe("API Key Creation", () => { + it("creates an API key successfully", async () => { + const res = await auth.api.createApiKey({ + headers: authHeaders, + body: { + name: "Test API Key", + }, + }); + + expect(res).toBeDefined(); + expect(res.id).toBeDefined(); + expect(res.name).toBe("Test API Key"); + expect(res.key).toBeDefined(); + expect(res.userId).toBe(userId); + expect(res.enabled).toBe(true); + }); + + it("creates an API key with custom name", async () => { + const res = await auth.api.createApiKey({ + headers: authHeaders, + body: { + name: "Custom API Key", + }, + }); + + expect(res).toBeDefined(); + expect(res.name).toBe("Custom API Key"); + expect(res.key).toBeDefined(); + expect(res.userId).toBe(userId); + }); + + it("rejects API key creation without authentication", async () => { + try { + await auth.api.createApiKey({ + headers: new Headers(), + body: { + name: "Unauthorized Key", + }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("API Key Listing", () => { + it("lists user's API keys", async () => { + // Create multiple API keys + await auth.api.createApiKey({ + headers: authHeaders, + body: { name: "Key 1" }, + }); + await auth.api.createApiKey({ + headers: authHeaders, + body: { name: "Key 2" }, + }); + + const res = await auth.api.listApiKeys({ + headers: authHeaders, + }); + + expect(res).toBeDefined(); + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(2); + expect(res.some((key) => key.name === "Key 1")).toBe(true); + expect(res.some((key) => key.name === "Key 2")).toBe(true); + }); + + it("returns empty list for user with no API keys", async () => { + const res = await auth.api.listApiKeys({ + headers: authHeaders, + }); + + expect(res).toBeDefined(); + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(0); + }); + + it("rejects listing without authentication", async () => { + try { + await auth.api.listApiKeys({ + headers: new Headers(), + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("API Key Deletion", () => { + let apiKeyId: string; + + beforeEach(async () => { + const res = await auth.api.createApiKey({ + headers: authHeaders, + body: { name: "Key to Revoke" }, + }); + apiKeyId = res.id; + }); + + it("deletes an API key successfully", async () => { + const res = await auth.api.deleteApiKey({ + headers: authHeaders, + body: { keyId: apiKeyId }, + }); + + expect(res.success).toBe(true); + + // Verify the key is no longer in the list + const keys = await auth.api.listApiKeys({ + headers: authHeaders, + }); + expect(keys.find((key) => key.id === apiKeyId)).toBeUndefined(); + }); + + it("rejects deleting non-existent API key", async () => { + try { + await auth.api.deleteApiKey({ + headers: authHeaders, + body: { keyId: "non-existent-key-id" }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it("rejects deleting API key without authentication", async () => { + try { + await auth.api.deleteApiKey({ + headers: new Headers(), + body: { keyId: apiKeyId }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it("rejects deleting another user's API key", async () => { + // Create another user + const otherUser = { + email: "other-user@example.com", + password: "password123", + name: "Other User", + username: "otheruser", + }; + + await auth.api.signUpEmail({ + body: otherUser, + }); + + const { headers: otherHeaders } = await auth.api.signInEmail({ + body: { + email: otherUser.email, + password: otherUser.password, + }, + returnHeaders: true, + }); + + try { + await auth.api.deleteApiKey({ + headers: buildHeaders(otherHeaders), + body: { keyId: apiKeyId }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + + // Clean up other user + await auth.api.deleteUser({ + headers: buildHeaders(otherHeaders), + body: { password: otherUser.password }, + }); + }); + }); + + describe("API Key Verification", () => { + let apiKey: string; + let apiKeyId: string; + + beforeEach(async () => { + const res = await auth.api.createApiKey({ + headers: authHeaders, + body: { name: "Test Auth Key" }, + }); + apiKey = res.key; + apiKeyId = res.id; + }); + + it("verifies valid API key", async () => { + const res = await auth.api.verifyApiKey({ + body: { key: apiKey }, + }); + + expect(res).toBeDefined(); + expect(res.valid).toBe(true); + }); + + it("rejects invalid API key", async () => { + const res = await auth.api.verifyApiKey({ + body: { key: "invalid-key" }, + }); + + expect(res.valid).toBe(false); + }); + + it("gets API key details", async () => { + const res = await auth.api.getApiKey({ + headers: authHeaders, + query: { id: apiKeyId }, + }); + + expect(res).toBeDefined(); + expect(res.id).toBe(apiKeyId); + expect(res.name).toBe("Test Auth Key"); + expect(res.userId).toBe(userId); + }); + + it("updates API key", async () => { + const res = await auth.api.updateApiKey({ + headers: authHeaders, + body: { + keyId: apiKeyId, + name: "Updated API Key Name", + }, + }); + + expect(res).toBeDefined(); + expect(res.name).toBe("Updated API Key Name"); + }); + }); + + describe("API Key Management", () => { + it("manages multiple API keys correctly", async () => { + // Create multiple keys + const key1 = await auth.api.createApiKey({ + headers: authHeaders, + body: { name: "Key 1" }, + }); + + const key2 = await auth.api.createApiKey({ + headers: authHeaders, + body: { name: "Key 2" }, + }); + + // List keys + const keys = await auth.api.listApiKeys({ + headers: authHeaders, + }); + + expect(keys.length).toBeGreaterThanOrEqual(2); + expect(keys.find((k) => k.id === key1.id)).toBeDefined(); + expect(keys.find((k) => k.id === key2.id)).toBeDefined(); + + // Delete one key + await auth.api.deleteApiKey({ + headers: authHeaders, + body: { keyId: key1.id }, + }); + + // Verify it's removed + const updatedKeys = await auth.api.listApiKeys({ + headers: authHeaders, + }); + + expect(updatedKeys.find((k) => k.id === key1.id)).toBeUndefined(); + expect(updatedKeys.find((k) => k.id === key2.id)).toBeDefined(); + }); + }); +}); diff --git a/api/src/test/auth/integration.test.ts b/api/src/test/auth/integration.test.ts new file mode 100644 index 0000000..1ec6be9 --- /dev/null +++ b/api/src/test/auth/integration.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { auth } from "@/lib/auth"; +import { AuthTestHelper } from "../utils"; + +describe("Authentication Integration Flow", () => { + let authHelper: AuthTestHelper; + + beforeEach(() => { + authHelper = new AuthTestHelper(); + }); + + it("completes full auth flow: signup -> signin -> create API key -> use API key -> cleanup", async () => { + // 1. Create and authenticate user + const { user, userId, authHeaders } = + await authHelper.createAuthenticatedUser({ + email: `integration-${Date.now()}@example.com`, + name: "Integration Test User", + username: `integrationuser${Date.now()}`, + }); + + // 2. Create API key using session auth + const apiKeyRes = await authHelper.createApiKey(authHeaders, { + name: "Integration Test Key", + }); + + expect(apiKeyRes.id).toBeDefined(); + expect(apiKeyRes.key).toBeDefined(); + expect(apiKeyRes.userId).toBe(userId); + expect(apiKeyRes.name).toBe("Integration Test Key"); + + // 3. Verify API key with verification endpoint + const verifyRes = await auth.api.verifyApiKey({ + body: { key: apiKeyRes.key }, + }); + + expect(verifyRes.valid).toBe(true); + + // 4. List API keys to verify it appears + const keysRes = await auth.api.listApiKeys({ + headers: authHeaders, + }); + + expect(keysRes).toBeDefined(); + expect(Array.isArray(keysRes)).toBe(true); + expect(keysRes.length).toBe(1); + expect(keysRes?.[0]?.id).toBe(apiKeyRes.id); + + // 5. Delete API key + const deleteRes = await auth.api.deleteApiKey({ + headers: authHeaders, + body: { keyId: apiKeyRes.id }, + }); + + expect(deleteRes.success).toBe(true); + + // 6. Verify API key no longer works + const invalidVerifyRes = await auth.api.verifyApiKey({ + body: { key: apiKeyRes.key }, + }); + + expect(invalidVerifyRes.valid).toBe(false); + + // 7. Cleanup user + await authHelper.cleanupUser(authHeaders, user.password); + }); + + it("handles multiple concurrent API keys per user", async () => { + const { user, authHeaders } = await authHelper.createAuthenticatedUser(); + + // Create multiple API keys + const [key1, key2, key3] = await Promise.all([ + authHelper.createApiKey(authHeaders, { name: "Key 1" }), + authHelper.createApiKey(authHeaders, { name: "Key 2" }), + authHelper.createApiKey(authHeaders, { name: "Key 3" }), + ]); + + // Verify all keys work + const [verify1, verify2, verify3] = await Promise.all([ + auth.api.verifyApiKey({ body: { key: key1.key } }), + auth.api.verifyApiKey({ body: { key: key2.key } }), + auth.api.verifyApiKey({ body: { key: key3.key } }), + ]); + + expect(verify1.valid).toBe(true); + expect(verify2.valid).toBe(true); + expect(verify3.valid).toBe(true); + + // List all keys + const allKeys = await auth.api.listApiKeys({ headers: authHeaders }); + expect(allKeys.length).toBe(3); + + // Delete one key + await auth.api.deleteApiKey({ + headers: authHeaders, + body: { keyId: key2.id }, + }); + + // Verify only 2 keys remain + const remainingKeys = await auth.api.listApiKeys({ headers: authHeaders }); + expect(remainingKeys.length).toBe(2); + expect(remainingKeys.find((k) => k.id === key2.id)).toBeUndefined(); + + // Verify deleted key doesn't work + const deletedVerify = await auth.api.verifyApiKey({ + body: { key: key2.key }, + }); + expect(deletedVerify.valid).toBe(false); + + // Verify other keys still work + const [stillWorking1, stillWorking3] = await Promise.all([ + auth.api.verifyApiKey({ body: { key: key1.key } }), + auth.api.verifyApiKey({ body: { key: key3.key } }), + ]); + + expect(stillWorking1.valid).toBe(true); + expect(stillWorking3.valid).toBe(true); + + // Cleanup + await authHelper.cleanupUser(authHeaders, user.password); + }); +}); diff --git a/api/src/test/auth/user.test.ts b/api/src/test/auth/user.test.ts new file mode 100644 index 0000000..27085d7 --- /dev/null +++ b/api/src/test/auth/user.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test"; +import { db } from "@/db"; +import { auth } from "@/lib/auth"; +import { buildHeaders } from "../utils"; + +describe("User Authentication", () => { + const user = { + email: "test-user@example.com", + password: "password123", + name: "Test User", + username: "testuser", + }; + + let authHeaders: Headers; + + describe("User Registration", () => { + it("creates a new user successfully", async () => { + const res = await auth.api.signUpEmail({ + body: { + ...user, + }, + }); + expect(res.user.email).toBe(user.email); + expect(res.user.name).toBe(user.name); + // Note: Username field may not be returned in the response even if set + + const foundUser = await db.query.user.findFirst({ + where: (users, { eq }) => eq(users.email, user.email), + }); + expect(foundUser).not.toBeNull(); + expect(foundUser?.email).toBe(user.email); + }); + + it("prevents duplicate user registration", async () => { + try { + await auth.api.signUpEmail({ + body: { + ...user, + }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("User Login", () => { + it("logs in user with correct credentials", async () => { + const { headers: signInHeaders, response: signInRes } = + await auth.api.signInEmail({ + body: { + email: user.email, + password: user.password, + }, + returnHeaders: true, + }); + + expect(signInRes.token).not.toBeNull(); + expect(signInRes.user.name).toBe(user.name); + expect(signInRes.user.email).toBe(user.email); + + authHeaders = buildHeaders(signInHeaders); + }); + + it("rejects login with incorrect password", async () => { + try { + await auth.api.signInEmail({ + body: { + email: user.email, + password: "wrongpassword", + }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it("rejects login with non-existent email", async () => { + try { + await auth.api.signInEmail({ + body: { + email: "nonexistent@example.com", + password: user.password, + }, + }); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("Session Management", () => { + it("retrieves valid session with auth headers", async () => { + const res = await auth.api.getSession({ headers: authHeaders }); + expect(res?.user.username).toBe(user.username); + expect(res?.user.email).toBe(user.email); + }); + + it("returns null for session without auth headers", async () => { + const res = await auth.api.getSession({ headers: new Headers() }); + expect(res?.user).toBeUndefined(); + }); + }); + + describe("User Deletion", () => { + it("deletes user with correct password", async () => { + const deleteRes = await auth.api.deleteUser({ + headers: authHeaders, + body: { + password: user.password, + }, + }); + expect(deleteRes.success).toBe(true); + + // Verify user is actually deleted + const foundUser = await db.query.user.findFirst({ + where: (users, { eq }) => eq(users.email, user.email), + }); + expect(foundUser).toBeUndefined(); + }); + }); +}); diff --git a/api/src/test/setup.ts b/api/src/test/setup.ts new file mode 100644 index 0000000..c628516 --- /dev/null +++ b/api/src/test/setup.ts @@ -0,0 +1,17 @@ +import { afterAll, beforeAll } from "bun:test"; +import { migrate } from "drizzle-orm/bun-sql/migrator"; +import { db } from "@/db"; + +beforeAll(async () => { + await migrate(db, { migrationsFolder: "./drizzle" }); +}); + +afterAll(async () => { + // Clear all test data from database - note: order matters due to foreign keys + const { session, apikey, account, user } = await import("@/db/schema/auth"); + + await db.delete(session).execute(); + await db.delete(apikey).execute(); + await db.delete(account).execute(); + await db.delete(user).execute(); +}); diff --git a/api/src/test/utils.ts b/api/src/test/utils.ts index 82ba53c..8e359fb 100644 --- a/api/src/test/utils.ts +++ b/api/src/test/utils.ts @@ -1,3 +1,6 @@ +import { db } from "@/db"; +import { auth } from "@/lib/auth"; + export function buildHeaders(signInHeaders: Headers) { const headers = new Headers(); for (const cookie of signInHeaders.getSetCookie() ?? []) { @@ -5,3 +8,94 @@ export function buildHeaders(signInHeaders: Headers) { } return headers; } + +export interface TestUser { + email: string; + password: string; + name: string; + username: string; +} + +export class AuthTestHelper { + createTestUser(userOverrides: Partial = {}): TestUser { + const defaultUser: TestUser = { + email: `test-${Date.now()}@example.com`, + password: "password123", + name: "Test User", + username: `testuser${Date.now()}`, + }; + + return { ...defaultUser, ...userOverrides }; + } + + async signUpUser(user: TestUser) { + return await auth.api.signUpEmail({ + body: user, + }); + } + + async signInUser(user: Pick) { + return await auth.api.signInEmail({ + body: { + email: user.email, + password: user.password, + }, + returnHeaders: true, + }); + } + + async createAuthenticatedUser(userOverrides: Partial = {}) { + const user = this.createTestUser(userOverrides); + const signUpRes = await this.signUpUser(user); + const { headers: signInHeaders } = await this.signInUser(user); + const authHeaders = buildHeaders(signInHeaders); + + return { + user, + userId: signUpRes.user.id, + authHeaders, + }; + } + + async cleanupUser(authHeaders: Headers, password: string) { + try { + await auth.api.deleteUser({ + headers: authHeaders, + body: { password }, + }); + } catch (_) { + // User might already be deleted, ignore error + } + } + + async createApiKey( + authHeaders: Headers, + keyOptions: Partial< + Parameters["0"]["body"] + > = {}, + ) { + return await auth.api.createApiKey({ + headers: authHeaders, + body: { + name: `Test API Key ${Date.now()}`, + ...keyOptions, + }, + }); + } + + createApiKeyHeaders(apiKey: string) { + const headers = new Headers(); + headers.set("Authorization", `Bearer ${apiKey}`); + return headers; + } + + async clearDatabase() { + // Clear all test data from database - note: order matters due to foreign keys + const { session, apikey, account, user } = await import("@/db/schema/auth"); + + await db.delete(session).execute(); + await db.delete(apikey).execute(); + await db.delete(account).execute(); + await db.delete(user).execute(); + } +}