diff --git a/.gitignore b/.gitignore index 8d1cac7..2c6cdd3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ dist-ssr # Environment files .env .env.local + +# ai +*.pbmd diff --git a/README.md b/README.md index c1d5f84..c6caa58 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,12 @@ To test the library, run the following command: bun test --env-file .env.local ``` +To test a specific file or test case: + +```bash +bun test --test-name-pattern="Developer login and token storage" src/lib/developer.test.ts --env-file .env.local +``` + Currently this build step requires `npx` because of [a Bun incompatibility with `vite-plugin-dts`](https://github.com/OpenSecretCloud/OpenSecret-SDK/issues/16). To pack the library (for publishing) run the following command: diff --git a/bun.lockb b/bun.lockb index c15e223..e0815a6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md new file mode 100644 index 0000000..cd43113 --- /dev/null +++ b/docs/PLATFORM.md @@ -0,0 +1,417 @@ +# Developer Platform API + +The Developer Platform API allows developers to manage organizations, projects, secrets, and user access within the OpenSecret platform. This API is specifically designed for developers integrating OpenSecret into their applications and platforms. + +### `OpenSecretDeveloper` + +The `OpenSecretDeveloper` component is the provider for developer-specific platform operations. It requires the URL of the OpenSecret developer API. + +```tsx +import { OpenSecretDeveloper } from "@opensecret/react"; + +function App() { + return ( + + + + ); +} +``` + +### Developer Authentication + +Before using the developer platform APIs, you need to authenticate. The SDK provides authentication methods through the `useOpenSecretDeveloper` hook: + +```tsx +import { useOpenSecretDeveloper } from "@opensecret/react"; + +function DeveloperLogin() { + const dev = useOpenSecretDeveloper(); + + // Login with existing developer account + async function handleLogin() { + try { + const response = await dev.signIn("developer@example.com", "yourpassword"); + console.log("Login successful", response); + // Now you can use the developer context APIs + // Authentication state is automatically updated + } catch (error) { + console.error("Login failed:", error); + } + } + + // Register a new developer account + async function handleRegister() { + try { + const response = await dev.signUp( + "developer@example.com", + "yourpassword", + "Developer Name" // Optional + ); + console.log("Registration successful", response); + // Now you can use the developer context APIs + // Authentication state is automatically updated + } catch (error) { + console.error("Registration failed:", error); + } + } + + // Sign out + async function handleLogout() { + try { + await dev.signOut(); + // The developer context will update automatically + } catch (error) { + console.error("Logout failed:", error); + } + } + + return ( +
+ {/* Login/Register UI */} +
+ ); +} +``` + +When a developer successfully logs in or registers, the authentication tokens are stored in localStorage and managed by the SDK. The `OpenSecretDeveloper` provider automatically detects these tokens and loads the developer profile. You can check the authentication state using the `auth` property: + +```tsx +const dev = useOpenSecretDeveloper(); + +// Check if developer is loaded and authenticated +if (!dev.auth.loading && dev.auth.developer) { + console.log("Developer is authenticated:", dev.auth.developer.email); +} else if (!dev.auth.loading) { + console.log("Developer is not authenticated"); +} +``` + +### `useOpenSecretDeveloper` + +The `useOpenSecretDeveloper` hook provides access to all developer platform management APIs. It returns an object with the following properties and methods: + +```tsx +import { useOpenSecretDeveloper } from "@opensecret/react"; + +function PlatformManagement() { + const dev = useOpenSecretDeveloper(); + + // Access developer information + const { loading, developer } = dev.auth; + + // Now you can use any of the platform management methods + // ... +} +``` + +#### Developer State + +- `auth`: An object containing the current developer's information + - `loading`: Boolean indicating whether developer information is being loaded + - `developer`: Developer data (undefined if not logged in) including: + - `id`: Developer's unique ID + - `email`: Developer's email address + - `name`: Developer's name (optional) + - `organizations`: Array of organizations the developer belongs to +- `apiUrl`: The current OpenSecret developer API URL being used + +#### Developer Authentication + +- `signIn(email: string, password: string): Promise`: Signs in a developer with the provided email and password. Returns a response containing access and refresh tokens. The authentication state is automatically updated. +- `signUp(email: string, password: string, name?: string): Promise`: Registers a new developer account with the provided email, password, and optional name. Returns a response containing access and refresh tokens. The authentication state is automatically updated. +- `signOut(): Promise`: Signs out the current developer by removing authentication tokens and making a server logout call. +- `refetchDeveloper(): Promise`: Refreshes the developer's authentication state. Useful after making changes that affect developer profile or organization membership. + +#### Attestation Verification + +- `pcrConfig`: An object containing additional PCR0 hashes to validate against. +- `getAttestation`: Gets attestation from the enclave. +- `authenticate`: Authenticates an attestation document. +- `parseAttestationForView`: Parses an attestation document for viewing. +- `awsRootCertDer`: AWS root certificate in DER format. +- `expectedRootCertHash`: Expected hash of the AWS root certificate. +- `getAttestationDocument()`: Gets and verifies an attestation document from the enclave. This is a convenience function that: + 1. Fetches the attestation document with a random nonce + 2. Authenticates the document + 3. Parses it for viewing + +#### Organization Management + +- `createOrganization(name: string): Promise`: Creates a new organization with the given name. +- `listOrganizations(): Promise`: Lists all organizations the developer has access to. +- `deleteOrganization(orgId: string): Promise`: Deletes an organization (requires owner role). + +Example: +```tsx +const handleCreateOrg = async () => { + try { + const org = await dev.createOrganization("My New Organization"); + console.log("Created organization:", org); + } catch (error) { + console.error("Failed to create organization:", error); + } +}; +``` + +#### Project Management + +- `createProject(orgId: string, name: string, description?: string): Promise`: Creates a new project within an organization. +- `listProjects(orgId: string): Promise`: Lists all projects within an organization. +- `getProject(orgId: string, projectId: string): Promise`: Gets a single project by ID. +- `updateProject(orgId: string, projectId: string, updates: { name?: string; description?: string; status?: string }): Promise`: Updates project details. +- `deleteProject(orgId: string, projectId: string): Promise`: Deletes a project. + +Example: +```tsx +const handleCreateProject = async (orgId) => { + try { + const project = await dev.createProject( + orgId, + "My New Project", + "A description of my project" + ); + console.log("Created project:", project); + // project.client_id can be used as the clientId for OpenSecretProvider + } catch (error) { + console.error("Failed to create project:", error); + } +}; + +const handleGetProject = async (orgId, projectId) => { + try { + const project = await dev.getProject(orgId, projectId); + console.log("Retrieved project details:", project); + } catch (error) { + console.error("Failed to get project:", error); + } +}; +``` + +#### Project Secrets Management + +- `createProjectSecret(orgId: string, projectId: string, keyName: string, secret: string): Promise`: Creates a new secret for a project. The secret must be base64 encoded. +- `listProjectSecrets(orgId: string, projectId: string): Promise`: Lists all secrets for a project. +- `deleteProjectSecret(orgId: string, projectId: string, keyName: string): Promise`: Deletes a project secret. + +Example: +```tsx +import { encode } from "@stablelib/base64"; + +const handleCreateSecret = async (orgId, projectId) => { + // Encode the secret + const secretValue = "my-secret-value"; + const encodedSecret = encode(new TextEncoder().encode(secretValue)); + + try { + await dev.createProjectSecret( + orgId, + projectId, + "API_KEY", + encodedSecret + ); + console.log("Secret created successfully"); + } catch (error) { + console.error("Failed to create secret:", error); + } +}; +``` + +#### Email Configuration + +- `getEmailSettings(orgId: string, projectId: string): Promise`: Gets email configuration for a project. +- `updateEmailSettings(orgId: string, projectId: string, settings: EmailSettings): Promise`: Updates email configuration. + +Example: +```tsx +const handleUpdateEmailSettings = async (orgId, projectId) => { + try { + await dev.updateEmailSettings(orgId, projectId, { + provider: "resend", + send_from: "noreply@yourdomain.com", + email_verification_url: "https://yourdomain.com/verify-email" + }); + console.log("Email settings updated"); + } catch (error) { + console.error("Failed to update email settings:", error); + } +}; +``` + +#### OAuth Configuration + +- `getOAuthSettings(orgId: string, projectId: string): Promise`: Gets OAuth settings for a project. +- `updateOAuthSettings(orgId: string, projectId: string, settings: OAuthSettings): Promise`: Updates OAuth configuration. + +Example: +```tsx +const handleUpdateOAuthSettings = async (orgId, projectId) => { + try { + await dev.updateOAuthSettings(orgId, projectId, { + google_oauth_enabled: true, + github_oauth_enabled: false, + google_oauth_settings: { + client_id: "your-google-client-id", + redirect_url: "https://yourdomain.com/auth/google/callback" + } + }); + console.log("OAuth settings updated"); + } catch (error) { + console.error("Failed to update OAuth settings:", error); + } +}; +``` + +#### Developer Membership Management + +- `inviteDeveloper(orgId: string, email: string, role?: string): Promise<{ code: string }>`: Creates an invitation to join an organization. +- `listOrganizationMembers(orgId: string): Promise`: Lists all members of an organization. +- `updateMemberRole(orgId: string, userId: string, role: string): Promise`: Updates a member's role. +- `removeMember(orgId: string, userId: string): Promise`: Removes a member from the organization. +- `acceptInvite(code: string): Promise`: Accepts an organization invitation. + +Example: +```tsx +const handleInviteDeveloper = async (orgId) => { + try { + const result = await dev.inviteDeveloper( + orgId, + "developer@example.com", + "admin" // Possible roles: "owner", "admin", "developer", "viewer" + ); + console.log("Invitation sent with code:", result.code); + } catch (error) { + console.error("Failed to invite developer:", error); + } +}; +``` + +### Complete Example + +Here's a complete example of how to use the developer platform API: + +```tsx +import React, { useEffect, useState } from 'react'; +import { OpenSecretDeveloper, useOpenSecretDeveloper } from '@opensecret/react'; + +function DeveloperPortal() { + const dev = useOpenSecretDeveloper(); + const [orgs, setOrgs] = useState([]); + const [selectedOrg, setSelectedOrg] = useState(null); + const [projects, setProjects] = useState([]); + + useEffect(() => { + // Load organizations when developer is available + if (!dev.auth.loading && dev.auth.developer) { + loadOrganizations(); + } + }, [dev.auth.loading, dev.auth.developer]); + + async function loadOrganizations() { + try { + const orgs = await dev.listOrganizations(); + setOrgs(orgs); + } catch (error) { + console.error("Failed to load organizations:", error); + } + } + + async function loadProjects(orgId) { + try { + const projects = await dev.listProjects(orgId); + setProjects(projects); + } catch (error) { + console.error("Failed to load projects:", error); + } + } + + async function handleCreateProject() { + if (!selectedOrg) return; + + try { + await dev.createProject( + selectedOrg.id, + "New Project " + Date.now(), + "Created from developer portal" + ); + // Reload projects + loadProjects(selectedOrg.id); + } catch (error) { + console.error("Failed to create project:", error); + } + } + + return ( +
+

Developer Portal

+ {dev.auth.loading ? ( +

Loading...

+ ) : dev.auth.developer ? ( +
+

Welcome, {dev.auth.developer.name || dev.auth.developer.email}

+ +

Your Organizations

+
    + {orgs.map(org => ( +
  • + {org.name} + +
  • + ))} +
+ + + {selectedOrg && ( +
+

Projects in {selectedOrg.name}

+
    + {projects.map(project => ( +
  • + {project.name} - Client ID: {project.client_id} + +
  • + ))} +
+ +
+ )} +
+ ) : ( +

Please log in to access developer features

+ )} +
+ ); +} + +function App() { + return ( + + + + ); +} + +export default App; +``` diff --git a/package.json b/package.json index a3de0b9..09dce9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/react", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "type": "module", "files": [ @@ -24,8 +24,8 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" }, "peerDependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { "@peculiar/x509": "^1.12.2", @@ -41,8 +41,8 @@ "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@types/bun": "latest", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.3", "eslint": "^9.13.0", "eslint-plugin-react-hooks": "^5.0.0", diff --git a/src/lib/api.ts b/src/lib/api.ts index 0bea7c9..7eeaab6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -205,8 +205,9 @@ export async function requestNewVerificationCode(): Promise { ); } -export async function fetchAttestationDocument(nonce: string): Promise { - const response = await fetch(`${apiUrl}/attestation/${nonce}`); +export async function fetchAttestationDocument(nonce: string, explicitApiUrl?: string): Promise { + const url = explicitApiUrl || apiUrl; + const response = await fetch(`${url}/attestation/${nonce}`); if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); } @@ -216,9 +217,11 @@ export async function fetchAttestationDocument(nonce: string): Promise { export async function keyExchange( clientPublicKey: string, - nonce: string + nonce: string, + explicitApiUrl?: string ): Promise<{ encrypted_session_key: string; session_id: string }> { - const response = await fetch(`${apiUrl}/key_exchange`, { + const url = explicitApiUrl || apiUrl; + const response = await fetch(`${url}/key_exchange`, { method: "POST", headers: { "Content-Type": "application/json" diff --git a/src/lib/attestation.ts b/src/lib/attestation.ts index 6594927..bda8d78 100644 --- a/src/lib/attestation.ts +++ b/src/lib/attestation.ts @@ -257,12 +257,13 @@ async function fakeAuthenticate( return zodParsed; } -export async function verifyAttestation(nonce: string): Promise { +export async function verifyAttestation(nonce: string, explicitApiUrl?: string): Promise { try { - const attestationDocumentBase64 = await fetchAttestationDocument(nonce); + const attestationDocumentBase64 = await fetchAttestationDocument(nonce, explicitApiUrl); // Get the API URL from the API layer where it's already set - const apiUrl = getApiUrl(); + // First check explicit URL, then check both possible APIs + const apiUrl = explicitApiUrl || getApiUrl(); // With a local backend we get a fake attestation document, so we'll just pretend to authenticate it if ( diff --git a/src/lib/context.ts b/src/lib/context.ts index ca3d5ec..fa50cd8 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -3,8 +3,6 @@ import { OpenSecretContext, OpenSecretContextType } from "./main"; export function useOpenSecret(): OpenSecretContextType { const context = useContext(OpenSecretContext); - if (!context) { - throw new Error("useOpenSecret must be used within an OpenSecretProvider"); - } + // React 19 compatibility: Don't check for nullish context since the default value is provided return context; } diff --git a/src/lib/developer.tsx b/src/lib/developer.tsx new file mode 100644 index 0000000..4cf33bb --- /dev/null +++ b/src/lib/developer.tsx @@ -0,0 +1,551 @@ +import React, { createContext, useState, useEffect } from "react"; +import * as platformApi from "./platformApi"; +import { setPlatformApiUrl } from "./platformApi"; +import { getAttestation } from "./getAttestation"; +import { authenticate } from "./attestation"; +import { + parseAttestationForView, + AWS_ROOT_CERT_DER, + EXPECTED_ROOT_CERT_HASH, + ParsedAttestationView +} from "./attestationForView"; +import type { AttestationDocument } from "./attestation"; +import { PcrConfig } from "./pcr"; +import type { + Organization, + Project, + ProjectSecret, + ProjectSettings, + EmailSettings, + OAuthSettings, + OrganizationMember, + PlatformOrg, + PlatformUser +} from "./platformApi"; + +export type DeveloperRole = "owner" | "admin" | "developer" | "viewer"; + +export type OrganizationDetails = Organization; + +export type ProjectDetails = Project; + +export { type ProjectSettings }; + +export type DeveloperResponse = PlatformUser & { organizations: PlatformOrg[] }; + +export type OpenSecretDeveloperAuthState = { + loading: boolean; + developer?: DeveloperResponse; +}; + +export type OpenSecretDeveloperContextType = { + auth: OpenSecretDeveloperAuthState; + + /** + * Signs in a developer with email and password + * @param email - Developer's email address + * @param password - Developer's password + * @returns A promise that resolves to the login response with access and refresh tokens + * + * @description + * - Calls the login API endpoint + * - Stores access_token and refresh_token in localStorage + * - Updates the developer state with user information + * - Throws an error if authentication fails + */ + signIn: (email: string, password: string) => Promise; + + /** + * Registers a new developer account + * @param email - Developer's email address + * @param password - Developer's password + * @param name - Optional developer name + * @returns A promise that resolves to the login response with access and refresh tokens + * + * @description + * - Calls the registration API endpoint + * - Stores access_token and refresh_token in localStorage + * - Updates the developer state with new user information + * - Throws an error if account creation fails + */ + signUp: ( + email: string, + password: string, + name?: string + ) => Promise; + + /** + * Signs out the current developer by removing authentication tokens + * + * @description + * - Calls the logout API endpoint with the current refresh_token + * - Removes access_token, refresh_token from localStorage + * - Resets the developer state to show no user is authenticated + */ + signOut: () => Promise; + + /** + * Refreshes the developer's authentication state + * @returns A promise that resolves when the refresh is complete + * @throws {Error} If the refresh fails + * + * @description + * - Retrieves the latest developer information from the server + * - Updates the developer state with fresh data + * - Useful after making changes that affect developer profile or organization membership + */ + refetchDeveloper: () => Promise; + + /** + * Additional PCR0 hashes to validate against + */ + pcrConfig: PcrConfig; + + /** + * Gets attestation from the enclave + */ + getAttestation: typeof getAttestation; + + /** + * Authenticates an attestation document + */ + authenticate: typeof authenticate; + + /** + * Parses an attestation document for viewing + */ + parseAttestationForView: ( + document: AttestationDocument, + cabundle: Uint8Array[], + pcrConfig?: PcrConfig + ) => Promise; + + /** + * AWS root certificate in DER format + */ + awsRootCertDer: typeof AWS_ROOT_CERT_DER; + + /** + * Expected hash of the AWS root certificate + */ + expectedRootCertHash: typeof EXPECTED_ROOT_CERT_HASH; + + /** + * Gets and verifies an attestation document from the enclave + * @returns A promise resolving to the parsed attestation document + * @throws {Error} If attestation fails or is invalid + * + * @description + * This is a convenience function that: + * 1. Fetches the attestation document with a random nonce + * 2. Authenticates the document + * 3. Parses it for viewing + */ + getAttestationDocument: () => Promise; + + /** + * Creates a new organization + * @param name - Organization name + * @returns A promise that resolves to the created organization + */ + createOrganization: (name: string) => Promise; + + /** + * Lists all organizations the developer has access to + * @returns A promise resolving to array of organization details + */ + listOrganizations: () => Promise; + + /** + * Deletes an organization (requires owner role) + * @param orgId - Organization ID + */ + deleteOrganization: (orgId: string) => Promise; + + /** + * Creates a new project within an organization + * @param orgId - Organization ID + * @param name - Project name + * @param description - Optional project description + * @returns A promise that resolves to the project details including client ID + */ + createProject: (orgId: string, name: string, description?: string) => Promise; + + /** + * Lists all projects within an organization + * @param orgId - Organization ID + * @returns A promise resolving to array of project details + */ + listProjects: (orgId: string) => Promise; + + /** + * Gets a single project by ID + * @param orgId - Organization ID + * @param projectId - Project ID + * @returns A promise resolving to the project details + */ + getProject: (orgId: string, projectId: string) => Promise; + + /** + * Updates project details + * @param orgId - Organization ID + * @param projectId - Project ID + * @param updates - Object containing fields to update + */ + updateProject: ( + orgId: string, + projectId: string, + updates: { name?: string; description?: string; status?: string } + ) => Promise; + + /** + * Deletes a project + * @param orgId - Organization ID + * @param projectId - Project ID + */ + deleteProject: (orgId: string, projectId: string) => Promise; + + /** + * Creates a new secret for a project + * @param orgId - Organization ID + * @param projectId - Project ID + * @param keyName - Secret key name (must be alphanumeric) + * @param secret - Secret value (must be base64 encoded by the caller) + * + * Example: + * ```typescript + * // To encode a string secret + * import { encode } from "@stablelib/base64"; + * const encodedSecret = encode(new TextEncoder().encode("my-secret-value")); + * + * // Now pass the encoded secret to the function + * createProjectSecret(orgId, projectId, "mySecretKey", encodedSecret); + * ``` + */ + createProjectSecret: ( + orgId: string, + projectId: string, + keyName: string, + secret: string + ) => Promise; + + /** + * Lists all secrets for a project + * @param orgId - Organization ID + * @param projectId - Project ID + */ + listProjectSecrets: (orgId: string, projectId: string) => Promise; + + /** + * Deletes a project secret + * @param orgId - Organization ID + * @param projectId - Project ID + * @param keyName - Secret key name + */ + deleteProjectSecret: (orgId: string, projectId: string, keyName: string) => Promise; + + /** + * Gets email configuration for a project + * @param orgId - Organization ID + * @param projectId - Project ID + */ + getEmailSettings: (orgId: string, projectId: string) => Promise; + + /** + * Updates email configuration + * @param orgId - Organization ID + * @param projectId - Project ID + * @param settings - Email settings + */ + updateEmailSettings: ( + orgId: string, + projectId: string, + settings: EmailSettings + ) => Promise; + + /** + * Gets OAuth settings for a project + * @param orgId - Organization ID + * @param projectId - Project ID + */ + getOAuthSettings: (orgId: string, projectId: string) => Promise; + + /** + * Updates OAuth configuration + * @param orgId - Organization ID + * @param projectId - Project ID + * @param settings - OAuth settings + */ + updateOAuthSettings: ( + orgId: string, + projectId: string, + settings: OAuthSettings + ) => Promise; + + /** + * Creates an invitation to join an organization + * @param orgId - Organization ID + * @param email - Developer's email address + * @param role - Role to assign (defaults to "admin") + */ + inviteDeveloper: (orgId: string, email: string, role?: string) => Promise<{ code: string }>; + + /** + * Lists all members of an organization + * @param orgId - Organization ID + */ + listOrganizationMembers: (orgId: string) => Promise; + + /** + * Updates a member's role + * @param orgId - Organization ID + * @param userId - User ID to update + * @param role - New role to assign + */ + updateMemberRole: (orgId: string, userId: string, role: string) => Promise; + + /** + * Removes a member from the organization + * @param orgId - Organization ID + * @param userId - User ID to remove + */ + removeMember: (orgId: string, userId: string) => Promise; + + /** + * Accepts an organization invitation + * @param code - Invitation code + */ + acceptInvite: (code: string) => Promise; + + /** + * Returns the current OpenSecret developer API URL being used + */ + apiUrl: string; +}; + +export const OpenSecretDeveloperContext = createContext({ + auth: { + loading: true, + developer: undefined + }, + signIn: async () => { + throw new Error("signIn called outside of OpenSecretDeveloper provider"); + }, + signUp: async () => { + throw new Error("signUp called outside of OpenSecretDeveloper provider"); + }, + signOut: async () => { + throw new Error("signOut called outside of OpenSecretDeveloper provider"); + }, + refetchDeveloper: async () => { + throw new Error("refetchDeveloper called outside of OpenSecretDeveloper provider"); + }, + pcrConfig: {}, + getAttestation, + authenticate, + parseAttestationForView, + awsRootCertDer: AWS_ROOT_CERT_DER, + expectedRootCertHash: EXPECTED_ROOT_CERT_HASH, + getAttestationDocument: async () => { + throw new Error("getAttestationDocument called outside of OpenSecretDeveloper provider"); + }, + createOrganization: platformApi.createOrganization, + listOrganizations: platformApi.listOrganizations, + deleteOrganization: platformApi.deleteOrganization, + createProject: platformApi.createProject, + listProjects: platformApi.listProjects, + getProject: platformApi.getProject, + updateProject: platformApi.updateProject, + deleteProject: platformApi.deleteProject, + createProjectSecret: platformApi.createProjectSecret, + listProjectSecrets: platformApi.listProjectSecrets, + deleteProjectSecret: platformApi.deleteProjectSecret, + getEmailSettings: platformApi.getEmailSettings, + updateEmailSettings: platformApi.updateEmailSettings, + getOAuthSettings: platformApi.getOAuthSettings, + updateOAuthSettings: platformApi.updateOAuthSettings, + inviteDeveloper: platformApi.inviteDeveloper, + listOrganizationMembers: platformApi.listOrganizationMembers, + updateMemberRole: platformApi.updateMemberRole, + removeMember: platformApi.removeMember, + acceptInvite: platformApi.acceptInvite, + apiUrl: "" +}); + +/** + * Provider component for OpenSecret developer operations. + * This provider is used for managing organizations, projects, and developer access. + * + * @param props - Configuration properties for the OpenSecret developer provider + * @param props.children - React child components to be wrapped by the provider + * @param props.apiUrl - URL of OpenSecret developer API + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function OpenSecretDeveloper({ + children, + apiUrl, + pcrConfig = {} +}: { + children: React.ReactNode; + apiUrl: string; + pcrConfig?: PcrConfig; +}) { + const [auth, setAuth] = useState({ + loading: true, + developer: undefined + }); + + useEffect(() => { + if (!apiUrl || apiUrl.trim() === "") { + throw new Error( + "OpenSecretDeveloper requires a non-empty apiUrl. Please provide a valid API endpoint URL." + ); + } + setPlatformApiUrl(apiUrl); + + // Store the platform API URL in window for access from other modules + if (typeof window !== 'undefined') { + window.__PLATFORM_API_URL__ = apiUrl; + } + }, [apiUrl]); + + async function fetchDeveloper() { + const access_token = window.localStorage.getItem("access_token"); + const refresh_token = window.localStorage.getItem("refresh_token"); + if (!access_token || !refresh_token) { + setAuth({ + loading: false, + developer: undefined + }); + return; + } + + try { + const response = await platformApi.platformMe(); + setAuth({ + loading: false, + developer: { + ...response.user, + organizations: response.organizations + } + }); + } catch (error) { + console.error("Failed to fetch developer:", error); + setAuth({ + loading: false, + developer: undefined + }); + } + } + + const getAttestationDocument = async () => { + const nonce = window.crypto.randomUUID(); + const response = await fetch(`${apiUrl}/attestation/${nonce}`); + if (!response.ok) { + throw new Error("Failed to fetch attestation document"); + } + + const data = await response.json(); + const verifiedDocument = await authenticate( + data.attestation_document, + AWS_ROOT_CERT_DER, + nonce + ); + return parseAttestationForView(verifiedDocument, verifiedDocument.cabundle, pcrConfig); + }; + + useEffect(() => { + fetchDeveloper(); + }, []); + + async function signIn(email: string, password: string) { + try { + const { access_token, refresh_token } = await platformApi.platformLogin(email, password); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + await fetchDeveloper(); + return { access_token, refresh_token, id: '', email }; + } catch (error) { + console.error("Login error:", error); + throw error; + } + } + + async function signUp(email: string, password: string, name?: string) { + try { + const { access_token, refresh_token } = await platformApi.platformRegister(email, password, name); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + await fetchDeveloper(); + return { access_token, refresh_token, id: '', email, name }; + } catch (error) { + console.error("Registration error:", error); + throw error; + } + } + + const value: OpenSecretDeveloperContextType = { + auth, + signIn, + signUp, + refetchDeveloper: fetchDeveloper, + signOut: async () => { + const refresh_token = window.localStorage.getItem("refresh_token"); + if (refresh_token) { + try { + await platformApi.platformLogout(refresh_token); + } catch (error) { + console.error("Error during logout:", error); + } + } + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + setAuth({ + loading: false, + developer: undefined + }); + }, + pcrConfig, + getAttestation, + authenticate, + parseAttestationForView, + awsRootCertDer: AWS_ROOT_CERT_DER, + expectedRootCertHash: EXPECTED_ROOT_CERT_HASH, + getAttestationDocument, + createOrganization: platformApi.createOrganization, + listOrganizations: platformApi.listOrganizations, + deleteOrganization: platformApi.deleteOrganization, + createProject: platformApi.createProject, + listProjects: platformApi.listProjects, + getProject: platformApi.getProject, + updateProject: platformApi.updateProject, + deleteProject: platformApi.deleteProject, + createProjectSecret: platformApi.createProjectSecret, + listProjectSecrets: platformApi.listProjectSecrets, + deleteProjectSecret: platformApi.deleteProjectSecret, + getEmailSettings: platformApi.getEmailSettings, + updateEmailSettings: platformApi.updateEmailSettings, + getOAuthSettings: platformApi.getOAuthSettings, + updateOAuthSettings: platformApi.updateOAuthSettings, + inviteDeveloper: platformApi.inviteDeveloper, + listOrganizationMembers: platformApi.listOrganizationMembers, + updateMemberRole: platformApi.updateMemberRole, + removeMember: platformApi.removeMember, + acceptInvite: platformApi.acceptInvite, + apiUrl + }; + + return ( + + {children} + + ); +} diff --git a/src/lib/developerContext.ts b/src/lib/developerContext.ts new file mode 100644 index 0000000..3298909 --- /dev/null +++ b/src/lib/developerContext.ts @@ -0,0 +1,8 @@ +import { useContext } from "react"; +import { OpenSecretDeveloperContext, OpenSecretDeveloperContextType } from "./developer"; + +export function useOpenSecretDeveloper(): OpenSecretDeveloperContextType { + const context = useContext(OpenSecretDeveloperContext); + // React 19 compatibility: Don't check for nullish context since the default value is provided + return context; +} diff --git a/src/lib/encryptedApi.ts b/src/lib/encryptedApi.ts index 55ff847..61d6e8e 100644 --- a/src/lib/encryptedApi.ts +++ b/src/lib/encryptedApi.ts @@ -1,6 +1,7 @@ import { encryptMessage, decryptMessage } from "./encryption"; import { getAttestation } from "./getAttestation"; import { refreshToken } from "./api"; +import { platformRefreshToken } from "./platformApi"; interface EncryptedResponse { encrypted: string; @@ -22,7 +23,15 @@ export async function authenticatedApiCall( try { if (forceRefresh) { console.log("Refreshing access token"); - await refreshToken(); + // Determine which refresh function to use based on the URL + // If it's a platform API call, use platformRefreshToken, otherwise use regular refreshToken + if (url.includes('/platform/')) { + console.log("Using platform refresh token"); + await platformRefreshToken(); + } else { + console.log("Using regular refresh token"); + await refreshToken(); + } } // Always get the latest token from localStorage @@ -41,7 +50,7 @@ export async function authenticatedApiCall( // Attempt to refresh token once if we get a 401 if (response.status === 401 && !forceRefresh) { - console.log("Received 401, attempting to refresh token"); + console.log(`Received 401 for URL ${url}, attempting to refresh token`); return tryAuthenticatedRequest(true); } @@ -73,11 +82,18 @@ async function internalEncryptedApiCall( accessToken?: string, errorMessage?: string ): Promise> { - let { sessionKey, sessionId } = await getAttestation(); + // Check if we're using the platform API + const isPlatformApiCall = url.includes('/platform/'); + const platformApiUrl = typeof window !== 'undefined' ? window.__PLATFORM_API_URL__ : ''; + + // Use the platform API URL for attestation if this is a platform API call + const explicitApiUrl = isPlatformApiCall ? platformApiUrl : undefined; + + let { sessionKey, sessionId } = await getAttestation(false, explicitApiUrl); const makeRequest = async (token: string | undefined, forceNewAttestation: boolean = false) => { if (forceNewAttestation || !sessionKey || !sessionId) { - const newAttestation = await getAttestation(true); + const newAttestation = await getAttestation(true, explicitApiUrl); sessionKey = newAttestation.sessionKey; sessionId = newAttestation.sessionId; } diff --git a/src/lib/getAttestation.ts b/src/lib/getAttestation.ts index 2ee2908..a8d1921 100644 --- a/src/lib/getAttestation.ts +++ b/src/lib/getAttestation.ts @@ -27,7 +27,7 @@ function generateNaclKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } export async function getAttestation( forceRefresh?: boolean, - apiUrl?: string + explicitApiUrl?: string ): Promise { // Check if we already have a sessionKey and sessionId in sessionstorage const sessionKey = sessionStorage.getItem("sessionKey"); @@ -48,7 +48,7 @@ export async function getAttestation( const attestationNonce = window.crypto.randomUUID(); console.log("Generated attestation nonce:", attestationNonce); - const document = await verifyAttestation(attestationNonce); + const document = await verifyAttestation(attestationNonce, explicitApiUrl); if (document && document.public_key) { console.log("Attestation document verification succeeded"); @@ -58,7 +58,8 @@ export async function getAttestation( const { encrypted_session_key, session_id } = await keyExchange( encode(clientKeyPair.publicKey), - attestationNonce + attestationNonce, + explicitApiUrl ); console.log("Key exchange completed."); diff --git a/src/lib/index.ts b/src/lib/index.ts index c4be94c..a21cec7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,12 +9,22 @@ export type { // Export the provider and context export { OpenSecretProvider, OpenSecretContext } from "./main"; +export { OpenSecretDeveloper, OpenSecretDeveloperContext } from "./developer"; -// Export the hook +// Export the hooks export { useOpenSecret } from "./context"; +export { useOpenSecretDeveloper } from "./developerContext"; // Export types needed by consumers export type { OpenSecretAuthState, OpenSecretContextType } from "./main"; +export type { + OpenSecretDeveloperAuthState, + OpenSecretDeveloperContextType, + DeveloperRole, + OrganizationDetails, + ProjectDetails, + ProjectSettings +} from "./developer"; export type { AttestationDocument } from "./attestation"; export type { ParsedAttestationView } from "./attestationForView"; export type { PcrConfig, Pcr0ValidationResult } from "./pcr"; diff --git a/src/lib/platformApi.ts b/src/lib/platformApi.ts new file mode 100644 index 0000000..9cc72c3 --- /dev/null +++ b/src/lib/platformApi.ts @@ -0,0 +1,413 @@ +import { encryptedApiCall, authenticatedApiCall } from "./encryptedApi"; + +// Platform Auth Types +export type PlatformLoginResponse = { + id: string; + email: string; + name?: string; + access_token: string; + refresh_token: string; +}; + +export type PlatformRefreshResponse = { + access_token: string; + refresh_token: string; +}; + +// Platform User Types +export type PlatformOrg = { + id: string; + name: string; + role?: string; + created_at?: string; + updated_at?: string; +}; + +export type PlatformUser = { + id: string; + email: string; + name?: string; + email_verified: boolean; + created_at: string; + updated_at: string; +}; + +export type MeResponse = { + user: PlatformUser; + organizations: PlatformOrg[]; +}; + +// Organization Types +export type Organization = { + id: string; + name: string; +}; + +export type Project = { + id: string; + client_id: string; + name: string; + description?: string; + status: string; + created_at: string; +}; + +export type ProjectSecret = { + key_name: string; + created_at: string; + updated_at: string; +}; + +export type ProjectSettings = { + category: string; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type EmailSettings = { + provider: string; + send_from: string; + email_verification_url: string; +}; + +/** + * Provider-specific OAuth settings + */ +export type OAuthProviderSettings = { + client_id: string; + redirect_url: string; +}; + +export type OAuthSettings = { + google_oauth_enabled: boolean; + github_oauth_enabled: boolean; + google_oauth_settings?: OAuthProviderSettings; + github_oauth_settings?: OAuthProviderSettings; +}; + +export type OrganizationMember = { + user_id: string; + role: string; +}; + +let platformApiUrl = ""; + +export function setPlatformApiUrl(url: string) { + platformApiUrl = url; +} + +export function getPlatformApiUrl(): string { + return platformApiUrl; +} + +// Platform Authentication +export async function platformLogin( + email: string, + password: string +): Promise { + return encryptedApiCall<{ email: string; password: string }, PlatformLoginResponse>( + `${platformApiUrl}/platform/login`, + "POST", + { email, password }, + undefined, + "Failed to login" + ); +} + +export async function platformRegister( + email: string, + password: string, + name?: string +): Promise { + return encryptedApiCall< + { email: string; password: string; name?: string }, + PlatformLoginResponse + >( + `${platformApiUrl}/platform/register`, + "POST", + { email, password, name }, + undefined, + "Failed to register" + ); +} + +export async function platformLogout(refresh_token: string): Promise { + return encryptedApiCall<{ refresh_token: string }, void>( + `${platformApiUrl}/platform/logout`, + "POST", + { refresh_token }, + undefined, + "Failed to logout" + ); +} + +/** + * Refreshes platform access and refresh tokens + * + * This function: + * 1. Gets the refresh token from localStorage + * 2. Calls the platform-specific refresh endpoint (/platform/refresh) + * 3. Updates localStorage with the new tokens + * + * The platform refresh endpoint expects: + * - A refresh token with audience "platform_refresh" in the request body + * - The request to be encrypted according to the platform's encryption scheme + * + * It returns new access and refresh tokens if validation succeeds. + */ +export async function platformRefreshToken(): Promise { + const refresh_token = window.localStorage.getItem("refresh_token"); + if (!refresh_token) throw new Error("No refresh token available"); + + const refreshData = { refresh_token }; + + try { + const response = await encryptedApiCall( + `${platformApiUrl}/platform/refresh`, + "POST", + refreshData, + undefined, + "Failed to refresh platform token" + ); + + window.localStorage.setItem("access_token", response.access_token); + window.localStorage.setItem("refresh_token", response.refresh_token); + return response; + } catch (error) { + console.error("Error refreshing platform token:", error); + throw error; + } +} + +// Organization Management +export async function createOrganization(name: string): Promise { + return authenticatedApiCall<{ name: string }, Organization>( + `${platformApiUrl}/platform/orgs`, + "POST", + { name } + ); +} + +export async function listOrganizations(): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs`, + "GET", + undefined + ); +} + +export async function deleteOrganization(orgId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}`, + "DELETE", + undefined + ); +} + +// Project Management +export async function createProject( + orgId: string, + name: string, + description?: string +): Promise { + return authenticatedApiCall<{ name: string; description?: string }, Project>( + `${platformApiUrl}/platform/orgs/${orgId}/projects`, + "POST", + { name, description } + ); +} + +export async function listProjects(orgId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects`, + "GET", + undefined + ); +} + +export async function getProject(orgId: string, projectId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}`, + "GET", + undefined + ); +} + +export async function updateProject( + orgId: string, + projectId: string, + updates: { name?: string; description?: string; status?: string } +): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}`, + "PATCH", + updates + ); +} + +export async function deleteProject(orgId: string, projectId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}`, + "DELETE", + undefined + ); +} + +// Helper function to check if a string is valid base64 +function isValidBase64(str: string): boolean { + // Base64 should have a length that is a multiple of 4 + // It should only contain characters A-Z, a-z, 0-9, +, /, and end with '=' or '==' + const base64Regex = /^[A-Za-z0-9+/]*[=]{0,2}$/; + + // Check if the string length is a multiple of 4 + const validLength = str.length % 4 === 0; + + // Check if the string only contains valid base64 characters + const validChars = base64Regex.test(str); + + return validLength && validChars; +} + +// Project Secrets +export async function createProjectSecret( + orgId: string, + projectId: string, + keyName: string, + secret: string +): Promise { + // Validate that the secret is base64 encoded + if (!isValidBase64(secret)) { + throw new Error("Secret must be base64 encoded. Use @stablelib/base64's encode function to encode your data."); + } + + return authenticatedApiCall<{ key_name: string; secret: string }, ProjectSecret>( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/secrets`, + "POST", + { key_name: keyName, secret } + ); +} + +export async function listProjectSecrets( + orgId: string, + projectId: string +): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/secrets`, + "GET", + undefined + ); +} + +export async function deleteProjectSecret( + orgId: string, + projectId: string, + keyName: string +): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/secrets/${keyName}`, + "DELETE", + undefined + ); +} + +// Email Settings +export async function getEmailSettings(orgId: string, projectId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/email`, + "GET", + undefined + ); +} + +export async function updateEmailSettings( + orgId: string, + projectId: string, + settings: EmailSettings +): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/email`, + "PUT", + settings + ); +} + +// OAuth Settings +export async function getOAuthSettings(orgId: string, projectId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/oauth`, + "GET", + undefined + ); +} + +export async function updateOAuthSettings( + orgId: string, + projectId: string, + settings: OAuthSettings +): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/projects/${projectId}/settings/oauth`, + "PUT", + settings + ); +} + +// Organization Membership +export async function inviteDeveloper( + orgId: string, + email: string, + role?: string +): Promise<{ code: string }> { + // Add validation for empty emails + if (!email || email.trim() === "") { + throw new Error("Email is required"); + } + + return authenticatedApiCall<{ email: string; role?: string }, { code: string }>( + `${platformApiUrl}/platform/orgs/${orgId}/invites`, + "POST", + { email, role } + ); +} + +export async function listOrganizationMembers(orgId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/memberships`, + "GET", + undefined + ); +} + +export async function updateMemberRole( + orgId: string, + userId: string, + role: string +): Promise { + return authenticatedApiCall<{ role: string }, OrganizationMember>( + `${platformApiUrl}/platform/orgs/${orgId}/memberships/${userId}`, + "PATCH", + { role } + ); +} + +export async function removeMember(orgId: string, userId: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/orgs/${orgId}/memberships/${userId}`, + "DELETE", + undefined + ); +} + +export async function acceptInvite(code: string): Promise { + return authenticatedApiCall( + `${platformApiUrl}/platform/accept_invite/${code}`, + "POST", + undefined + ); +} + +// Platform User +export async function platformMe(): Promise { + return authenticatedApiCall(`${platformApiUrl}/platform/me`, "GET", undefined); +} diff --git a/src/lib/ai.test.ts b/src/lib/test/integration/ai.test.ts similarity index 97% rename from src/lib/ai.test.ts rename to src/lib/test/integration/ai.test.ts index 0e99f70..778bb15 100644 --- a/src/lib/ai.test.ts +++ b/src/lib/test/integration/ai.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { fetchLogin } from "./api"; -import { createCustomFetch } from "./ai"; +import { fetchLogin } from "../../api"; +import { createCustomFetch } from "../../ai"; import OpenAI from "openai"; const TEST_EMAIL = process.env.VITE_TEST_EMAIL; diff --git a/src/lib/api.test.ts b/src/lib/test/integration/api.test.ts similarity index 99% rename from src/lib/api.test.ts rename to src/lib/test/integration/api.test.ts index 1c1d261..cae3b87 100644 --- a/src/lib/api.test.ts +++ b/src/lib/test/integration/api.test.ts @@ -9,7 +9,7 @@ import { fetchUser, // convertGuestToEmailAccount, generateThirdPartyToken -} from "./api"; +} from "../../api"; const TEST_EMAIL = process.env.VITE_TEST_EMAIL; const TEST_PASSWORD = process.env.VITE_TEST_PASSWORD; diff --git a/src/lib/attestation.test.ts b/src/lib/test/integration/attestation.test.ts similarity index 99% rename from src/lib/attestation.test.ts rename to src/lib/test/integration/attestation.test.ts index 792d475..2696ab7 100644 --- a/src/lib/attestation.test.ts +++ b/src/lib/test/integration/attestation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { createSigStructure, parseDocumentData, parseDocumentPayload } from "./attestation"; +import { createSigStructure, parseDocumentData, parseDocumentPayload } from "../../attestation"; import { encode } from "@stablelib/base64"; const HARDCODED_TEST_ATTESTATION_DOCUMENT = diff --git a/src/lib/test/integration/developer.test.ts b/src/lib/test/integration/developer.test.ts new file mode 100644 index 0000000..697dc5e --- /dev/null +++ b/src/lib/test/integration/developer.test.ts @@ -0,0 +1,1819 @@ +import { expect, test, beforeEach } from "bun:test"; +import { platformLogin, platformRegister, platformMe } from "../../platformApi"; +import "../platform-api-url-loader"; +import * as platformApi from "../../platformApi"; +import { encode } from "@stablelib/base64"; + +const TEST_DEVELOPER_EMAIL = process.env.VITE_TEST_DEVELOPER_EMAIL; +const TEST_DEVELOPER_PASSWORD = process.env.VITE_TEST_DEVELOPER_PASSWORD; +const TEST_DEVELOPER_NAME = process.env.VITE_TEST_DEVELOPER_NAME; + +if (!TEST_DEVELOPER_EMAIL || !TEST_DEVELOPER_PASSWORD || !TEST_DEVELOPER_NAME) { + throw new Error("Test developer credentials must be set in .env.local"); +} + +// Cache login response to avoid multiple logins +let cachedLoginResponse: { access_token: string; refresh_token: string } | null = null; + +// Helper function to base64 encode strings +const encodeSecret = (secret: string): string => { + return encode(new TextEncoder().encode(secret)); +}; + +async function tryDeveloperLogin() { + // If we have a successful login cached, reuse it + if (cachedLoginResponse) { + return cachedLoginResponse; + } + + try { + // First, try to login directly + console.log(`Attempting login with email: ${TEST_DEVELOPER_EMAIL}`); + const response = await platformLogin(TEST_DEVELOPER_EMAIL!, TEST_DEVELOPER_PASSWORD!); + console.log("Login successful"); + cachedLoginResponse = response; + + // Store tokens for subsequent API calls + window.localStorage.setItem("access_token", response.access_token); + window.localStorage.setItem("refresh_token", response.refresh_token); + + return response; + } catch (loginError) { + console.warn("Login failed, attempting to register the user"); + + try { + // Try to register the user with the credentials from environment variables + const response = await platformRegister( + TEST_DEVELOPER_EMAIL!, + TEST_DEVELOPER_PASSWORD!, + TEST_DEVELOPER_NAME! + ); + console.log("Successfully registered test user"); + cachedLoginResponse = response; + + // Store tokens for subsequent API calls + window.localStorage.setItem("access_token", response.access_token); + window.localStorage.setItem("refresh_token", response.refresh_token); + + return response; + } catch (registerError: any) { + if ( + registerError.message.includes("Email already registered") || + registerError.message.includes("User already exists") + ) { + console.log("User already registered, retrying login"); + // If user already exists, try login again (maybe there was a temporary issue) + const response = await platformLogin(TEST_DEVELOPER_EMAIL!, TEST_DEVELOPER_PASSWORD!); + cachedLoginResponse = response; + + // Store tokens for subsequent API calls + window.localStorage.setItem("access_token", response.access_token); + window.localStorage.setItem("refresh_token", response.refresh_token); + + return response; + } else { + console.error("Registration failed with unexpected error:", registerError.message); + throw registerError; + } + } + } +} + +// Clean up before each test +beforeEach(async () => { + window.localStorage.clear(); +}); + +test("Developer login and token storage", async () => { + try { + const { access_token, refresh_token } = await tryDeveloperLogin(); + expect(access_token).toBeDefined(); + expect(refresh_token).toBeDefined(); + + // Store tokens in localStorage + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Verify tokens were stored + expect(window.localStorage.getItem("access_token")).toBe(access_token); + expect(window.localStorage.getItem("refresh_token")).toBe(refresh_token); + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Platform Me endpoint returns user with organizations", async () => { + try { + // Clear any existing storage + window.localStorage.clear(); + + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + + // Store tokens for subsequent API calls + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + try { + // Call the me endpoint + const response = await platformMe(); + + // Verify the response structure + expect(response).toBeDefined(); + expect(response.user).toBeDefined(); + expect(response.user.id).toBeDefined(); + expect(response.user.email).toBeDefined(); + expect(typeof response.user.email_verified).toBe("boolean"); + expect(response.organizations).toBeDefined(); + expect(Array.isArray(response.organizations)).toBe(true); + + // If the user has organizations, verify their structure + if (response.organizations && response.organizations.length > 0) { + const org = response.organizations[0]; + + expect(org.id).toBeDefined(); + expect(org.name).toBeDefined(); + // These fields may or may not be present depending on the API implementation + // Only check them if they exist + if (org.role) expect(org.role).toBeDefined(); + if (org.created_at) expect(org.created_at).toBeDefined(); + if (org.updated_at) expect(org.updated_at).toBeDefined(); + } + } catch (meError: any) { + console.error("Error calling platform Me endpoint:", meError.message); + throw meError; + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Developer login fails with invalid credentials", async () => { + try { + await platformLogin("invalid@email.com", "wrongpassword"); + throw new Error("Should not succeed with invalid credentials"); + } catch (error: any) { + // Match a wider range of error messages about invalid credentials + expect(error.message).toMatch(/Invalid email|Invalid password|Invalid.*login|login.*Invalid/i); + } +}); + +test("Developer registration fails with existing email", async () => { + try { + // This should fail since we've already registered this email in tryDeveloperLogin + await platformRegister(TEST_DEVELOPER_EMAIL!, TEST_DEVELOPER_PASSWORD!, TEST_DEVELOPER_NAME!); + // If we reach here without an error being thrown, the test should fail + expect(true).toBe(false); // This line should never be reached + } catch (error: any) { + // We expect an "Email already registered" error + expect(error.message).toMatch(/Email already registered|User already exists/); + } +}); + +test("Developer login persists tokens correctly", async () => { + try { + const { access_token, refresh_token } = await tryDeveloperLogin(); + + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Verify tokens are stored + expect(window.localStorage.getItem("access_token")).toBe(access_token); + expect(window.localStorage.getItem("refresh_token")).toBe(refresh_token); + + // Clear storage + window.localStorage.clear(); + expect(window.localStorage.getItem("access_token")).toBeNull(); + expect(window.localStorage.getItem("refresh_token")).toBeNull(); + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Developer registration validates input", async () => { + // Test empty email + try { + await platformRegister("", TEST_DEVELOPER_PASSWORD!, TEST_DEVELOPER_NAME!); + throw new Error("Should not accept empty email"); + } catch (error: any) { + // Allow for different error message formats + expect(error.message).toMatch(/Invalid email|Email.*invalid|Bad Request/i); + } + + // Test invalid email format + try { + await platformRegister("notanemail", TEST_DEVELOPER_PASSWORD!, TEST_DEVELOPER_NAME!); + throw new Error("Should not accept invalid email format"); + } catch (error: any) { + // Allow for different error message formats + expect(error.message).toMatch(/Invalid email|Email.*invalid|Bad Request/i); + } + + // Test empty password + try { + await platformRegister(TEST_DEVELOPER_EMAIL!, "", TEST_DEVELOPER_NAME!); + throw new Error("Should not accept empty password"); + } catch (error: any) { + // Allow for different error message formats + expect(error.message).toMatch(/Invalid password|Password.*invalid|Bad Request/i); + } +}); + +test("Create, list, and delete organization", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create a unique organization name (to avoid conflicts if test is run multiple times) + const orgName = `Test Org ${Date.now()}`; + + // Create the organization + const createdOrg = await platformApi.createOrganization(orgName); + expect(createdOrg).toBeDefined(); + expect(createdOrg.name).toBe(orgName); + expect(createdOrg.id).toBeDefined(); + + // List organizations and verify the new one is there + const orgs = await platformApi.listOrganizations(); + expect(orgs).toBeDefined(); + expect(Array.isArray(orgs)).toBe(true); + + // Find our organization in the list + const foundOrg = orgs.find((org) => org.id === createdOrg.id); + expect(foundOrg).toBeDefined(); + expect(foundOrg?.name).toBe(orgName); + + // Delete the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + + // List organizations again and verify the deleted one is gone + const orgsAfterDelete = await platformApi.listOrganizations(); + const shouldBeUndefined = orgsAfterDelete.find((org) => org.id === createdOrg.id); + expect(shouldBeUndefined).toBeUndefined(); + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Organization creation with invalid input", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Test empty name + try { + await platformApi.createOrganization(""); + throw new Error("Should not accept empty organization name"); + } catch (error: any) { + expect(error.message).toMatch(/Invalid|name.*required|Bad Request/i); + } + + // Test extremely long name (if there's a limit) + try { + const veryLongName = "a".repeat(1000); + await platformApi.createOrganization(veryLongName); + // Note: This may or may not fail depending on API implementation + } catch (error: any) { + expect(error.message).toMatch(/Invalid|name.*too long|Bad Request/i); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Organization deletion edge cases", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Test deleting non-existent organization + try { + await platformApi.deleteOrganization("non-existent-id"); + throw new Error("Should not be able to delete non-existent organization"); + } catch (error: any) { + expect(error.message).toMatch( + /Not Found|Organization not found|Bad Request|HTTP error! Status: 400/i + ); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Create organization with duplicate name", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create a unique organization name + const orgName = `Test Duplicate Org ${Date.now()}`; + + // Create the first organization + const firstOrg = await platformApi.createOrganization(orgName); + expect(firstOrg).toBeDefined(); + + try { + // Try creating a second organization with the same name + await platformApi.createOrganization(orgName); + + // If we reach here, it means duplicate names are allowed + // We should clean up both organizations + const orgs = await platformApi.listOrganizations(); + const duplicateOrgs = orgs.filter((org) => org.name === orgName); + for (const org of duplicateOrgs) { + await platformApi.deleteOrganization(org.id.toString()); + } + } catch (error: any) { + // Expected error for duplicate name + expect(error.message).toMatch(/duplicate|already exists|Bad Request/i); + + // Clean up the first organization + await platformApi.deleteOrganization(firstOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Create and manage multiple organizations", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Keep track of created organizations to delete them later + const createdOrgIds: string[] = []; + + // Create three organizations + const timestamp = Date.now(); + for (let i = 0; i < 3; i++) { + const orgName = `Test Multi Org ${timestamp}-${i}`; + const org = await platformApi.createOrganization(orgName); + expect(org).toBeDefined(); + expect(org.name).toBe(orgName); + createdOrgIds.push(org.id.toString()); + } + + // List organizations and verify all three exist + const orgs = await platformApi.listOrganizations(); + for (const orgId of createdOrgIds) { + const found = orgs.some((org) => org.id.toString() === orgId); + expect(found).toBe(true); + } + + // Delete organizations one by one and verify they're gone + for (const orgId of createdOrgIds) { + await platformApi.deleteOrganization(orgId); + const orgsAfter = await platformApi.listOrganizations(); + const stillExists = orgsAfter.some((org) => org.id.toString() === orgId); + expect(stillExists).toBe(false); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("List organizations with no organizations", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Get all existing organizations + const initialOrgs = await platformApi.listOrganizations(); + + // Delete all organizations (if any) + for (const org of initialOrgs) { + await platformApi.deleteOrganization(org.id.toString()); + } + + // List organizations - should be empty or still return a valid empty array + const emptyOrgs = await platformApi.listOrganizations(); + expect(Array.isArray(emptyOrgs)).toBe(true); + + // Create a test organization to ensure state is reset properly + const orgName = `Test Reset Org ${Date.now()}`; + await platformApi.createOrganization(orgName); + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Organization operations require authentication", async () => { + try { + // Clear any authentication tokens + window.localStorage.clear(); + + // Try to list organizations without authentication + try { + await platformApi.listOrganizations(); + throw new Error("Should not be able to list organizations without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to create an organization without authentication + try { + await platformApi.createOrganization("Test Org"); + throw new Error("Should not be able to create organizations without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to delete an organization without authentication + try { + await platformApi.deleteOrganization("any-id"); + throw new Error("Should not be able to delete organizations without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Organization with special characters in name", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization with special characters in name + const specialOrgName = `Test Org & Special #${Date.now()}`; + + try { + const org = await platformApi.createOrganization(specialOrgName); + expect(org).toBeDefined(); + expect(org.name).toBe(specialOrgName); + + // Clean up + await platformApi.deleteOrganization(org.id.toString()); + } catch (error: any) { + // If the API doesn't allow special characters, expect a proper validation error + expect(error.message).toMatch(/invalid|character|Bad Request/i); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("List organizations pagination handling", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Get initial list of organizations + const initialOrgs = await platformApi.listOrganizations(); + + // Create a batch of organizations if needed to test pagination + const createdOrgIds: string[] = []; + const timestamp = Date.now(); + const batchSize = 5; // Create enough to potentially trigger pagination + + for (let i = 0; i < batchSize; i++) { + const orgName = `Test Pagination Org ${timestamp}-${i}`; + const org = await platformApi.createOrganization(orgName); + createdOrgIds.push(org.id.toString()); + } + + // Fetch the updated list of organizations - should include all created ones + const updatedOrgs = await platformApi.listOrganizations(); + + // Verify we have more organizations now + expect(updatedOrgs.length).toBeGreaterThanOrEqual(initialOrgs.length + batchSize); + + // Verify all our newly created orgs are in the list + for (const id of createdOrgIds) { + const found = updatedOrgs.some((org) => org.id.toString() === id); + expect(found).toBe(true); + } + + // Clean up all created organizations + for (const id of createdOrgIds) { + await platformApi.deleteOrganization(id); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +// Add test for platform API URL validation +test("Platform API URL is required", () => { + // The platform-api-url-loader will throw if no URL is provided + // Verify it's set to something non-empty + const apiUrl = process.env.VITE_OPEN_SECRET_API_URL; + expect(apiUrl).toBeDefined(); + expect(apiUrl?.trim().length).toBeGreaterThan(0); +}); + +// Test chained organization operations as would be used in real app +test("Organization API flow with chained operations", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // 1. Create a new organization + const orgName = `Test Chain Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + // 2. Verify it appears in the list + let orgs = await platformApi.listOrganizations(); + let found = orgs.some((org) => org.id.toString() === createdOrg.id.toString()); + expect(found).toBe(true); + + // 3. Delete the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + + // 4. Verify it no longer appears in the list + orgs = await platformApi.listOrganizations(); + found = orgs.some((org) => org.id.toString() === createdOrg.id.toString()); + expect(found).toBe(false); + + // 5. Attempt to delete the same organization again (should fail) + try { + await platformApi.deleteOrganization(createdOrg.id.toString()); + throw new Error("Should not be able to delete the same organization twice"); + } catch (error: any) { + expect(error.message).toMatch( + /Not Found|Organization not found|Bad Request|HTTP error! Status: 400/i + ); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +// Test deleting a non-existent organization with a valid UUID format +test("Organization deletion with random UUID", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Generate a random UUID that (almost certainly) doesn't exist + const randomUUID = crypto.randomUUID(); + + // Try to delete an organization with this UUID + try { + await platformApi.deleteOrganization(randomUUID); + throw new Error("Should not be able to delete non-existent organization"); + } catch (error: any) { + // This should return a 404 Not Found or similar + expect(error.message).toMatch( + /Not Found|Organization not found|not exist|HTTP error! Status: 404/i + ); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +// ===== PROJECT TESTS ===== + +test("Get project by ID", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create a new organization for testing + const orgName = `Test Get Project Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + expect(createdOrg).toBeDefined(); + expect(createdOrg.name).toBe(orgName); + + try { + // Create a project first + const projectName = `Test Get Project ${Date.now()}`; + const projectDescription = "A project to test getProject functionality"; + const createdProject = await platformApi.createProject( + createdOrg.id.toString(), + projectName, + projectDescription + ); + expect(createdProject).toBeDefined(); + + // Get the project by ID + const retrievedProject = await platformApi.getProject( + createdOrg.id.toString(), + createdProject.id.toString() + ); + + // Verify the project details + expect(retrievedProject).toBeDefined(); + expect(retrievedProject.id).toBe(createdProject.id); + expect(retrievedProject.name).toBe(projectName); + expect(retrievedProject.description).toBe(projectDescription); + expect(retrievedProject.client_id).toBeDefined(); + expect(retrievedProject.status).toBeDefined(); + expect(retrievedProject.created_at).toBeDefined(); + + // Test getting a non-existent project + try { + await platformApi.getProject(createdOrg.id.toString(), "non-existent-id"); + throw new Error("Should not be able to get non-existent project"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + + // Test getting a project with non-existent organization + try { + await platformApi.getProject("non-existent-id", createdProject.id.toString()); + throw new Error("Should not be able to get project with non-existent organization"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + } finally { + // Clean up by deleting the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project CRUD operations within an organization", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create a new organization for testing projects + const orgName = `Test Project Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + expect(createdOrg).toBeDefined(); + expect(createdOrg.name).toBe(orgName); + + try { + // 1. Create a project + const projectName = `Test Project ${Date.now()}`; + const projectDescription = "A test project for automated testing"; + const createdProject = await platformApi.createProject( + createdOrg.id.toString(), + projectName, + projectDescription + ); + expect(createdProject).toBeDefined(); + expect(createdProject.name).toBe(projectName); + expect(createdProject.description).toBe(projectDescription); + expect(createdProject.client_id).toBeDefined(); + + // 2. List projects and verify the new one is there + const projects = await platformApi.listProjects(createdOrg.id.toString()); + expect(projects).toBeDefined(); + expect(Array.isArray(projects)).toBe(true); + + // Find our project in the list + const foundProject = projects.find((project) => project.id === createdProject.id); + expect(foundProject).toBeDefined(); + expect(foundProject?.name).toBe(projectName); + expect(foundProject?.description).toBe(projectDescription); + + // 3. Update the project + const updatedName = `Updated Project ${Date.now()}`; + const updatedDescription = "This description has been updated"; + const updatedStatus = "inactive"; // Assuming status can be changed + + const updatedProject = await platformApi.updateProject( + createdOrg.id.toString(), + createdProject.id.toString(), + { + name: updatedName, + description: updatedDescription, + status: updatedStatus + } + ); + + expect(updatedProject).toBeDefined(); + expect(updatedProject.name).toBe(updatedName); + expect(updatedProject.description).toBe(updatedDescription); + expect(updatedProject.status).toBe(updatedStatus); + + // 4. List projects again and verify the updates + const updatedProjects = await platformApi.listProjects(createdOrg.id.toString()); + const updatedFoundProject = updatedProjects.find( + (project) => project.id === createdProject.id + ); + expect(updatedFoundProject).toBeDefined(); + expect(updatedFoundProject?.name).toBe(updatedName); + expect(updatedFoundProject?.description).toBe(updatedDescription); + expect(updatedFoundProject?.status).toBe(updatedStatus); + + // 5. Delete the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + + // 6. List projects again and verify the deleted one is gone + const projectsAfterDelete = await platformApi.listProjects(createdOrg.id.toString()); + const shouldBeUndefined = projectsAfterDelete.find( + (project) => project.id === createdProject.id + ); + expect(shouldBeUndefined).toBeUndefined(); + } finally { + // Clean up by deleting the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project creation with invalid input", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Error Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Test empty name + try { + await platformApi.createProject(createdOrg.id.toString(), ""); + throw new Error("Should not accept empty project name"); + } catch (error: any) { + expect(error.message).toMatch(/Invalid|name.*required|Bad Request/i); + } + + // Test extremely long name (if there's a limit) + try { + const veryLongName = "a".repeat(1000); + await platformApi.createProject(createdOrg.id.toString(), veryLongName); + // Note: This may or may not fail depending on API implementation + } catch (error: any) { + expect(error.message).toMatch(/Invalid|name.*too long|Bad Request/i); + } + + // Test non-existent organization ID + try { + await platformApi.createProject("non-existent-id", "Test Project"); + throw new Error("Should not accept non-existent organization ID"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + } finally { + // Clean up + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project update with invalid input", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Update Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project first + const projectName = `Test Project Update ${Date.now()}`; + const project = await platformApi.createProject(createdOrg.id.toString(), projectName); + + // Test updating a non-existent project + try { + await platformApi.updateProject(createdOrg.id.toString(), "non-existent-id", { + name: "New Name" + }); + throw new Error("Should not be able to update non-existent project"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + + // Test updating a project with non-existent organization + try { + await platformApi.updateProject("non-existent-id", project.id.toString(), { + name: "New Name" + }); + throw new Error("Should not be able to update project with non-existent organization"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + + // Test empty update object (this may or may not be allowed) + try { + await platformApi.updateProject(createdOrg.id.toString(), project.id.toString(), {}); + // If it succeeds, no need to do anything + } catch (error: any) { + expect(error.message).toMatch(/invalid|Bad Request/i); + } + + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), project.id.toString()); + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project deletion edge cases", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Delete Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Test deleting a non-existent project + try { + await platformApi.deleteProject(createdOrg.id.toString(), "non-existent-id"); + throw new Error("Should not be able to delete non-existent project"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + + // Test deleting a project from a non-existent organization + try { + await platformApi.deleteProject("non-existent-id", "some-project-id"); + throw new Error("Should not be able to delete project from non-existent organization"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + + // Create a project and delete it + const projectName = `Test Project Delete ${Date.now()}`; + const project = await platformApi.createProject(createdOrg.id.toString(), projectName); + await platformApi.deleteProject(createdOrg.id.toString(), project.id.toString()); + + // Try to delete it again (should fail) + try { + await platformApi.deleteProject(createdOrg.id.toString(), project.id.toString()); + throw new Error("Should not be able to delete the same project twice"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project with special characters in name", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Special Chars Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Test creating a project with special characters in name + const specialProjectName = `Test Project & Special #${Date.now()}`; + + try { + const project = await platformApi.createProject( + createdOrg.id.toString(), + specialProjectName + ); + expect(project).toBeDefined(); + expect(project.name).toBe(specialProjectName); + + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), project.id.toString()); + } catch (error: any) { + // If the API doesn't allow special characters, expect a proper validation error + expect(error.message).toMatch(/invalid|character|Bad Request/i); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project listing with multiple projects (pagination handling)", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Pagination Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create several projects to test pagination + const projectIds: string[] = []; + const timestamp = Date.now(); + const batchSize = 5; // Create enough to potentially trigger pagination + + for (let i = 0; i < batchSize; i++) { + const projectName = `Test Pagination Project ${timestamp}-${i}`; + const project = await platformApi.createProject( + createdOrg.id.toString(), + projectName, + `Description for pagination project ${i}` + ); + projectIds.push(project.id.toString()); + } + + // Fetch all projects - should include all created ones + const projects = await platformApi.listProjects(createdOrg.id.toString()); + + // Verify we have at least the number of projects we created + expect(projects.length).toBeGreaterThanOrEqual(batchSize); + + // Verify all our newly created projects are in the list + for (const id of projectIds) { + const found = projects.some((project) => project.id.toString() === id); + expect(found).toBe(true); + } + + // Clean up all created projects + for (const id of projectIds) { + await platformApi.deleteProject(createdOrg.id.toString(), id); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project operations require authentication", async () => { + try { + // First create an org and project while authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + const orgName = `Test Project Auth Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + const projectName = `Test Auth Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + // Now clear authentication and try operations + window.localStorage.clear(); + + // Try to list projects without authentication + try { + await platformApi.listProjects(createdOrg.id.toString()); + throw new Error("Should not be able to list projects without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to create a project without authentication + try { + await platformApi.createProject(createdOrg.id.toString(), "New Project"); + throw new Error("Should not be able to create projects without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to update a project without authentication + try { + await platformApi.updateProject(createdOrg.id.toString(), createdProject.id.toString(), { + name: "Updated Name" + }); + throw new Error("Should not be able to update projects without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to delete a project without authentication + try { + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + throw new Error("Should not be able to delete projects without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Re-authenticate to clean up + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Clean up + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + await platformApi.deleteOrganization(createdOrg.id.toString()); + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Create project with duplicate name in same organization", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Duplicate Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project with a specific name + const duplicateProjectName = `Test Duplicate Project ${Date.now()}`; + const firstProject = await platformApi.createProject( + createdOrg.id.toString(), + duplicateProjectName + ); + expect(firstProject).toBeDefined(); + + try { + // Try creating a second project with the same name + await platformApi.createProject(createdOrg.id.toString(), duplicateProjectName); + + // If we reach here, it means duplicate names are allowed + // We should clean up both projects + await platformApi.deleteProject(createdOrg.id.toString(), firstProject.id.toString()); + } catch (error: any) { + // Expected error for duplicate name + expect(error.message).toMatch(/duplicate|already exists|Bad Request/i); + + // Clean up the first project + await platformApi.deleteProject(createdOrg.id.toString(), firstProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project API flow with chained operations", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Chain Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // 1. Create a new project + const projectName = `Test Chain Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + // 2. Verify it appears in the list + let projects = await platformApi.listProjects(createdOrg.id.toString()); + let found = projects.some( + (project) => project.id.toString() === createdProject.id.toString() + ); + expect(found).toBe(true); + + // 3. Update the project + const updatedName = `Updated Chain Project ${Date.now()}`; + const updatedProject = await platformApi.updateProject( + createdOrg.id.toString(), + createdProject.id.toString(), + { name: updatedName } + ); + expect(updatedProject.name).toBe(updatedName); + + // 4. Verify the update appears in the list + projects = await platformApi.listProjects(createdOrg.id.toString()); + const updatedFound = projects.find( + (project) => project.id.toString() === createdProject.id.toString() + ); + expect(updatedFound).toBeDefined(); + expect(updatedFound?.name).toBe(updatedName); + + // 5. Delete the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + + // 6. Verify it no longer appears in the list + projects = await platformApi.listProjects(createdOrg.id.toString()); + found = projects.some((project) => project.id.toString() === createdProject.id.toString()); + expect(found).toBe(false); + + // 7. Attempt to delete the same project again (should fail) + try { + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + throw new Error("Should not be able to delete the same project twice"); + } catch (error: any) { + expect(error.message).toMatch( + /Not Found|Organization not found|Bad Request|HTTP error! Status: 400/i + ); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project deletion with random UUID", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Project Random UUID Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Generate a random UUID that (almost certainly) doesn't exist + const randomUUID = crypto.randomUUID(); + + // Try to delete a project with this UUID + try { + await platformApi.deleteProject(createdOrg.id.toString(), randomUUID); + throw new Error("Should not be able to delete non-existent project"); + } catch (error: any) { + // This should return a 404 Not Found or similar + expect(error.message).toMatch( + /Not Found|Organization not found|not exist|HTTP error! Status: 404/i + ); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +// ===== PROJECT SECRET TESTS ===== + +test("Project secret CRUD operations", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create a new organization for testing + const orgName = `Test Secret Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + expect(createdOrg).toBeDefined(); + + try { + // Create a project for testing secrets + const projectName = `Test Secret Project ${Date.now()}`; + const createdProject = await platformApi.createProject( + createdOrg.id.toString(), + projectName, + "A project for testing secrets" + ); + expect(createdProject).toBeDefined(); + + try { + // 1. Create a secret with alphanumeric only key name + const secretKeyName = `testsecret${Date.now()}`; + const secretValue = "supersecretvaluethatshouldbencrypted"; + const createdSecret = await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName, + encodeSecret(secretValue) + ); + expect(createdSecret).toBeDefined(); + expect(createdSecret.key_name).toBe(secretKeyName); + expect(createdSecret.created_at).toBeDefined(); + expect(createdSecret.updated_at).toBeDefined(); + // Note: The actual secret value should not be returned for security reasons + + // 2. List secrets and verify the new one is there + const secrets = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + expect(secrets).toBeDefined(); + expect(Array.isArray(secrets)).toBe(true); + + // Find our secret in the list + const foundSecret = secrets.find((secret) => secret.key_name === secretKeyName); + expect(foundSecret).toBeDefined(); + expect(foundSecret?.key_name).toBe(secretKeyName); + // Secret value should not be included in the list + + // 3. Delete the secret + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName + ); + + // 4. List secrets again and verify the deleted one is gone + const secretsAfterDelete = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + const shouldBeUndefined = secretsAfterDelete.find( + (secret) => secret.key_name === secretKeyName + ); + expect(shouldBeUndefined).toBeUndefined(); + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project secret creation with invalid input", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Secret Error Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project for testing + const projectName = `Test Secret Error Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + try { + // Test empty key name + try { + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + "", + encodeSecret("some-value") + ); + throw new Error("Should not accept empty secret key name"); + } catch (error: any) { + expect(error.message).toMatch(/Invalid|name.*required|Bad Request/i); + } + + // Test empty secret value + try { + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + "valid-key-name", + encodeSecret("") + ); + throw new Error("Should not accept empty secret value"); + } catch (error: any) { + expect(error.message).toMatch(/Invalid|value.*required|Bad Request/i); + } + + // Test extremely long key name (if there's a limit) + try { + const veryLongName = "a".repeat(1000); + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + veryLongName, + encodeSecret("some-value") + ); + // Note: This may or may not fail depending on API implementation + } catch (error: any) { + expect(error.message).toMatch(/Invalid|name.*too long|Bad Request/i); + } + + // Test non-existent project ID + try { + await platformApi.createProjectSecret( + createdOrg.id.toString(), + "non-existent-id", + "test-key", + encodeSecret("test-value") + ); + throw new Error("Should not accept non-existent project ID"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + + // Test non-existent organization ID + try { + await platformApi.createProjectSecret( + "non-existent-id", + createdProject.id.toString(), + "test-key", + encodeSecret("test-value") + ); + throw new Error("Should not accept non-existent organization ID"); + } catch (error: any) { + expect(error.message).toMatch(/not found|invalid|Bad Request|HTTP error! Status: 40/i); + } + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project secret with special characters in key name", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Secret Special Chars Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project for testing + const projectName = `Test Secret Special Chars Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + try { + // First create a valid secret to prove the API works correctly + const validKeyName = `testsecret${Date.now()}`; + const secretValue = "validalphanumericsecretvalue"; + + const validSecret = await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + validKeyName, + encodeSecret(secretValue) + ); + + expect(validSecret).toBeDefined(); + expect(validSecret.key_name).toBe(validKeyName); + + // Now try with a key name containing special characters - this SHOULD fail + const invalidKeyName = `test@special#${Date.now()}`; + + let errorThrown = false; + try { + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + invalidKeyName, + encodeSecret(secretValue) + ); + } catch (error: any) { + errorThrown = true; + // We expect an error about invalid characters or bad request + expect(error.message).toMatch(/invalid|character|Bad Request|HTTP error! Status: 400/i); + } + + // Make sure the error was thrown + expect(errorThrown).toBe(true); + + // Clean up the valid secret + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + validKeyName + ); + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project secret deletion edge cases", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Secret Delete Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project for testing + const projectName = `Test Secret Delete Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + try { + // Focus on testing the core deletion functionality with better error handling + console.log("Creating a test secret for deletion test..."); + + // Create a secret that we'll delete + const secretKeyName = `deletetest${Date.now()}`; + const secretValue = "deletemevalue"; + + const createdSecret = await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName, + encodeSecret(secretValue) + ); + + expect(createdSecret).toBeDefined(); + console.log(`Successfully created test secret: ${secretKeyName}`); + + // List secrets to verify it exists + const secretsBefore = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + + const secretExists = secretsBefore.some((s) => s.key_name === secretKeyName); + expect(secretExists).toBe(true); + console.log("Verified secret exists in list"); + + // Delete the secret (should succeed) + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName + ); + console.log("Successfully deleted the secret"); + + // Verify the secret was deleted + const secretsAfter = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + + const secretStillExists = secretsAfter.some((s) => s.key_name === secretKeyName); + expect(secretStillExists).toBe(false); + console.log("Verified secret was deleted successfully"); + + // Now try to delete a non-existent secret - this might return either 404 or 400 + console.log("Testing deletion of non-existent secret..."); + try { + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + `nonexistent${Date.now()}` + ); + // If it succeeds (idempotent deletion), that's acceptable too + console.log("Note: Deleting non-existent secret succeeded (idempotent behavior)"); + } catch (error: any) { + console.log(`Got expected error for non-existent secret: ${error.message}`); + // Accept either "Not Found" (404) or "Bad Request" (400) as valid responses + // Some APIs return 400 for resource that doesn't exist rather than 404 + expect(error.message).toMatch( + /not found|Resource not found|Bad Request|HTTP error! Status: 40[04]/i + ); + } + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Create project secret with duplicate key name", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Secret Duplicate Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project for testing + const projectName = `Test Secret Duplicate Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + try { + // Create a secret with a specific key name (alphanumeric only) + const duplicateKeyName = `duplicatesecretkey${Date.now()}`; + const secretValue1 = "firstsecretvalue"; + const firstSecret = await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + duplicateKeyName, + encodeSecret(secretValue1) + ); + expect(firstSecret).toBeDefined(); + + try { + // Try creating a second secret with the same key name but different value + const secretValue2 = "secondsecretvalue"; + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + duplicateKeyName, + encodeSecret(secretValue2) + ); + throw new Error("Should not be able to create duplicate key"); + } catch (error: any) { + // Expected error for duplicate key + expect(error.message).toMatch(/duplicate|already exists|Bad Request/i); + + // Clean up the first secret + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + duplicateKeyName + ); + } + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project secret operations require authentication", async () => { + try { + // First create an org and project while authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + const orgName = `Test Secret Auth Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + const projectName = `Test Secret Auth Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + // Create a secret with alphanumeric name + const secretKeyName = `authtestsecret${Date.now()}`; + const secretValue = "authenticatedsecretvalue"; + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName, + encodeSecret(secretValue) + ); + + // Now clear authentication and try operations + window.localStorage.clear(); + + // Try to list secrets without authentication + try { + await platformApi.listProjectSecrets(createdOrg.id.toString(), createdProject.id.toString()); + throw new Error("Should not be able to list secrets without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to create a secret without authentication + try { + await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + "newsecretkey", + encodeSecret("newsecretvalue") + ); + throw new Error("Should not be able to create secrets without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Try to delete a secret without authentication + try { + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName + ); + throw new Error("Should not be able to delete secrets without authentication"); + } catch (error: any) { + expect(error.message).toMatch(/unauthorized|unauthenticated|no access token|token/i); + } + + // Re-authenticate to clean up + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Clean up + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + secretKeyName + ); + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + await platformApi.deleteOrganization(createdOrg.id.toString()); + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project secret API flow with chained operations", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Secret Chain Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project for testing + const projectName = `Test Secret Chain Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + try { + // 1. Verify no secrets exist initially + const initialSecrets = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + expect(initialSecrets).toEqual([]); + + // 2. Create a series of secrets (with alphanumeric key names) + const secrets = []; + for (let i = 1; i <= 3; i++) { + const keyName = `chainedsecret${i}${Date.now()}`; + const value = `secretvalue${i}`; + + const secret = await platformApi.createProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + keyName, + encodeSecret(value) + ); + + expect(secret).toBeDefined(); + expect(secret.key_name).toBe(keyName); + secrets.push(keyName); + } + + // 3. List all secrets and verify they exist + const listedSecrets = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + expect(listedSecrets.length).toBe(3); + + for (const keyName of secrets) { + const found = listedSecrets.some((secret) => secret.key_name === keyName); + expect(found).toBe(true); + } + + // 4. Delete each secret one by one and verify it's gone + for (const keyName of secrets) { + await platformApi.deleteProjectSecret( + createdOrg.id.toString(), + createdProject.id.toString(), + keyName + ); + + const secretsAfterDelete = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + + const stillExists = secretsAfterDelete.some((secret) => secret.key_name === keyName); + expect(stillExists).toBe(false); + } + + // 5. Verify all secrets are gone + const finalSecrets = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + expect(finalSecrets).toEqual([]); + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); + +test("Project secret listing with no secrets", async () => { + try { + // Login first to get authenticated + const { access_token, refresh_token } = await tryDeveloperLogin(); + window.localStorage.setItem("access_token", access_token); + window.localStorage.setItem("refresh_token", refresh_token); + + // Create an organization for testing + const orgName = `Test Secret Empty Org ${Date.now()}`; + const createdOrg = await platformApi.createOrganization(orgName); + + try { + // Create a project for testing + const projectName = `Test Secret Empty Project ${Date.now()}`; + const createdProject = await platformApi.createProject(createdOrg.id.toString(), projectName); + + try { + // List secrets for a project that has none + const secrets = await platformApi.listProjectSecrets( + createdOrg.id.toString(), + createdProject.id.toString() + ); + + // Should return an empty array, not null or undefined + expect(Array.isArray(secrets)).toBe(true); + expect(secrets.length).toBe(0); + } finally { + // Clean up the project + await platformApi.deleteProject(createdOrg.id.toString(), createdProject.id.toString()); + } + } finally { + // Clean up the organization + await platformApi.deleteOrganization(createdOrg.id.toString()); + } + } catch (error: any) { + console.error("Test failed:", error.message); + throw error; + } +}); diff --git a/src/lib/test/integration/developerHook.test.ts b/src/lib/test/integration/developerHook.test.ts new file mode 100644 index 0000000..bce9e0a --- /dev/null +++ b/src/lib/test/integration/developerHook.test.ts @@ -0,0 +1,228 @@ +import { expect, test, beforeEach, mock } from "bun:test"; +import { PlatformLoginResponse } from "../../platformApi"; + +// Mock platform login response +const mockLoginResponse: PlatformLoginResponse = { + id: "test-id", + email: "test@example.com", + access_token: "test-access-token", + refresh_token: "test-refresh-token" +}; + +// Setup localStorage mock +beforeEach(() => { + window.localStorage.clear(); +}); + +test("Developer signIn method works correctly", async () => { + // Mock the signIn function + const mockSignIn = mock((email: string, password: string) => { + expect(email).toBe("test@example.com"); + expect(password).toBe("password"); + return Promise.resolve(mockLoginResponse); + }); + + // Create a mock context value + const mockContext = { + developer: { + loading: false, + developer: undefined + }, + signIn: mockSignIn, + signUp: () => Promise.resolve(mockLoginResponse), + signOut: () => {}, + createOrganization: () => Promise.resolve({ id: "org-id", name: "Test Org" }), + listOrganizations: () => Promise.resolve([]), + deleteOrganization: () => Promise.resolve(), + createProject: () => + Promise.resolve({ + id: 1, + uuid: "uuid", + client_id: "client-id", + name: "Test Project", + status: "active" + }), + listProjects: () => Promise.resolve([]), + updateProject: () => + Promise.resolve({ + id: 1, + uuid: "uuid", + client_id: "client-id", + name: "Test Project", + status: "active" + }), + deleteProject: () => Promise.resolve(), + createProjectSecret: () => + Promise.resolve({ key_name: "test", created_at: "", updated_at: "" }), + listProjectSecrets: () => Promise.resolve([]), + deleteProjectSecret: () => Promise.resolve(), + getEmailSettings: () => + Promise.resolve({ provider: "", send_from: "", email_verification_url: "" }), + updateEmailSettings: () => + Promise.resolve({ provider: "", send_from: "", email_verification_url: "" }), + getOAuthSettings: () => + Promise.resolve({ google_oauth_enabled: false, github_oauth_enabled: false }), + updateOAuthSettings: () => + Promise.resolve({ google_oauth_enabled: false, github_oauth_enabled: false }), + inviteDeveloper: () => Promise.resolve({ code: "code" }), + listOrganizationMembers: () => Promise.resolve([]), + updateMemberRole: () => Promise.resolve({ user_id: "", role: "" }), + removeMember: () => Promise.resolve(), + acceptInvite: () => Promise.resolve(), + apiUrl: "https://example.com" + }; + + // Mock direct call to sign in method + await mockContext.signIn("test@example.com", "password"); + + // Verify the mock was called + expect(mockSignIn).toHaveBeenCalled(); +}); + +test("Developer signUp method works correctly", async () => { + // Mock the signUp function + const mockSignUp = mock((email: string, password: string, name?: string) => { + expect(email).toBe("test@example.com"); + expect(password).toBe("password"); + expect(name).toBe("Test User"); + return Promise.resolve(mockLoginResponse); + }); + + // Create a mock context value + const mockContext = { + developer: { + loading: false, + developer: undefined + }, + signIn: () => Promise.resolve(mockLoginResponse), + signUp: mockSignUp, + signOut: () => {}, + createOrganization: () => Promise.resolve({ id: "org-id", name: "Test Org" }), + listOrganizations: () => Promise.resolve([]), + deleteOrganization: () => Promise.resolve(), + createProject: () => + Promise.resolve({ + id: 1, + uuid: "uuid", + client_id: "client-id", + name: "Test Project", + status: "active" + }), + listProjects: () => Promise.resolve([]), + updateProject: () => + Promise.resolve({ + id: 1, + uuid: "uuid", + client_id: "client-id", + name: "Test Project", + status: "active" + }), + deleteProject: () => Promise.resolve(), + createProjectSecret: () => + Promise.resolve({ key_name: "test", created_at: "", updated_at: "" }), + listProjectSecrets: () => Promise.resolve([]), + deleteProjectSecret: () => Promise.resolve(), + getEmailSettings: () => + Promise.resolve({ provider: "", send_from: "", email_verification_url: "" }), + updateEmailSettings: () => + Promise.resolve({ provider: "", send_from: "", email_verification_url: "" }), + getOAuthSettings: () => + Promise.resolve({ google_oauth_enabled: false, github_oauth_enabled: false }), + updateOAuthSettings: () => + Promise.resolve({ google_oauth_enabled: false, github_oauth_enabled: false }), + inviteDeveloper: () => Promise.resolve({ code: "code" }), + listOrganizationMembers: () => Promise.resolve([]), + updateMemberRole: () => Promise.resolve({ user_id: "", role: "" }), + removeMember: () => Promise.resolve(), + acceptInvite: () => Promise.resolve(), + apiUrl: "https://example.com" + }; + + // Mock direct call to sign up method + await mockContext.signUp("test@example.com", "password", "Test User"); + + // Verify the mock was called + expect(mockSignUp).toHaveBeenCalled(); +}); + +test("Developer signOut method works correctly", async () => { + // Setup localStorage with some tokens + window.localStorage.setItem("access_token", "test-token"); + window.localStorage.setItem("refresh_token", "test-refresh"); + + // Mock the signOut function + const mockSignOut = mock(() => { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + }); + + // Create mock state + const mockDeveloper = { + id: "user-id", + email: "test@example.com", + email_verified: true, + created_at: "2023-01-01", + updated_at: "2023-01-01", + organizations: [] + }; + + // Create a mock context value with authenticated developer + const mockContext = { + developer: { + loading: false, + developer: mockDeveloper + }, + signIn: () => Promise.resolve(mockLoginResponse), + signUp: () => Promise.resolve(mockLoginResponse), + signOut: mockSignOut, + createOrganization: () => Promise.resolve({ id: "org-id", name: "Test Org" }), + listOrganizations: () => Promise.resolve([]), + deleteOrganization: () => Promise.resolve(), + createProject: () => + Promise.resolve({ + id: 1, + uuid: "uuid", + client_id: "client-id", + name: "Test Project", + status: "active" + }), + listProjects: () => Promise.resolve([]), + updateProject: () => + Promise.resolve({ + id: 1, + uuid: "uuid", + client_id: "client-id", + name: "Test Project", + status: "active" + }), + deleteProject: () => Promise.resolve(), + createProjectSecret: () => + Promise.resolve({ key_name: "test", created_at: "", updated_at: "" }), + listProjectSecrets: () => Promise.resolve([]), + deleteProjectSecret: () => Promise.resolve(), + getEmailSettings: () => + Promise.resolve({ provider: "", send_from: "", email_verification_url: "" }), + updateEmailSettings: () => + Promise.resolve({ provider: "", send_from: "", email_verification_url: "" }), + getOAuthSettings: () => + Promise.resolve({ google_oauth_enabled: false, github_oauth_enabled: false }), + updateOAuthSettings: () => + Promise.resolve({ google_oauth_enabled: false, github_oauth_enabled: false }), + inviteDeveloper: () => Promise.resolve({ code: "code" }), + listOrganizationMembers: () => Promise.resolve([]), + updateMemberRole: () => Promise.resolve({ user_id: "", role: "" }), + removeMember: () => Promise.resolve(), + acceptInvite: () => Promise.resolve(), + apiUrl: "https://example.com" + }; + + // Call signOut + mockContext.signOut(); + + // Verify the mock was called + expect(mockSignOut).toHaveBeenCalled(); + + // Verify tokens were removed + expect(window.localStorage.getItem("access_token")).toBeNull(); + expect(window.localStorage.getItem("refresh_token")).toBeNull(); +}); diff --git a/src/lib/signing.test.ts b/src/lib/test/integration/signing.test.ts similarity index 99% rename from src/lib/signing.test.ts rename to src/lib/test/integration/signing.test.ts index 35edcc5..58ded20 100644 --- a/src/lib/signing.test.ts +++ b/src/lib/test/integration/signing.test.ts @@ -1,6 +1,6 @@ import { expect, test, beforeEach } from "bun:test"; -import { fetchLogin, signMessage, fetchPublicKey, fetchPrivateKeyBytes } from "./api"; -import { bytesToHex } from "./test/utils"; +import { fetchLogin, signMessage, fetchPublicKey, fetchPrivateKeyBytes } from "../../api"; +import { bytesToHex } from "../utils"; import { sha256 } from "@noble/hashes/sha256"; import { schnorr, secp256k1 } from "@noble/curves/secp256k1"; diff --git a/src/lib/test/platform-api-url-loader.ts b/src/lib/test/platform-api-url-loader.ts new file mode 100644 index 0000000..496a564 --- /dev/null +++ b/src/lib/test/platform-api-url-loader.ts @@ -0,0 +1,13 @@ +import { setPlatformApiUrl } from "../platformApi"; + +// Get the API URL from environment variables +const apiUrl = process.env.VITE_OPEN_SECRET_API_URL; + +if (!apiUrl) { + throw new Error("VITE_OPEN_SECRET_API_URL must be set in environment variables"); +} + +// Set the Platform API URL before tests run +setPlatformApiUrl(apiUrl); + +console.log("Platform API URL set to:", apiUrl); diff --git a/src/lib/util.ts b/src/lib/util.ts index d0c5f9d..27cdb20 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,12 +1,21 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; +// Simpler implementation compatible with React 19 export function useOnMount(callback: () => void) { - const hasRun = useRef(false); - + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { - if (!hasRun.current) { - hasRun.current = true; - callback(); - } - }, [callback]); + callback(); + }, []); +} + +// Helper function to sleep for a specified duration +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Extend the Window interface to include our custom property +declare global { + interface Window { + __PLATFORM_API_URL__?: string; + } } diff --git a/vite.config.ts b/vite.config.ts index 8fa374d..34fefba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,16 +51,20 @@ export default defineConfig({ fileName: (format) => `opensecret-react.${format}.js` }, rollupOptions: { - // Only externalize React and React DOM + // Externalize React and React DOM along with their internals external: [ "react", - "react-dom" + "react-dom", + "react/jsx-runtime", + /^react\/.*/, + /^react-dom\/.*/ ], output: { // Provide global variables to use in the UMD build globals: { react: "React", - "react-dom": "ReactDOM" + "react-dom": "ReactDOM", + "react/jsx-runtime": "React" } } }