diff --git a/tests/api/.env.development b/tests/api/.env.development index a7f716cdf..ab6ba03ed 100644 --- a/tests/api/.env.development +++ b/tests/api/.env.development @@ -20,3 +20,4 @@ AWS_REGION=eu-west-1 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= +NAME_SLUG= diff --git a/tests/api/helpers/hasura.ts b/tests/api/helpers/hasura.ts index 803e7da40..4dc6fc4a2 100644 --- a/tests/api/helpers/hasura.ts +++ b/tests/api/helpers/hasura.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { GraphQLClient } from "graphql-request"; // GraphQL client with admin privileges for creating test data @@ -75,6 +76,14 @@ const DELETE_USER_MUTATION = ` } `; +const FIND_USER_BY_AUTH0_QUERY = ` + query FindUserByAuth0Id($auth0Id: String!) { + user(where: {auth0Id: {_eq: $auth0Id}}) { + id + } + } +`; + // Simple GraphQL mutation for creating app_metadata const CREATE_APP_METADATA_MUTATION = ` mutation CreateAppMetadata($object: app_metadata_insert_input!) { @@ -235,13 +244,13 @@ export const createTestTeam = async (name: string) => { }; // Helper for creating test user -export const createTestUser = async (email: string, teamId: string) => { +export const createTestUser = async (email: string, teamId?: string) => { try { const response = (await adminGraphqlClient.request(CREATE_USER_MUTATION, { object: { email, auth0Id: `auth0|test_${Date.now()}`, - team_id: teamId, + ...(teamId && { team_id: teamId }), ironclad_id: `ironclad_test_${Date.now()}`, world_id_nullifier: `0x${Date.now().toString(16)}`, }, @@ -285,6 +294,26 @@ export const deleteTestTeam = async (teamId: string) => { } }; +// Helper for finding user by auth0Id +export const findUserByAuth0Id = async (auth0Id: string) => { + try { + const response = (await adminGraphqlClient.request( + FIND_USER_BY_AUTH0_QUERY, + { + auth0Id, + }, + )) as any; + + return response.user?.[0]?.id || null; + } catch (error: any) { + const errorMessage = + error?.response?.data?.message || error?.message || "Unknown error"; + throw new Error( + `Failed to find user by auth0Id ${auth0Id}: ${errorMessage}`, + ); + } +}; + // Helper for deleting test user export const deleteTestUser = async (userId: string) => { try { @@ -455,30 +484,54 @@ export const deleteTestLocalisation = async (localisationId: string) => { } }; -// Helper for creating test API key +// Helper for creating test API key with real credentials for HTTP auth export const createTestApiKey = async ( teamId: string, name: string = "Test API Key", ) => { - try { - const response = (await adminGraphqlClient.request( - CREATE_API_KEY_MUTATION, - { - object: { - team_id: teamId, - name, - api_key: "test_hashed_secret_value", - is_active: true, - }, + const AUTH0_SECRET = process.env.AUTH0_SECRET; + if (!AUTH0_SECRET) { + throw new Error("AUTH0_SECRET env var must be set for tests"); + } + + // Step 1: Generate friendly ID in Hasura format (key_xxxxxxxxxx) + const randomId = crypto.randomBytes(8).toString("hex"); + const keyId = `key_${randomId}`; + + // Step 2: Generate secret and hash using the pre-generated ID + const secret = `sk_${crypto.randomBytes(24).toString("hex")}`; + const hmac = crypto.createHmac("sha256", AUTH0_SECRET); + hmac.update(`${keyId}.${secret}`); + const hashed_secret = hmac.digest("hex"); + + // Step 3: Create API key record with the generated ID and hash in one operation + const createResponse = (await adminGraphqlClient.request( + CREATE_API_KEY_MUTATION, + { + object: { + id: keyId, // Use our pre-generated ID + team_id: teamId, + api_key: hashed_secret, + name, + is_active: true, }, - )) as any; + }, + )) as any; - return response.insert_api_key_one?.id; - } catch (error: any) { - const errorMessage = - error?.response?.data?.message || error?.message || "Unknown error"; - throw new Error(`Failed to create test API key: ${errorMessage}`); + if (!createResponse.insert_api_key_one?.id) { + throw new Error("Failed to create API key record"); } + + // Step 4: Create proper API key header format for HTTP auth + const credentials = `${keyId}:${secret}`; + const encodedCredentials = Buffer.from(credentials).toString("base64"); + const apiKeyHeader = `api_${encodedCredentials}`; + + return { + apiKeyId: keyId, + secret, + apiKeyHeader, + }; }; // Helper for deleting test API key diff --git a/tests/api/jest.config.ts b/tests/api/jest.config.ts index 00a873572..8cbff830b 100644 --- a/tests/api/jest.config.ts +++ b/tests/api/jest.config.ts @@ -7,7 +7,7 @@ dotenv.config({ path: ".env.development" }); const config: Config = { preset: "ts-jest", - setupFilesAfterEnv: ["jest-expect-message"], + setupFilesAfterEnv: ["jest-expect-message", "/jest.setup.ts"], testMatch: ["/specs/**/*.spec.ts"], testTimeout: 30000, moduleDirectories: ["node_modules", "", "/../../web"], diff --git a/tests/api/jest.setup.ts b/tests/api/jest.setup.ts new file mode 100644 index 000000000..a70ffbd0d --- /dev/null +++ b/tests/api/jest.setup.ts @@ -0,0 +1,16 @@ +// Global setup for API tests - validates required environment variables + +const requiredEnvVars = ["INTERNAL_API_URL", "NAME_SLUG"]; + +beforeAll(() => { + const missingEnvVars = requiredEnvVars.filter( + (envVar) => !process.env[envVar], + ); + + if (missingEnvVars.length > 0) { + throw new Error( + `Required environment variables are not set: ${missingEnvVars.join(", ")}\n` + + "Please check your environment configuration.", + ); + } +}); diff --git a/tests/api/specs/dev-portal-helpers/create-action.spec.ts b/tests/api/specs/dev-portal-helpers/create-action.spec.ts new file mode 100644 index 000000000..f9b3cf251 --- /dev/null +++ b/tests/api/specs/dev-portal-helpers/create-action.spec.ts @@ -0,0 +1,90 @@ +import axios from "axios"; +import { + createTestApiKey, + createTestApp, + createTestTeam, + createTestUser, + deleteTestAction, + deleteTestApiKey, + deleteTestApp, + deleteTestTeam, + deleteTestUser, +} from "helpers"; + +const INTERNAL_API_URL = process.env.INTERNAL_API_URL; +const NAME_SLUG = process.env.NAME_SLUG; + +describe("Dev Portal Helpers API Endpoints", () => { + describe("POST /api/v2/create-action/[app_id]", () => { + let cleanUpFunctions: Array<() => Promise> = []; + + afterEach(async () => { + await cleanUpFunctions.reduce>( + (promise, callback) => promise.then(() => callback()), + Promise.resolve(), + ); + + cleanUpFunctions = []; + }); + + it("Create Action Successfully with API Key", async () => { + // Setup test data + const teamId = await createTestTeam("Test Team"); + cleanUpFunctions.push(async () => await deleteTestTeam(teamId)); + + const userEmail = `qa+${NAME_SLUG}+${Date.now()}@toolsforhumanity.com`; + const userId = await createTestUser(userEmail, teamId); + cleanUpFunctions.push(async () => await deleteTestUser(userId)); + + const appId = await createTestApp("Test App", teamId); + cleanUpFunctions.push(async () => await deleteTestApp(appId)); + + // Create API key for authentication + const { apiKeyId, apiKeyHeader } = await createTestApiKey( + teamId, + "Test Key for Create Action", + ); + cleanUpFunctions.push(async () => await deleteTestApiKey(apiKeyId)); + + // Test data + const actionData = { + action: `test_action_${Date.now()}`, + name: "Test Action", + description: "Test action description", + max_verifications: 5, + }; + + const response = await axios.post( + `${INTERNAL_API_URL}/api/v2/create-action/${appId}`, + actionData, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKeyHeader}`, + }, + }, + ); + + expect(response.status).toBe(200); + expect(response.data).toEqual( + expect.objectContaining({ + action: expect.objectContaining({ + id: expect.any(String), + action: actionData.action, + name: actionData.name, + description: actionData.description, + max_verifications: actionData.max_verifications, + external_nullifier: expect.any(String), + status: "active", + }), + }), + ); + + // Extract action_id from response for cleanup + const createdActionId = response.data.action.id; + cleanUpFunctions.push( + async () => await deleteTestAction(createdActionId), + ); + }); + }); +}); diff --git a/tests/api/specs/dev-portal-helpers/create-team.spec.ts b/tests/api/specs/dev-portal-helpers/create-team.spec.ts new file mode 100644 index 000000000..4354b7234 --- /dev/null +++ b/tests/api/specs/dev-portal-helpers/create-team.spec.ts @@ -0,0 +1,136 @@ +import axios from "axios"; +import { + createTestUser, + deleteTestTeam, + deleteTestUser, + findUserByAuth0Id, +} from "helpers"; +import { createAppSession } from "helpers/auth0"; + +const INTERNAL_API_URL = process.env.INTERNAL_API_URL; +const NAME_SLUG = process.env.NAME_SLUG; + +describe("Dev Portal Helpers API Endpoints", () => { + describe("POST /api/create-team", () => { + let cleanUpFunctions: Array<() => Promise> = []; + + afterEach(async () => { + await cleanUpFunctions.reduce>( + (promise, callback) => promise.then(() => callback()), + Promise.resolve(), + ); + + cleanUpFunctions = []; + }); + + it("Create team successfully for existing user", async () => { + const userEmail = `qa+${NAME_SLUG}+${Date.now()}@toolsforhumanity.com`; + const testUserId = await createTestUser(userEmail); + + // Add cleanup functions + cleanUpFunctions.push(async () => await deleteTestUser(testUserId)); + + // Generate unique auth0Id to prevent constraint violations + const uniqueAuth0Id = `auth0|test_existing_user_${Date.now()}`; + + const existingUserSession = await createAppSession({ + user: { + sub: uniqueAuth0Id, + email: userEmail, + hasura: { + id: testUserId, + }, + }, + }); + + const teamData = { + team_name: "Test Team for API Tests", + hasUser: true, + }; + + const response = await axios.post( + `${INTERNAL_API_URL}/api/create-team`, + teamData, + { + headers: { + "Content-Type": "application/json", + Cookie: existingUserSession, + }, + }, + ); + + expect( + response.status, + `Create team request resolved with a wrong code:\n${JSON.stringify(response.data, null, 2)}`, + ).toBe(200); + + expect(response.data).toEqual( + expect.objectContaining({ + returnTo: expect.stringMatching(/^\/teams\/team_[a-f0-9]{32}$/), + }), + ); + + // Extract team_id from returnTo URL for cleanup + const createdTeamId = response.data.returnTo.split("/teams/")[1]; + cleanUpFunctions.push(async () => await deleteTestTeam(createdTeamId)); + }); + + it("Create an initial team along with the user", async () => { + const userEmail = `qa+${NAME_SLUG}+${Date.now()}@toolsforhumanity.com`; + const teamData = { + team_name: "Test Team for New User", + hasUser: false, + }; + + // Generate unique auth0Id to prevent constraint violations + const uniqueAuth0Id = `auth0|test_new_user_${Date.now()}`; + + const auth0Session = { + user: { + sub: uniqueAuth0Id, + email: userEmail, + hasura: { + id: `test_hasura_user_id_${Date.now()}`, + }, + }, + }; + + const sessionCookie = await createAppSession(auth0Session); + + const response = await axios.post( + `${INTERNAL_API_URL}/api/create-team`, + teamData, + { + headers: { + "Content-Type": "application/json", + Cookie: sessionCookie, + }, + }, + ); + + expect( + response.status, + `Create team request resolved with a wrong code:\n${JSON.stringify(response.data, null, 2)}`, + ).toBe(200); + expect(response.data).toEqual( + expect.objectContaining({ + returnTo: expect.stringMatching( + /^\/teams\/team_[a-f0-9]{32}\/apps\/$/, + ), + }), + ); + + // Extract team_id from returnTo URL for cleanup + const createdTeamId = response.data.returnTo + .split("/teams/")[1] + .split("/apps/")[0]; + cleanUpFunctions.push(async () => await deleteTestTeam(createdTeamId)); + + // Find and cleanup the created user by auth0Id + const createdUserId = await findUserByAuth0Id(uniqueAuth0Id); + if (createdUserId) { + cleanUpFunctions.push(async () => await deleteTestUser(createdUserId)); + } + }); + }); +}); diff --git a/tests/api/specs/dev-portal-helpers/graphql-proxy.spec.ts b/tests/api/specs/dev-portal-helpers/graphql-proxy.spec.ts new file mode 100644 index 000000000..4e0f0952e --- /dev/null +++ b/tests/api/specs/dev-portal-helpers/graphql-proxy.spec.ts @@ -0,0 +1,139 @@ +import axios from "axios"; +import { + createTestApiKey, + createTestApp, + createTestTeam, + createTestUser, + deleteTestApiKey, + deleteTestApp, + deleteTestTeam, + deleteTestUser, +} from "helpers"; +import { createAppSession } from "helpers/auth0"; + +const INTERNAL_API_URL = process.env.INTERNAL_API_URL; +const INTERNAL_ENDPOINTS_SECRET = process.env.INTERNAL_ENDPOINTS_SECRET; +const NAME_SLUG = process.env.NAME_SLUG; + +describe("Dev Portal Helpers API Endpoints", () => { + describe("POST /api/v1/graphql", () => { + let cleanUpFunctions: Array<() => Promise> = []; + + afterEach(async () => { + await cleanUpFunctions.reduce>( + (promise, callback) => promise.then(() => callback()), + Promise.resolve(), + ); + + cleanUpFunctions = []; + }); + + describe("API Key Authentication", () => { + it("Should authenticate with API key and proxy GraphQL request", async () => { + // Setup test data + const teamId = await createTestTeam("Test Team GraphQL"); + cleanUpFunctions.push(async () => await deleteTestTeam(teamId)); + + const userEmail = `qa+${NAME_SLUG}+${Date.now()}@toolsforhumanity.com`; + const userId = await createTestUser(userEmail, teamId); + cleanUpFunctions.push(async () => await deleteTestUser(userId)); + + const appId = await createTestApp("Test App GraphQL", teamId); + cleanUpFunctions.push(async () => await deleteTestApp(appId)); + + // Create API key for authentication + const { apiKeyId, apiKeyHeader } = await createTestApiKey( + teamId, + "Test Key for GraphQL", + ); + cleanUpFunctions.push(async () => await deleteTestApiKey(apiKeyId)); + + // Test GraphQL query - simple query to get teams + const graphqlQuery = { + query: ` + query GetTeams { + team(limit: 1) { + id + } + } + `, + }; + + const response = await axios.post( + `${INTERNAL_API_URL}/api/v1/graphql`, + graphqlQuery, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKeyHeader}`, + }, + }, + ); + + expect(response.status).toBe(200); + const returnedTeamIds = response.data.data.team.map( + (team: any) => team.id, + ); + expect(returnedTeamIds).toContain(teamId); + }); + }); + + describe("Auth0 Session Authentication", () => { + it("Should authenticate with Auth0 session cookie and proxy GraphQL request", async () => { + // Setup test data for Auth0 user + const teamId = await createTestTeam("Test Team Auth0 GraphQL"); + cleanUpFunctions.push(async () => await deleteTestTeam(teamId)); + + const userEmail = `qa+${NAME_SLUG}+${Date.now()}@toolsforhumanity.com`; + const userId = await createTestUser(userEmail, teamId); + cleanUpFunctions.push(async () => await deleteTestUser(userId)); + + // Create Auth0 session + const mockAuth0Session = { + user: { + sub: `auth0|test_${Date.now()}`, + hasura: { + id: userId, + }, + }, + }; + + const sessionCookie = await createAppSession(mockAuth0Session); + + // Test GraphQL query with Auth0 session + const graphqlQuery = { + query: ` + query GetUser($userId: String!) { + user_by_pk(id: $userId) { + id + email + } + } + `, + variables: { + userId: userId, + }, + }; + + const response = await axios.post( + `${INTERNAL_API_URL}/api/v1/graphql`, + graphqlQuery, + { + headers: { + "Content-Type": "application/json", + Cookie: sessionCookie, + }, + }, + ); + + expect(response.status).toBe(200); + expect(response.data).toHaveProperty("data"); + expect(response.data.data).toHaveProperty("user_by_pk"); + expect(response.data.data.user_by_pk).toMatchObject({ + id: userId, + email: userEmail, + }); + }); + }); + }); +}); diff --git a/tests/api/specs/hasura/reset-api-key.spec.ts b/tests/api/specs/hasura/reset-api-key.spec.ts index 60141eb50..ab7076ec2 100644 --- a/tests/api/specs/hasura/reset-api-key.spec.ts +++ b/tests/api/specs/hasura/reset-api-key.spec.ts @@ -63,10 +63,11 @@ describe("Hasura API - Reset API Key", () => { testMetadataId = metadata.id; // Create test API key - testApiKeyId = await createTestApiKey( + const apiKeyData = await createTestApiKey( testTeamId!, "Test API Key for Reset", ); + testApiKeyId = apiKeyData.apiKeyId; }); it("Reset API Key Successfully", async () => { diff --git a/tests/api/specs/helpers/team.spec.ts b/tests/api/specs/helpers/team.spec.ts deleted file mode 100644 index 9ce3529e8..000000000 --- a/tests/api/specs/helpers/team.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import axios from "axios"; -import { deleteTestTeam } from "helpers"; -import { createAppSession } from "helpers/auth0"; - -describe.skip("Team Actions", () => { - const INTERNAL_API_URL = process.env.INTERNAL_API_URL; - let cleanUpFunctions: Array<() => Promise> = []; - let sessionCookie: string; - - beforeAll(async () => { - if (!INTERNAL_API_URL) { - throw new Error("INTERNAL_API_URL environment variable is not set!"); - } - - sessionCookie = await createAppSession({ - user: { - sub: process.env.TEST_USER_AUTH0_ID, - hasura: { - id: process.env.TEST_USER_HASURA_ID, - }, - }, - }); - }); - - afterEach(async () => { - await cleanUpFunctions.reduce>( - (promise, callback) => promise.then(() => callback()), - Promise.resolve(), - ); - - cleanUpFunctions = []; - }); - - it("Create a team", async () => { - const response = await axios.post( - `${INTERNAL_API_URL}/api/create-team`, - { - team_name: "My team 1", - hasUser: true, - }, - { - headers: { - "Content-Type": "application/json", - Cookie: sessionCookie, - }, - validateStatus: () => true, - }, - ); - - const body = response.data; - expect( - response.status, - `Create Team response body: ${JSON.stringify(body)}`, - ).toBe(200); - - if (body.returnTo && typeof body.returnTo === "string") { - cleanUpFunctions.push(async () => { - const teamId = response.data.returnTo.split("/teams/")[1]; - await deleteTestTeam(teamId); - }); - } - }); -});