diff --git a/examples/web-cli/api/sign.ts b/examples/web-cli/api/sign.ts index eb3293a2..658d2b8a 100644 --- a/examples/web-cli/api/sign.ts +++ b/examples/web-cli/api/sign.ts @@ -13,6 +13,8 @@ import { signCredentials, getSigningSecret } from "../server/sign-handler.js"; * Request Body: * - apiKey: string (required) - Ably API key in format "appId.keyId:secret" * - bypassRateLimit: boolean (optional) - Set to true for CI/testing + * - endpoint: string (optional) - Custom Ably endpoint URL + * - controlAPIHost: string (optional) - Custom control API host URL * * Response: * - signedConfig: string - JSON-encoded config that was signed @@ -35,14 +37,14 @@ export default async function handler( return res.status(500).json({ error: "Signing secret not configured" }); } - const { apiKey, bypassRateLimit } = req.body; + const { apiKey, bypassRateLimit, endpoint, controlAPIHost } = req.body; if (!apiKey) { return res.status(400).json({ error: "apiKey is required" }); } // Use shared signing logic - const result = signCredentials({ apiKey, bypassRateLimit }, secret); + const result = signCredentials({ apiKey, bypassRateLimit, endpoint, controlAPIHost }, secret); res.status(200).json(result); } diff --git a/examples/web-cli/package.json b/examples/web-cli/package.json index 636b2619..8334d7fa 100644 --- a/examples/web-cli/package.json +++ b/examples/web-cli/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run --passWithNoTests" }, "dependencies": { "@ably/react-web-cli": "workspace:*", @@ -35,6 +36,7 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^6.2.7", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.0" } } diff --git a/examples/web-cli/server/__tests__/sign-handler.test.ts b/examples/web-cli/server/__tests__/sign-handler.test.ts new file mode 100644 index 00000000..e1e4a630 --- /dev/null +++ b/examples/web-cli/server/__tests__/sign-handler.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { signCredentials } from "../sign-handler"; + +describe("signCredentials()", () => { + const TEST_SECRET = "test-secret-key"; + + describe("endpoint configuration", () => { + it("should include endpoint in signed config when provided", () => { + const { signedConfig } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: "https://custom.ably.io", + }, + TEST_SECRET + ); + + const config = JSON.parse(signedConfig); + expect(config.endpoint).toBe("https://custom.ably.io"); + }); + + it("should include controlAPIHost in signed config when provided", () => { + const { signedConfig } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + controlAPIHost: "https://control-api.ably.io", + }, + TEST_SECRET + ); + + const config = JSON.parse(signedConfig); + expect(config.controlAPIHost).toBe("https://control-api.ably.io"); + }); + + it("should include both endpoint and controlAPIHost when provided", () => { + const { signedConfig } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: "https://custom.ably.io", + controlAPIHost: "https://control-api.ably.io", + }, + TEST_SECRET + ); + + const config = JSON.parse(signedConfig); + expect(config.endpoint).toBe("https://custom.ably.io"); + expect(config.controlAPIHost).toBe("https://control-api.ably.io"); + }); + + it("should not include endpoint/controlAPIHost when not provided", () => { + const { signedConfig } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + }, + TEST_SECRET + ); + + const config = JSON.parse(signedConfig); + expect(config.endpoint).toBeUndefined(); + expect(config.controlAPIHost).toBeUndefined(); + }); + + it("should include standard fields along with endpoint config", () => { + const { signedConfig } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: true, + endpoint: "https://custom.ably.io", + }, + TEST_SECRET + ); + + const config = JSON.parse(signedConfig); + expect(config.apiKey).toBe("test-key"); + expect(config.bypassRateLimit).toBe(true); + expect(config.timestamp).toBeDefined(); + expect(config.endpoint).toBe("https://custom.ably.io"); + }); + + it("should generate valid signature with endpoint in config", () => { + const result = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: "https://custom.ably.io", + }, + TEST_SECRET + ); + + expect(result.signature).toBeDefined(); + expect(result.signature).toHaveLength(64); // HMAC-SHA256 produces 64-char hex string + }); + + it("should produce different signatures with and without endpoint", () => { + const withEndpoint = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: "https://custom.ably.io", + }, + TEST_SECRET + ); + + const withoutEndpoint = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + }, + TEST_SECRET + ); + + expect(withEndpoint.signature).not.toBe(withoutEndpoint.signature); + }); + }); +}); diff --git a/examples/web-cli/server/sign-handler.ts b/examples/web-cli/server/sign-handler.ts index e3c30cee..3fa50bbc 100644 --- a/examples/web-cli/server/sign-handler.ts +++ b/examples/web-cli/server/sign-handler.ts @@ -8,6 +8,8 @@ import crypto from "crypto"; export interface SignRequest { apiKey: string; bypassRateLimit?: boolean; + endpoint?: string; + controlAPIHost?: string; } export interface SignResponse { @@ -25,13 +27,15 @@ export function signCredentials( request: SignRequest, secret: string, ): SignResponse { - const { apiKey, bypassRateLimit } = request; + const { apiKey, bypassRateLimit, endpoint, controlAPIHost } = request; // Build config object (matches terminal server expectations) const config = { apiKey, timestamp: Date.now(), bypassRateLimit: bypassRateLimit || false, + ...(endpoint && { endpoint }), + ...(controlAPIHost && { controlAPIHost }), }; // Serialize to JSON - this exact string is what gets signed diff --git a/examples/web-cli/src/App.tsx b/examples/web-cli/src/App.tsx index c5dd584c..23dde336 100644 --- a/examples/web-cli/src/App.tsx +++ b/examples/web-cli/src/App.tsx @@ -169,13 +169,20 @@ function App() { // Handle authentication const handleAuthenticate = useCallback(async (newApiKey: string, remember?: boolean) => { try { + // Optional: Get endpoint configuration from environment variables + // Real implementations should determine these values based on their requirements + const endpoint = import.meta.env.VITE_ABLY_ENDPOINT; + const controlAPIHost = import.meta.env.VITE_ABLY_CONTROL_HOST; + // Call /api/sign endpoint to get signed config const response = await fetch('/api/sign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: newApiKey, - bypassRateLimit: false + bypassRateLimit: false, + ...(endpoint && { endpoint }), + ...(controlAPIHost && { controlAPIHost }), }) }); diff --git a/examples/web-cli/vite.config.ts b/examples/web-cli/vite.config.ts index e9aa281b..025b455c 100644 --- a/examples/web-cli/vite.config.ts +++ b/examples/web-cli/vite.config.ts @@ -36,7 +36,7 @@ function apiSignPlugin(): Plugin { req.on("end", () => { try { - const { apiKey, bypassRateLimit } = JSON.parse(body); + const { apiKey, bypassRateLimit, endpoint, controlAPIHost } = JSON.parse(body); if (!apiKey) { res.statusCode = 400; @@ -46,7 +46,7 @@ function apiSignPlugin(): Plugin { } // Use shared signing logic - const result = signCredentials({ apiKey, bypassRateLimit }, secret); + const result = signCredentials({ apiKey, bypassRateLimit, endpoint, controlAPIHost }, secret); res.statusCode = 200; res.setHeader("Content-Type", "application/json"); diff --git a/examples/web-cli/vitest.config.ts b/examples/web-cli/vitest.config.ts new file mode 100644 index 00000000..ea52ed56 --- /dev/null +++ b/examples/web-cli/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + testTimeout: 10000, + }, +}); diff --git a/packages/react-web-cli/src/__tests__/integration/endpoint-config.test.ts b/packages/react-web-cli/src/__tests__/integration/endpoint-config.test.ts new file mode 100644 index 00000000..0ae9b768 --- /dev/null +++ b/packages/react-web-cli/src/__tests__/integration/endpoint-config.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { createAuthPayload } from "../../terminal-shared"; +import crypto from "node:crypto"; + +/** + * Integration test for endpoint configuration flow + * Tests the complete flow from signing to auth payload creation + */ + +// Simulate the signCredentials function from examples/web-cli/server/sign-handler.ts +function signCredentials( + request: { + apiKey: string; + bypassRateLimit?: boolean; + endpoint?: string; + controlAPIHost?: string; + }, + secret: string, +): { signedConfig: string; signature: string } { + const { apiKey, bypassRateLimit, endpoint, controlAPIHost } = request; + + const config = { + apiKey, + timestamp: Date.now(), + bypassRateLimit: bypassRateLimit || false, + ...(endpoint && { endpoint }), + ...(controlAPIHost && { controlAPIHost }), + }; + + const configString = JSON.stringify(config); + const hmac = crypto.createHmac("sha256", secret); + hmac.update(configString); + const signature = hmac.digest("hex"); + + return { + signedConfig: configString, + signature, + }; +} + +describe("Endpoint Configuration Integration", () => { + const TEST_SECRET = "test-secret-key"; + + it("should pass custom endpoint through signing to auth payload", () => { + const customEndpoint = "https://staging.ably.io"; + const customControlAPI = "https://control-staging.ably.io"; + + // 1. Sign credentials with endpoint + const { signedConfig, signature } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: customEndpoint, + controlAPIHost: customControlAPI, + }, + TEST_SECRET, + ); + + // 2. Create auth payload + const payload = createAuthPayload("session-123", signedConfig, signature); + + // 3. Verify endpoint is in environment variables + expect(payload.environmentVariables.ABLY_ENDPOINT).toBe(customEndpoint); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBe( + customControlAPI, + ); + }); + + it("should work without endpoint configuration", () => { + // 1. Sign credentials without endpoint + const { signedConfig, signature } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + }, + TEST_SECRET, + ); + + // 2. Create auth payload + const payload = createAuthPayload("session-123", signedConfig, signature); + + // 3. Verify endpoint is not in environment variables + expect(payload.environmentVariables.ABLY_ENDPOINT).toBeUndefined(); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBeUndefined(); + + // 4. Verify standard env vars are still present + expect(payload.environmentVariables.ABLY_WEB_CLI_MODE).toBe("true"); + expect(payload.environmentVariables.PS1).toBe("ably> "); + }); + + it("should handle only endpoint without controlAPIHost", () => { + const customEndpoint = "https://staging.ably.io"; + + const { signedConfig, signature } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: customEndpoint, + }, + TEST_SECRET, + ); + + const payload = createAuthPayload("session-123", signedConfig, signature); + + expect(payload.environmentVariables.ABLY_ENDPOINT).toBe(customEndpoint); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBeUndefined(); + }); + + it("should handle only controlAPIHost without endpoint", () => { + const customControlAPI = "https://control-staging.ably.io"; + + const { signedConfig, signature } = signCredentials( + { + apiKey: "test-key", + bypassRateLimit: false, + controlAPIHost: customControlAPI, + }, + TEST_SECRET, + ); + + const payload = createAuthPayload("session-123", signedConfig, signature); + + expect(payload.environmentVariables.ABLY_ENDPOINT).toBeUndefined(); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBe( + customControlAPI, + ); + }); + + it("should preserve signature integrity with endpoint in config", () => { + const request = { + apiKey: "test-key", + bypassRateLimit: false, + endpoint: "https://staging.ably.io", + }; + + const { signedConfig, signature } = signCredentials(request, TEST_SECRET); + + // Verify signature by recomputing it + const hmac = crypto.createHmac("sha256", TEST_SECRET); + hmac.update(signedConfig); + const recomputedSignature = hmac.digest("hex"); + + expect(signature).toBe(recomputedSignature); + }); + + it("should maintain backward compatibility when endpoint is not provided", () => { + // Old-style request without endpoint + const oldStyleRequest = { + apiKey: "test-key", + bypassRateLimit: true, + }; + + const { signedConfig, signature } = signCredentials( + oldStyleRequest, + TEST_SECRET, + ); + const payload = createAuthPayload("session-456", signedConfig, signature); + + // Verify old behavior still works + expect(payload.config).toBe(signedConfig); + expect(payload.signature).toBe(signature); + expect(payload.apiKey).toBe("test-key"); + + // Verify no endpoint vars are added + expect(payload.environmentVariables.ABLY_ENDPOINT).toBeUndefined(); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBeUndefined(); + + // Verify standard vars still present + expect(payload.environmentVariables.ABLY_WEB_CLI_MODE).toBe("true"); + }); +}); diff --git a/packages/react-web-cli/src/terminal-shared.test.ts b/packages/react-web-cli/src/terminal-shared.test.ts new file mode 100644 index 00000000..ec5a1d27 --- /dev/null +++ b/packages/react-web-cli/src/terminal-shared.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { createAuthPayload } from "./terminal-shared"; + +describe("createAuthPayload()", () => { + describe("endpoint configuration", () => { + it("should extract endpoint from signed config and add to environment variables", () => { + const signedConfig = JSON.stringify({ + apiKey: "test-key", + timestamp: Date.now(), + endpoint: "https://custom.ably.io", + }); + + const payload = createAuthPayload( + "session-123", + signedConfig, + "signature", + ); + + expect(payload.environmentVariables.ABLY_ENDPOINT).toBe( + "https://custom.ably.io", + ); + }); + + it("should extract controlAPIHost from signed config and add to environment variables", () => { + const signedConfig = JSON.stringify({ + apiKey: "test-key", + timestamp: Date.now(), + controlAPIHost: "https://control-api.ably.io", + }); + + const payload = createAuthPayload( + "session-123", + signedConfig, + "signature", + ); + + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBe( + "https://control-api.ably.io", + ); + }); + + it("should handle both endpoint and controlAPIHost together", () => { + const signedConfig = JSON.stringify({ + apiKey: "test-key", + timestamp: Date.now(), + endpoint: "https://custom.ably.io", + controlAPIHost: "https://control-api.ably.io", + }); + + const payload = createAuthPayload( + "session-123", + signedConfig, + "signature", + ); + + expect(payload.environmentVariables.ABLY_ENDPOINT).toBe( + "https://custom.ably.io", + ); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBe( + "https://control-api.ably.io", + ); + }); + + it("should not add endpoint environment variables when not present in config", () => { + const signedConfig = JSON.stringify({ + apiKey: "test-key", + timestamp: Date.now(), + }); + + const payload = createAuthPayload( + "session-123", + signedConfig, + "signature", + ); + + expect(payload.environmentVariables.ABLY_ENDPOINT).toBeUndefined(); + expect( + payload.environmentVariables.ABLY_CONTROL_API_HOST, + ).toBeUndefined(); + }); + + it("should include base environment variables regardless of endpoint config", () => { + const signedConfig = JSON.stringify({ + apiKey: "test-key", + timestamp: Date.now(), + endpoint: "https://custom.ably.io", + }); + + const payload = createAuthPayload( + "session-123", + signedConfig, + "signature", + ); + + expect(payload.environmentVariables.ABLY_WEB_CLI_MODE).toBe("true"); + expect(payload.environmentVariables.PS1).toBe("ably> "); + expect(payload.environmentVariables.ABLY_ENDPOINT).toBe( + "https://custom.ably.io", + ); + }); + + it("should extract apiKey and accessToken along with endpoint config", () => { + const signedConfig = JSON.stringify({ + apiKey: "test-key-123", + accessToken: "test-token-456", + timestamp: Date.now(), + endpoint: "https://custom.ably.io", + controlAPIHost: "https://control-api.ably.io", + }); + + const payload = createAuthPayload( + "session-123", + signedConfig, + "signature", + ); + + expect(payload.apiKey).toBe("test-key-123"); + expect(payload.accessToken).toBe("test-token-456"); + expect(payload.environmentVariables.ABLY_ENDPOINT).toBe( + "https://custom.ably.io", + ); + expect(payload.environmentVariables.ABLY_CONTROL_API_HOST).toBe( + "https://control-api.ably.io", + ); + }); + }); +}); diff --git a/packages/react-web-cli/src/terminal-shared.ts b/packages/react-web-cli/src/terminal-shared.ts index 80c6d316..5396dd7c 100644 --- a/packages/react-web-cli/src/terminal-shared.ts +++ b/packages/react-web-cli/src/terminal-shared.ts @@ -211,9 +211,21 @@ export function createAuthPayload( if (parsedConfig.accessToken) { payload.accessToken = parsedConfig.accessToken; } + + // Extract endpoint configuration and add to environment variables + if (parsedConfig.endpoint) { + payload.environmentVariables.ABLY_ENDPOINT = parsedConfig.endpoint; + } + if (parsedConfig.controlAPIHost) { + payload.environmentVariables.ABLY_CONTROL_API_HOST = + parsedConfig.controlAPIHost; + } + console.log("[createAuthPayload] Using signed config auth", { hasApiKey: !!payload.apiKey, hasAccessToken: !!payload.accessToken, + hasEndpoint: !!parsedConfig.endpoint, + hasControlAPIHost: !!parsedConfig.controlAPIHost, }); } catch (error) { console.warn("[createAuthPayload] Failed to parse signed config:", error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1757323..f30701ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@16.18.11)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4)) + vitest: + specifier: ^4.0.0 + version: 4.0.14(@edge-runtime/vm@3.2.0)(@types/node@16.18.11)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4) packages/react-web-cli: dependencies: @@ -9153,6 +9156,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.14(vite@6.3.4(@types/node@16.18.11)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4))': + dependencies: + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.4(@types/node@16.18.11)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4) + '@vitest/mocker@4.0.14(vite@6.3.4(@types/node@20.17.30)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4))': dependencies: '@vitest/spy': 4.0.14 @@ -9195,7 +9206,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.14(@edge-runtime/vm@3.2.0)(@types/node@20.17.30)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4) + vitest: 4.0.14(@edge-runtime/vm@3.2.0)(@types/node@16.18.11)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4) '@vitest/utils@4.0.14': dependencies: @@ -13274,6 +13285,46 @@ snapshots: lightningcss: 1.29.2 tsx: 4.19.4 + vitest@4.0.14(@edge-runtime/vm@3.2.0)(@types/node@16.18.11)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4): + dependencies: + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@6.3.4(@types/node@16.18.11)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.3.4(@types/node@16.18.11)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 3.2.0 + '@types/node': 16.18.11 + '@vitest/ui': 4.0.14(vitest@4.0.14) + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.14(@edge-runtime/vm@3.2.0)(@types/node@20.17.30)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4): dependencies: '@vitest/expect': 4.0.14 diff --git a/scripts/pre-push-validation.sh b/scripts/pre-push-validation.sh index 9b9d5a67..b70d5be5 100755 --- a/scripts/pre-push-validation.sh +++ b/scripts/pre-push-validation.sh @@ -58,6 +58,16 @@ echo "๐Ÿงช Step 3: Running unit tests..." run_quiet pnpm test:unit echo " โœ… Unit tests passed" +# Step 3b: React Web CLI package tests +echo "๐Ÿงช Step 3b: Running react-web-cli package tests..." +run_quiet pnpm test:react-web-cli +echo " โœ… React web CLI tests passed" + +# Step 3c: Example tests +echo "๐Ÿงช Step 3c: Running example tests..." +run_quiet pnpm --filter ably-web-cli-example test +echo " โœ… Example tests passed" + # Step 4: Basic E2E tests (critical path) echo "๐ŸŽฏ Step 4: Running basic E2E tests..." run_quiet pnpm test:e2e:basic @@ -69,7 +79,7 @@ run_quiet pnpm test:integration echo " โœ… Integration tests passed" # Note: Full E2E test suite should be run in CI, not pre-push -# Pre-push only runs unit, integration, and basic E2E tests +# Pre-push runs: unit tests, package tests (react-web-cli, examples), integration, and basic E2E tests echo "โœ… All pre-push validation steps completed successfully!"