diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 000000000..dd0be9f27 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,62 @@ +name: Checks + +# Lint, unit tests, and the type-check + build gate on every PR that touches +# the ipaas frontend. Unlike the E2E workflows, none of this needs live +# credentials or a reachable backend, so it runs on every push too for +# branch-health visibility. +# +# Not yet a required status check. To make it required: add +# "Checks / Lint, test, build check" to branch protection rules under +# Settings → Branches → Required status checks. + +on: + pull_request: + branches: + - main + - devant-migration + paths: + - 'ipaas/**' + push: + branches: + - main + - devant-migration + paths: + - 'ipaas/**' + +jobs: + checks: + name: Lint, test, build check + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: ipaas/pnpm-lock.yaml + + - name: Install dependencies + working-directory: ipaas + run: pnpm install --frozen-lockfile + + - name: Lint + working-directory: ipaas + run: pnpm lint + + - name: Unit tests + working-directory: ipaas + run: pnpm test + + - name: Type check + build + working-directory: ipaas + run: pnpm build:check diff --git a/ipaas/AGENTS.md b/ipaas/AGENTS.md index f3800871e..301f79a34 100644 --- a/ipaas/AGENTS.md +++ b/ipaas/AGENTS.md @@ -14,8 +14,8 @@ Every feature follows a strict four-layer separation. Violating these boundaries ┌─────────────────────────────────────────────────┐ │ pages/ components/ │ UI only │ Import from: hooks/, types/, constants/, │ -│ utils/, assets/, auth/ (OAuth │ -│ state utilities only — see below) │ +│ utils/, assets/, auth/oauthState │ +│ (OAuth CSRF state — see below) │ └───────────────┬─────────────────────────────────┘ │ imports ┌───────────────▼─────────────────────────────────┐ @@ -42,20 +42,21 @@ utils/ ← same config/ ← same ``` -**The single most important rule**: components and pages must never import directly from `api/`, `auth/tokenManager` (data functions), or any backend transport. All data access goes through `hooks/`. +**The single most important rule**: components and pages must never import directly from `api/`, `auth/tokenManager`, or any backend transport. All data access goes through `hooks/`. --- ## The one accepted exception -`auth/tokenManager.ts` exports two categories of functions: - -| Category | Examples | Used in | -|---|---|---| -| OAuth CSRF state (pure local storage utilities) | `generateAndSaveGitHubState`, `validateAndClearGitHubState`, `validateAndClearOIDCState`, `getAndClearRedirectUrl` | pages — acceptable | -| Token/data access | `getOrgUuidFromToken`, `authenticatedFetch` | hooks only, via `useOrgUuid()` | - -Pages may import the OAuth CSRF helpers directly because they are pure client-side state utilities with no cache semantics, not data access. +OAuth CSRF state (pure local storage/sessionStorage utilities — no token or network access) +lives in its own module, `auth/oauthState.ts`, separate from `auth/tokenManager.ts` (token/data +access, e.g. `getOrgUuidFromToken`, `authenticatedFetch` — hooks only, via `useOrgUuid()`). +Pages may import `auth/oauthState.ts` directly (`generateAndSaveGitHubState`, +`validateAndClearGitHubState`, `validateAndClearOIDCState`, `getAndClearRedirectUrl`) because +it holds nothing but pure client-side state utilities with no cache semantics, not data access. +`auth/tokenManager.ts` itself stays off-limits to pages with no per-file exception — splitting +the CSRF helpers into their own file makes this an allowlist by construction rather than an +ESLint exception list that has to be kept in sync by hand. --- diff --git a/ipaas/CLAUDE.md b/ipaas/CLAUDE.md index 157b73200..c6f6f1b13 100644 --- a/ipaas/CLAUDE.md +++ b/ipaas/CLAUDE.md @@ -59,6 +59,17 @@ pnpm dev:cloud # Cloud variant pnpm dev:icp # ICP variant ``` +## Running unit tests + +```bash +pnpm test # run once (CI-friendly) +pnpm test:watch # watch mode +``` + +Vitest, no live backend or credentials needed. Co-located as `.test.ts` next to the +source file (e.g. `src/utils/identifyIntegration.test.ts`). `#api`/`#product` resolve to +`wip` in tests, same as the IDE (see `vitest.config.ts`). + ## Running e2e tests ```bash diff --git a/ipaas/eslint.config.js b/ipaas/eslint.config.js index 5068957ab..258e948c6 100644 --- a/ipaas/eslint.config.js +++ b/ipaas/eslint.config.js @@ -22,6 +22,23 @@ import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; +// `group` patterns are matched gitignore-style: a leading `#` starts a comment +// unless escaped, so the alias must be written as `\#api`, not `#api`. +const NO_DIRECT_API_PATTERN = { + group: ['\\#api', '\\#api/*'], + message: 'UI code must not import api/ directly — call a hook from src/hooks/ instead. See AGENTS.md (the four-layer architecture).', +}; + +// Flat config does not merge `no-restricted-imports` option objects across +// matching blocks — the last matching block's value wins outright. Every +// block below that sets this rule must therefore repeat this pattern, or +// files it covers would silently lose the "never a literal product folder" +// restriction set by the repo-wide block. +const NO_LITERAL_PRODUCT_FOLDER_PATTERN = { + group: ['**/api/wip/*', '**/api/cloud/*', '**/api/icp/*'], + message: "Import product API functions via the '#api/' alias, never a literal product folder — see AGENTS.md.", +}; + export default [ { ignores: ['dist', 'playwright-report', 'test-results'] }, js.configs.recommended, @@ -42,4 +59,44 @@ export default [ '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_', argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_', ignoreRestSiblings: true }], }, }, + // Everywhere: go through the #api alias, never a literal product folder + // (src/api/_check.ts is exempt — it intentionally imports every product's + // real files to assert them against contracts.ts at compile time). + { + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/api/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [NO_LITERAL_PRODUCT_FOLDER_PATTERN], + }, + ], + }, + }, + // UI layer (pages/components/layouts/contexts): no direct api/ access and no + // direct auth/tokenManager data access — all server state and token data + // must flow through src/hooks/. See AGENTS.md, src/pages/AGENTS.md, + // src/components/AGENTS.md. + // + // No per-file exception for the OAuth CSRF helpers: they live in their own + // module, src/auth/oauthState.ts, which this block does not restrict. That + // makes the allowed surface an allowlist by construction (oauthState.ts's + // exports) rather than a denylist of tokenManager.ts's other exports that + // would silently go stale if tokenManager.ts gained a new export. + { + files: ['src/pages/**/*.{ts,tsx}', 'src/components/**/*.{ts,tsx}', 'src/layouts/**/*.{ts,tsx}', 'src/contexts/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + NO_DIRECT_API_PATTERN, + NO_LITERAL_PRODUCT_FOLDER_PATTERN, + { group: ['**/auth/tokenManager'], message: 'auth/tokenManager is raw token/data access — use a hook (useAuth, useOrgUuid, ...) instead. The OAuth CSRF helpers are in the separate src/auth/oauthState module (see src/pages/AGENTS.md), which is not restricted.' }, + ], + }, + ], + }, + }, ]; diff --git a/ipaas/package.json b/ipaas/package.json index d704e9544..ab70602aa 100644 --- a/ipaas/package.json +++ b/ipaas/package.json @@ -11,6 +11,8 @@ "build:cloud": "PRODUCT=cloud vite build", "build:icp": "PRODUCT=icp vite build", "build:check": "tsc -b && PRODUCT=wip vite build", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx}\"", @@ -57,12 +59,14 @@ "eslint-plugin-react-refresh": "0.4.24", "globals": "16.4.0", "googleapis": "^173.0.0", + "jsdom": "^29.1.1", "prettier": "^3.8.1", "rollup-plugin-visualizer": "6.0.5", "sass": "^1.99.0", "tsx": "^4.22.4", "typescript": "5.9.3", "typescript-eslint": "8.45.0", - "vite": "7.1.12" + "vite": "7.1.12", + "vitest": "^4.1.9" } } diff --git a/ipaas/src/auth/AuthContext.tsx b/ipaas/src/auth/AuthContext.tsx index 0ac694a95..1d8c5311a 100644 --- a/ipaas/src/auth/AuthContext.tsx +++ b/ipaas/src/auth/AuthContext.tsx @@ -30,7 +30,6 @@ import { getRefreshToken, revokeToken, setOnAuthFailure, - generateAndSaveOIDCState, generatePKCE, saveCodeVerifier, getAndClearCodeVerifier, @@ -40,6 +39,7 @@ import { saveOidcAuthMetadata, clearOidcAuthMetadata, } from './tokenManager'; +import { generateAndSaveOIDCState } from './oauthState'; const USER_KEY = 'icp_user'; diff --git a/ipaas/src/auth/ProtectedRoute.tsx b/ipaas/src/auth/ProtectedRoute.tsx index cd2a56634..3ac818240 100644 --- a/ipaas/src/auth/ProtectedRoute.tsx +++ b/ipaas/src/auth/ProtectedRoute.tsx @@ -23,7 +23,7 @@ import { useAuth } from './AuthContext'; import { useAccessControl } from '../contexts/AccessControlContext'; import { fetchOrgPermissions } from '#api/auth'; import { loginUrl, forceChangePasswordUrl } from '../paths'; -import { saveRedirectUrl } from './tokenManager'; +import { saveRedirectUrl } from './oauthState'; import { Permissions } from '../constants/permissions'; export default function ProtectedRoute(): JSX.Element { diff --git a/ipaas/src/auth/oauthState.ts b/ipaas/src/auth/oauthState.ts new file mode 100644 index 000000000..b1e321b5f --- /dev/null +++ b/ipaas/src/auth/oauthState.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * OAuth CSRF state — pure localStorage/sessionStorage utilities, no token or + * network access. Deliberately a separate module from tokenManager.ts (which + * holds actual token/data access): pages and components may import from here + * directly (see src/pages/AGENTS.md, src/components/AGENTS.md). Keeping this + * file's export surface limited to exactly these helpers — rather than + * allow-listing names out of tokenManager.ts — means a future addition to + * tokenManager.ts (e.g. a new data-access function) can never become + * importable from UI code by accident; it would have to be added here + * explicitly first. + */ + +const REDIRECT_URL_KEY = 'icp_redirect_url'; +const OIDC_STATE_KEY = 'icp_oidc_state'; + +export function saveRedirectUrl(url: string): void { + localStorage.setItem(REDIRECT_URL_KEY, url); +} + +export function getAndClearRedirectUrl(): string | null { + const url = localStorage.getItem(REDIRECT_URL_KEY); + localStorage.removeItem(REDIRECT_URL_KEY); + return url; +} + +export function generateAndSaveOIDCState(): string { + const state = crypto.randomUUID(); + localStorage.setItem(OIDC_STATE_KEY, state); + return state; +} + +export function validateAndClearOIDCState(state: string): boolean { + const savedState = localStorage.getItem(OIDC_STATE_KEY); + localStorage.removeItem(OIDC_STATE_KEY); + return savedState === state; +} + +// GitHub OAuth CSRF state — sessionStorage so it's scoped to the initiating tab +const GITHUB_OAUTH_STATE_KEY = 'icp_github_oauth_state'; + +export function generateAndSaveGitHubState(): string { + const state = crypto.randomUUID(); + sessionStorage.setItem(GITHUB_OAUTH_STATE_KEY, state); + return state; +} + +export function validateAndClearGitHubState(state: string): boolean { + const saved = sessionStorage.getItem(GITHUB_OAUTH_STATE_KEY); + sessionStorage.removeItem(GITHUB_OAUTH_STATE_KEY); + return saved !== null && saved === state; +} diff --git a/ipaas/src/auth/tokenManager.ts b/ipaas/src/auth/tokenManager.ts index d2b748a96..2e5b3966f 100644 --- a/ipaas/src/auth/tokenManager.ts +++ b/ipaas/src/auth/tokenManager.ts @@ -23,8 +23,6 @@ const ACCESS_TOKEN_KEY = 'icp_auth_token'; const REFRESH_TOKEN_KEY = 'icp_refresh_token'; const TOKEN_EXPIRES_AT_KEY = 'icp_token_expires_at'; const REFRESH_TOKEN_EXPIRES_AT_KEY = 'icp_refresh_token_expires_at'; -const REDIRECT_URL_KEY = 'icp_redirect_url'; -const OIDC_STATE_KEY = 'icp_oidc_state'; const OIDC_AUTH_MODE_KEY = 'icp_auth_mode'; const OIDC_ORG_HANDLE_KEY = 'icp_org_handle'; @@ -415,43 +413,6 @@ export async function revokeToken(): Promise { } } -export function saveRedirectUrl(url: string): void { - localStorage.setItem(REDIRECT_URL_KEY, url); -} - -export function getAndClearRedirectUrl(): string | null { - const url = localStorage.getItem(REDIRECT_URL_KEY); - localStorage.removeItem(REDIRECT_URL_KEY); - return url; -} - -export function generateAndSaveOIDCState(): string { - const state = crypto.randomUUID(); - localStorage.setItem(OIDC_STATE_KEY, state); - return state; -} - -export function validateAndClearOIDCState(state: string): boolean { - const savedState = localStorage.getItem(OIDC_STATE_KEY); - localStorage.removeItem(OIDC_STATE_KEY); - return savedState === state; -} - -// GitHub OAuth CSRF state — sessionStorage so it's scoped to the initiating tab -const GITHUB_OAUTH_STATE_KEY = 'icp_github_oauth_state'; - -export function generateAndSaveGitHubState(): string { - const state = crypto.randomUUID(); - sessionStorage.setItem(GITHUB_OAUTH_STATE_KEY, state); - return state; -} - -export function validateAndClearGitHubState(state: string): boolean { - const saved = sessionStorage.getItem(GITHUB_OAUTH_STATE_KEY); - sessionStorage.removeItem(GITHUB_OAUTH_STATE_KEY); - return saved !== null && saved === state; -} - // --------------------------------------------------------------------------- // PKCE helpers // --------------------------------------------------------------------------- diff --git a/ipaas/src/components/AiCopilot/ApiChatMessage.tsx b/ipaas/src/components/AiCopilot/ApiChatMessage.tsx index 4da31bdb8..5eade74a6 100644 --- a/ipaas/src/components/AiCopilot/ApiChatMessage.tsx +++ b/ipaas/src/components/AiCopilot/ApiChatMessage.tsx @@ -20,10 +20,14 @@ import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Divider, St import { AlertCircle, CheckCircle2, ChevronDown, Terminal } from '@wso2/oxygen-ui-icons-react'; import { useState } from 'react'; import type { JSX } from 'react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; import { prism } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'; import type { ApiChatExecutionResult } from '../../types/copilot'; +// Only JSON is ever rendered here (API execution payloads). +SyntaxHighlighter.registerLanguage('json', json); + interface ParsedResult { resource?: { method?: string; inputs?: { requestBody?: unknown } }; output?: { code?: number; path?: string; headers?: Record; body?: unknown }; diff --git a/ipaas/src/components/CodeViewer.tsx b/ipaas/src/components/CodeViewer.tsx index 5b40f807e..5eee94ed7 100644 --- a/ipaas/src/components/CodeViewer.tsx +++ b/ipaas/src/components/CodeViewer.tsx @@ -19,8 +19,23 @@ import { useCallback, useState, useMemo } from 'react'; import { Box, Button, Stack, Typography } from '@wso2/oxygen-ui'; import { Copy, Check } from '@wso2/oxygen-ui-icons-react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; import { prism } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup'; +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'; +import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'; +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; + +// The light Prism build ships no languages by default — register only what +// CodeViewerProps['language'] supports. 'markup' registers the 'xml' alias +// itself; 'text' needs no registration (the library special-cases it as +// plain, unhighlighted output). +SyntaxHighlighter.registerLanguage('markup', markup); +SyntaxHighlighter.registerLanguage('json', json); +SyntaxHighlighter.registerLanguage('yaml', yaml); +SyntaxHighlighter.registerLanguage('javascript', javascript); +SyntaxHighlighter.registerLanguage('typescript', typescript); const formatCode = (code: string, language: string): string => { if (!code) return 'No content available.'; diff --git a/ipaas/src/config/runtimeConfig.test.ts b/ipaas/src/config/runtimeConfig.test.ts new file mode 100644 index 000000000..bdbac2723 --- /dev/null +++ b/ipaas/src/config/runtimeConfig.test.ts @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import { loadConfig, getDevPortalBaseUrl, getDevPortalApiUrl, type ApiConfig } from './runtimeConfig'; + +// window.location.origin is pinned to this in vitest.config.ts so the +// origin-derived defaults (asgardeoSignInRedirectUrl, githubAppAuthRedirectUrl) +// are deterministic. +const ORIGIN = 'https://app.test'; + +// runtimeConfig.ts computes this once, at module load, from import.meta.env.DEV. +// Derive it the same way here instead of hardcoding either branch, so this +// test doesn't depend on which mode vitest happens to run under. +const DEFAULT_SUBSCRIPTIONS_API_URL = import.meta.env.DEV ? '/subscriptions-proxy' : 'https://subscriptions.dv.wso2.com'; + +function mockFetch(json: unknown, ok = true, status = 200): void { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok, + status, + json: () => Promise.resolve(json), + }), + ); +} + +beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('loadConfig', () => { + it('maps every config.json field to its ApiConfig field, trimming/deriving where the source does', async () => { + mockFetch({ + VITE_GRAPHQL_URL: 'https://graphql.example.com/graphql', + VITE_AUTH_BASE_URL: 'https://auth.example.com/auth/', + VITE_OBSERVABILITY_URL: 'https://obs.example.com/', + VITE_ALERTING_URL: 'https://alert.example.com/', + SYSTEM_APIS_BASE_URL: 'https://sysapis.example.com', + BILLING_API_BASE_URL: 'https://billing.example.com/', + ASGARDEO_CLIENT_ID: 'client-123', + ASGARDEO_AUTHORIZE_ENDPOINT: 'https://idp.example.com/authorize', + ASGARDEO_TOKEN_ENDPOINT: 'https://idp.example.com/token', + ASGARDEO_SIGN_IN_REDIRECT_URL: 'https://app.example.com/signin', + ASGARDEO_SCOPE: 'openid email', + STS_TOKEN_ENDPOINT: 'https://sts.example.com/token', + STS_CLIENT_ID: 'sts-client-1', + STS_SCOPE: 'scope-a scope-b', + CHOREO_BASE_API_URL: 'https://choreo.example.com/', + APIM_BASE_URL: 'https://apim.example.com/', + INSIGHTS_BASE_URL: 'https://insights.example.com/', + ASGARDEO_ORG_NUMERIC_ID: '42', + SYS_API_PREFIX: 'prefix-xyz', + GITHUB_APP_CLIENT_ID: 'gh-client-1', + GITHUB_APP_AUTH_REDIRECTION_URL: 'https://app.example.com/ghapp', + SUBSCRIPTIONS_API_URL: 'https://subs.example.com', + SAMPLES_URL: 'https://samples.example.com', + PREBUILT_INTEGRATIONS_URL: 'https://prebuilt.example.com', + ASGARDEO_SIGNUP_URL: 'https://idp.example.com/signup', + AI_COPILOT_URL_SUFFIX: '/copilot-suffix', + AI_COPILOT_DATACOLLECTOR_BASE_URL: 'https://copilot-dc.example.com/', + }); + + await loadConfig(); + + expect(window.API_CONFIG).toEqual({ + graphqlUrl: 'https://graphql.example.com/graphql', + authBaseUrl: 'https://auth.example.com/auth', + observabilityUrl: 'https://obs.example.com', + alertingUrl: 'https://alert.example.com', + asgardeoClientId: 'client-123', + asgardeoAuthorizeEndpoint: 'https://idp.example.com/authorize', + asgardeoTokenEndpoint: 'https://idp.example.com/token', + asgardeoSignInRedirectUrl: 'https://app.example.com/signin', + asgardeoScope: 'openid email', + stsTokenEndpoint: 'https://sts.example.com/token', + stsClientId: 'sts-client-1', + stsScope: 'scope-a scope-b', + choreoBaseApiUrl: 'https://choreo.example.com', + choreoOrgApiUrl: 'https://choreo.example.com/orgs/1.0.0', + apimBaseUrl: 'https://apim.example.com', + insightsBaseUrl: 'https://insights.example.com', + systemApisBaseUrl: 'https://sysapis.example.com', + asgardeoOrgNumericId: 42, + sysApiPrefix: 'prefix-xyz', + githubAppClientId: 'gh-client-1', + githubAppAuthRedirectUrl: 'https://app.example.com/ghapp', + subscriptionsApiUrl: 'https://subs.example.com', + billingApiBaseUrl: 'https://billing.example.com', + samplesUrl: 'https://samples.example.com', + prebuiltIntegrationsUrl: 'https://prebuilt.example.com', + asgardeoSignupUrl: 'https://idp.example.com/signup', + aiCopilotUrlSuffix: '/copilot-suffix', + aiCopilotDatacollectorBaseUrl: 'https://copilot-dc.example.com', + }); + }); + + it('falls back to hardcoded defaults for fields config.json omits', async () => { + mockFetch({}); + + await loadConfig(); + + expect(window.API_CONFIG.graphqlUrl).toBe('https://apis.preview-dv.choreo.dev/projects/1.0.0/graphql'); + expect(window.API_CONFIG.authBaseUrl).toBe('https://localhost:9445/auth'); + expect(window.API_CONFIG.choreoBaseApiUrl).toBe('https://apis.preview-dv.choreo.dev'); + expect(window.API_CONFIG.choreoOrgApiUrl).toBe('https://apis.preview-dv.choreo.dev/orgs/1.0.0'); + expect(window.API_CONFIG.alertingUrl).toBe('https://localhost:9448/icp/alerting'); + expect(window.API_CONFIG.asgardeoSignInRedirectUrl).toBe(`${ORIGIN}/signin`); + expect(window.API_CONFIG.githubAppAuthRedirectUrl).toBe(`${ORIGIN}/ghapp`); + expect(window.API_CONFIG.systemApisBaseUrl).toBe(''); + expect(window.API_CONFIG.asgardeoOrgNumericId).toBeUndefined(); + }); + + it('trims exactly one trailing slash from URL fields, but not graphqlUrl or the sign-in redirect URL', async () => { + mockFetch({ + VITE_AUTH_BASE_URL: 'https://auth.example.com//', + VITE_OBSERVABILITY_URL: 'https://obs.example.com/', + APIM_BASE_URL: 'https://apim.example.com/', + INSIGHTS_BASE_URL: 'https://insights.example.com/', + CHOREO_BASE_API_URL: 'https://choreo.example.com/', + }); + + await loadConfig(); + + // The trim regex (/\/$/) strips a single trailing slash, leaving one behind + // if the input had two — this pins that exact (possibly surprising) behavior. + expect(window.API_CONFIG.authBaseUrl).toBe('https://auth.example.com/'); + expect(window.API_CONFIG.observabilityUrl).toBe('https://obs.example.com'); + expect(window.API_CONFIG.apimBaseUrl).toBe('https://apim.example.com'); + expect(window.API_CONFIG.insightsBaseUrl).toBe('https://insights.example.com'); + expect(window.API_CONFIG.choreoBaseApiUrl).toBe('https://choreo.example.com'); + }); + + describe('alertingUrl precedence', () => { + it('an explicit VITE_ALERTING_URL wins even when SYSTEM_APIS_BASE_URL is also set', async () => { + mockFetch({ VITE_ALERTING_URL: 'https://explicit-alert.example.com/', SYSTEM_APIS_BASE_URL: 'https://sysapis.example.com' }); + await loadConfig(); + expect(window.API_CONFIG.alertingUrl).toBe('https://explicit-alert.example.com'); + }); + + it('derives from SYSTEM_APIS_BASE_URL when no explicit alerting URL is given', async () => { + mockFetch({ SYSTEM_APIS_BASE_URL: 'https://sysapis.example.com/' }); + await loadConfig(); + expect(window.API_CONFIG.alertingUrl).toBe('https://sysapis.example.com/systemapis/choreo-alerting-api/v1.0'); + }); + + it('falls back to the hardcoded default when neither is given', async () => { + mockFetch({}); + await loadConfig(); + expect(window.API_CONFIG.alertingUrl).toBe('https://localhost:9448/icp/alerting'); + }); + }); + + describe('asgardeoOrgNumericId', () => { + it('parses ASGARDEO_ORG_NUMERIC_ID from config.json when present', async () => { + mockFetch({ ASGARDEO_ORG_NUMERIC_ID: '7' }); + await loadConfig(); + expect(window.API_CONFIG.asgardeoOrgNumericId).toBe(7); + }); + + it('falls back to localStorage when config.json omits it', async () => { + localStorage.setItem('icp_org_numeric_id', '99'); + mockFetch({}); + await loadConfig(); + expect(window.API_CONFIG.asgardeoOrgNumericId).toBe(99); + }); + + it('is undefined when neither config.json nor localStorage has it', async () => { + mockFetch({}); + await loadConfig(); + expect(window.API_CONFIG.asgardeoOrgNumericId).toBeUndefined(); + }); + }); + + it('falls back wholesale to defaults when the response is not ok, and warns', async () => { + mockFetch({ VITE_GRAPHQL_URL: 'https://should-be-ignored.example.com' }, false, 500); + + await loadConfig(); + + expect(window.API_CONFIG).toEqual({ + graphqlUrl: 'https://apis.preview-dv.choreo.dev/projects/1.0.0/graphql', + authBaseUrl: 'https://localhost:9445/auth', + observabilityUrl: 'https://localhost:9448/icp/observability', + alertingUrl: 'https://localhost:9448/icp/alerting', + asgardeoClientId: '', + asgardeoAuthorizeEndpoint: 'https://dev.api.asgardeo.io/t/a/oauth2/authorize', + asgardeoTokenEndpoint: 'https://dev.api.asgardeo.io/t/a/oauth2/token', + asgardeoSignInRedirectUrl: `${ORIGIN}/signin`, + asgardeoScope: 'openid profile email groups', + stsTokenEndpoint: '', + stsClientId: '', + stsScope: '', + choreoBaseApiUrl: 'https://apis.preview-dv.choreo.dev', + choreoOrgApiUrl: 'https://apis.preview-dv.choreo.dev/orgs/1.0.0', + apimBaseUrl: 'https://sts.preview-dv.choreo.dev', + insightsBaseUrl: 'https://choreocontrolplane.preview-dv.choreo.dev', + systemApisBaseUrl: '', + sysApiPrefix: '783c6c4d-8b9b-4190-b70a-e717ab1ee739-systemapis', + githubAppClientId: '', + githubAppAuthRedirectUrl: `${ORIGIN}/ghapp`, + subscriptionsApiUrl: DEFAULT_SUBSCRIPTIONS_API_URL, + billingApiBaseUrl: '', + asgardeoSignupUrl: 'https://dev.asgardeo.io/signup', + aiCopilotUrlSuffix: '', + aiCopilotDatacollectorBaseUrl: '', + }); + expect(console.warn).toHaveBeenCalledOnce(); + }); + + it('falls back wholesale to defaults when fetch itself rejects, and warns', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new Error('network down')), + ); + + await loadConfig(); + + expect(window.API_CONFIG.graphqlUrl).toBe('https://apis.preview-dv.choreo.dev/projects/1.0.0/graphql'); + expect(window.API_CONFIG.subscriptionsApiUrl).toBe(DEFAULT_SUBSCRIPTIONS_API_URL); + expect(console.warn).toHaveBeenCalledOnce(); + }); +}); + +describe('getDevPortalBaseUrl / getDevPortalApiUrl', () => { + it('substitutes the devportal label for the choreoOrgApiUrl hostname', async () => { + mockFetch({ CHOREO_BASE_API_URL: 'https://apis.something.choreo.dev' }); + await loadConfig(); + + expect(getDevPortalBaseUrl()).toBe('https://devportal.something.choreo.dev'); + expect(getDevPortalApiUrl('myorg', 'My API', 'v1')).toBe('https://devportal.something.choreo.dev/myorg/views/default/api/My%20API-v1'); + }); + + it('returns null when choreoOrgApiUrl cannot be parsed as a URL', () => { + // Deliberately minimal fixture — only the field these two functions read. + window.API_CONFIG = { choreoOrgApiUrl: '' } as unknown as ApiConfig; + + expect(getDevPortalBaseUrl()).toBeNull(); + expect(getDevPortalApiUrl('myorg', 'My API', 'v1')).toBeNull(); + }); +}); diff --git a/ipaas/src/hooks/useGitHubAuth.ts b/ipaas/src/hooks/useGitHubAuth.ts index cfc730d53..a48d7ebe7 100644 --- a/ipaas/src/hooks/useGitHubAuth.ts +++ b/ipaas/src/hooks/useGitHubAuth.ts @@ -20,7 +20,7 @@ import { useState } from 'react'; import { useObtainGithubToken } from './useRepository'; import { GITHUB_AUTH } from '../constants/github'; import { buildGitHubOAuthUrl } from '../paths'; -import { generateAndSaveGitHubState, validateAndClearGitHubState } from '../auth/tokenManager'; +import { generateAndSaveGitHubState, validateAndClearGitHubState } from '../auth/oauthState'; import type { AuthStatus } from '../types/import'; export interface UseGitHubAuthReturn { diff --git a/ipaas/src/hooks/useOrg.ts b/ipaas/src/hooks/useOrg.ts index 3da43208b..09f063a52 100644 --- a/ipaas/src/hooks/useOrg.ts +++ b/ipaas/src/hooks/useOrg.ts @@ -19,6 +19,7 @@ import { useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createDefaultProject, fetchOrgComponentLimits, fetchOrgSubscriptions, fetchOrgs, fetchProjectsByOrgId, initOrg, registerUser, validateOrgName } from '#api/org'; +import { switchOrgToken } from '../auth/tokenManager'; export function useOrgs() { return useQuery({ @@ -91,3 +92,11 @@ export function useRegisterUser() { mutationFn: ({ orgName, termsAccepted, serviceName }: { orgName: string; termsAccepted: boolean; serviceName: string }) => registerUser(orgName, termsAccepted, serviceName), }); } + +// Exchanges the current token for one scoped to a different org. Wraps +// auth/tokenManager's switchOrgToken so UI code never imports it directly. +export function useSwitchOrgToken() { + return useMutation({ + mutationFn: (orgHandle: string) => switchOrgToken(orgHandle), + }); +} diff --git a/ipaas/src/hooks/useProjects.ts b/ipaas/src/hooks/useProjects.ts index 4f8a195ea..615df6fa1 100644 --- a/ipaas/src/hooks/useProjects.ts +++ b/ipaas/src/hooks/useProjects.ts @@ -16,6 +16,7 @@ * under the License. */ +import { useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { UUID_RE } from '../utils/string'; import { fetchProjects, fetchProject, fetchProjectContributors, fetchProjectComponentLabels, fetchProjectHandlerAvailability, createProject, createMonoRepoProject } from '#api/projects'; @@ -42,6 +43,19 @@ export function useProjects() { }); } +// Imperative fetcher for use in event flows (login callbacks, onboarding redirects) +// outside the render cycle — shares cache with useProjects. +export function useFetchProjects() { + const qc = useQueryClient(); + return useCallback(() => { + const id = orgId(); + return qc.fetchQuery({ + queryKey: ['projects', id], + queryFn: () => fetchProjects(id), + }); + }, [qc]); +} + export function useProjectsByOrg(orgHandle: string) { const { data: orgs } = useOrgs(); const numericId = orgs?.find((o) => o.handle === orgHandle)?.numericId ?? 0; diff --git a/ipaas/src/layouts/AppLayout.tsx b/ipaas/src/layouts/AppLayout.tsx index d84acf161..adcdb5827 100644 --- a/ipaas/src/layouts/AppLayout.tsx +++ b/ipaas/src/layouts/AppLayout.tsx @@ -47,7 +47,7 @@ import { useAppShell, useNotifications, } from '@wso2/oxygen-ui'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import type { JSX } from 'react'; import { useNavigate, Outlet, NavLink, useLocation } from 'react-router'; @@ -109,19 +109,18 @@ import { import FeaturePreviewModal from '../components/FeaturePreview/FeaturePreviewModal'; import { useProject, useProjectByHandler, useProjects } from '../hooks/useProjects'; import { useComponents } from '../hooks/useComponents'; -import { useOrgs } from '../hooks/useOrg'; +import { useOrgs, useSwitchOrgToken } from '../hooks/useOrg'; import { useBillingOrg } from '../hooks/useBillingOrg'; import { isSupportedIntegration, GENERIC_SERVICE_TYPES } from '../constants/integrations'; import { identifyIntegration } from '../utils/identifyIntegration'; import { useOrgPermissions } from '../hooks/useAuth'; -import { switchOrgToken } from '../auth/tokenManager'; import { mockNotifications } from '../mock-data/mockNotifications'; import { useScope, useResource, resourceUrl, broaden, narrow, newProjectUrl, newComponentUrl, hasProject, hasComponent, type Resource } from '../nav'; import { componentOverviewUrl, loginUrl, orgHomeUrl, privacyPolicyUrl, profileUrl, projectHomeUrl, termsOfUseUrl } from '../paths'; import { useAuth } from '../auth/AuthContext'; import { useAccessControl } from '../contexts/AccessControlContext'; import { CopilotContext, CopilotProvider } from '../contexts/CopilotContext'; -import CopilotDrawer from '../components/AiCopilot/CopilotDrawer'; +const CopilotDrawer = lazy(() => import('../components/AiCopilot/CopilotDrawer')); import { IS_WIP, IS_CLOUD } from '../features'; import AIIcon from '../assets/icons/ai/AIIcon'; import { ALL_USER_MGT_PERMISSIONS, Permissions } from '../constants/permissions'; @@ -326,6 +325,7 @@ function AppLayoutInner(): JSX.Element { const [orgSearch, setOrgSearch] = useState(''); const orgSearchRef = useRef(null); const { data: orgsData = [] } = useOrgs(); + const switchOrgTokenMutation = useSwitchOrgToken(); const { notifications, actions: notifActions, unreadCount, unreadNotifications } = useNotifications({ initialNotifications: [...mockNotifications] }); const alertNotifications = notifications.filter((n) => n.type === 'warning' || n.type === 'error'); @@ -646,7 +646,8 @@ function AppLayoutInner(): JSX.Element { navigate(orgHomeUrl(o.handle)); return; } - switchOrgToken(o.handle) + switchOrgTokenMutation + .mutateAsync(o.handle) .then(() => { if (o.numericId > 0) { window.API_CONFIG.asgardeoOrgNumericId = o.numericId; @@ -1628,7 +1629,11 @@ function AppLayoutInner(): JSX.Element { }}> - {IS_WIP && } + {IS_WIP && ( + + + + )} diff --git a/ipaas/src/nav.test.ts b/ipaas/src/nav.test.ts new file mode 100644 index 000000000..b5a3afa84 --- /dev/null +++ b/ipaas/src/nav.test.ts @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { beforeAll, describe, it, expect } from 'vitest'; +import type { FC } from 'react'; +import { + hasProject, + hasComponent, + broaden, + narrow, + resourceUrl, + sidebarItems, + generateMatrixRoutes, + newProjectUrl, + importProjectUrl, + newEnvironmentUrl, + orgCdPipelinesUrl, + cdPipelineEditorUrl, + newComponentUrl, + generateMcpUrl, + type Matrix, + type OrgScope, + type ProjectScope, + type ComponentScope, +} from './nav'; + +const orgScope: OrgScope = { level: 'organizations', org: 'acme' }; +const projectScope: ProjectScope = { level: 'projects', org: 'acme', project: 'p1' }; +const componentScope: ComponentScope = { level: 'components', org: 'acme', project: 'p1', component: 'c1' }; + +describe('scope type guards', () => { + it('hasProject is false only for organizations scope', () => { + expect(hasProject(orgScope)).toBe(false); + expect(hasProject(projectScope)).toBe(true); + expect(hasProject(componentScope)).toBe(true); + }); + + it('hasComponent is true only for components scope', () => { + expect(hasComponent(orgScope)).toBe(false); + expect(hasComponent(projectScope)).toBe(false); + expect(hasComponent(componentScope)).toBe(true); + }); +}); + +describe('broaden', () => { + it('drops one level at a time, organizations -> null', () => { + expect(broaden(componentScope)).toEqual(projectScope); + expect(broaden(projectScope)).toEqual(orgScope); + expect(broaden(orgScope)).toBeNull(); + }); +}); + +describe('narrow', () => { + it('adds one level at a time using the given childId', () => { + expect(narrow(orgScope, 'p2')).toEqual({ level: 'projects', org: 'acme', project: 'p2' }); + expect(narrow(projectScope, 'c2')).toEqual({ level: 'components', org: 'acme', project: 'p1', component: 'c2' }); + }); + + it('is a no-op on an already-deepest (components) scope', () => { + expect(narrow(componentScope, 'whatever')).toEqual(componentScope); + }); +}); + +describe('pure URL builders', () => { + it('newProjectUrl', () => expect(newProjectUrl({ org: 'acme' })).toBe('/organizations/acme/projects/new')); + it('importProjectUrl', () => expect(importProjectUrl({ org: 'acme' })).toBe('/organizations/acme/projects/import')); + it('newEnvironmentUrl', () => expect(newEnvironmentUrl({ org: 'acme' })).toBe('/organizations/acme/environments/new')); + it('orgCdPipelinesUrl', () => expect(orgCdPipelinesUrl({ org: 'acme' })).toBe('/organizations/acme/admin/cd-pipelines')); + it('newComponentUrl', () => expect(newComponentUrl({ org: 'acme', project: 'p1' })).toBe('/organizations/acme/projects/p1/components/new')); + + describe('cdPipelineEditorUrl', () => { + it('without a pipelineId -> the create route', () => expect(cdPipelineEditorUrl({ org: 'acme' })).toBe('/organizations/acme/admin/cd-pipelines/new')); + it('with a pipelineId -> the edit route', () => expect(cdPipelineEditorUrl({ org: 'acme' }, 'pipe-1')).toBe('/organizations/acme/admin/cd-pipelines/pipe-1/edit')); + }); + + describe('generateMcpUrl', () => { + const scope = { org: 'acme', project: 'p1' }; + it('with neither optional param -> no query string', () => expect(generateMcpUrl(scope)).toBe('/organizations/acme/projects/p1/components/new/generate-mcp')); + it('with only sourceApiId', () => expect(generateMcpUrl(scope, 'api-1')).toBe('/organizations/acme/projects/p1/components/new/generate-mcp?sourceApiId=api-1')); + it('with both params', () => expect(generateMcpUrl(scope, 'api-1', 'handler-1')).toBe('/organizations/acme/projects/p1/components/new/generate-mcp?sourceApiId=api-1&sourceHandler=handler-1')); + }); +}); + +// Synthetic fixture shaped like (but intentionally decoupled from) the real +// MATRIX in src/config/routes.tsx — it exercises generateMatrixRoutes' +// algorithm (level availability, segment-to-pattern, access-control's :tab +// substitution), not "does this match production routes today". +const OrgPage: FC = () => null; +const ProjectPage: FC = () => null; +const ComponentPage: FC = () => null; + +const TEST_MATRIX: Matrix = { + overview: { segment: '', pages: { organizations: OrgPage, projects: ProjectPage, components: ComponentPage } }, + build: { segment: 'build', pages: { organizations: OrgPage, projects: ProjectPage, components: ComponentPage } }, + deploy: { segment: 'deploy', pages: { organizations: OrgPage, projects: ProjectPage, components: ComponentPage } }, + alerts: { segment: 'alerts', pages: { components: ComponentPage } }, + logs: { segment: 'logs', pages: { projects: ProjectPage, components: ComponentPage } }, + environments: { segment: 'environments', pages: { organizations: OrgPage, projects: ProjectPage } }, + 'access-control': { segment: 'settings/access-control/:tab', pages: { organizations: OrgPage, projects: ProjectPage, components: ComponentPage } }, +}; + +describe('generateMatrixRoutes + resourceUrl + sidebarItems (matrix-driven)', () => { + beforeAll(() => { + // resourceUrl/sidebarItems read module-level state populated by this call — + // it must run before any test in this file that uses them. + generateMatrixRoutes(TEST_MATRIX); + }); + + it('generates one route per (resource, level) pair the matrix defines', () => { + const routes = generateMatrixRoutes(TEST_MATRIX); + // 3 + 3 + 3 + 1 + 2 + 2 + 3 = 17 + expect(routes).toHaveLength(17); + expect(routes.map((r) => r.path)).toContain('organizations/:orgHandler/build'); + expect(routes.map((r) => r.path)).toContain('organizations/:orgHandler/projects/:projectHandler/logs'); + expect(routes.map((r) => r.path)).toContain('organizations/:orgHandler/projects/:projectHandler/components/:componentHandler/alerts'); + }); + + it('builds the URL for a resource available at the scope level', () => { + expect(resourceUrl(orgScope, 'build')).toBe('/organizations/acme/build'); + expect(resourceUrl(componentScope, 'alerts')).toBe('/organizations/acme/projects/p1/components/c1/alerts'); + }); + + it('overview has an empty segment, so its URL is just the scope prefix', () => { + expect(resourceUrl(orgScope, 'overview')).toBe('/organizations/acme'); + }); + + it('falls back to overview when the resource is not available at the scope level', () => { + // 'alerts' only has a 'components' page in TEST_MATRIX + expect(resourceUrl(orgScope, 'alerts')).toBe(resourceUrl(orgScope, 'overview')); + expect(resourceUrl(projectScope, 'alerts')).toBe(resourceUrl(projectScope, 'overview')); + }); + + describe('access-control :tab substitution', () => { + it("organizations level -> 'users'", () => expect(resourceUrl(orgScope, 'access-control')).toBe('/organizations/acme/settings/access-control/users')); + it("projects level -> 'roles'", () => expect(resourceUrl(projectScope, 'access-control')).toBe('/organizations/acme/projects/p1/settings/access-control/roles')); + it("components level -> 'roles'", () => expect(resourceUrl(componentScope, 'access-control')).toBe('/organizations/acme/projects/p1/components/c1/settings/access-control/roles')); + }); + + it('sidebarItems lists only resources available at the scope level, with the active flag and a capitalized label', () => { + const items = sidebarItems(orgScope, 'build'); + expect(items.map((i) => i.resource).sort()).toEqual(['access-control', 'build', 'deploy', 'environments', 'overview'].sort()); + expect(items.find((i) => i.resource === 'build')).toMatchObject({ active: true, label: 'Build' }); + expect(items.find((i) => i.resource === 'access-control')).toMatchObject({ active: false, label: 'Access-control' }); + }); + + it('sidebarItems excludes resources unavailable at a narrower/wider level than they support', () => { + const orgItems = sidebarItems(orgScope, null).map((i) => i.resource); + expect(orgItems).not.toContain('alerts'); + expect(orgItems).not.toContain('logs'); + + const componentItems = sidebarItems(componentScope, null).map((i) => i.resource); + expect(componentItems).not.toContain('environments'); + }); +}); diff --git a/ipaas/src/pages/AGENTS.md b/ipaas/src/pages/AGENTS.md index 66c67261e..833e03d8b 100644 --- a/ipaas/src/pages/AGENTS.md +++ b/ipaas/src/pages/AGENTS.md @@ -11,25 +11,32 @@ Route-level components. Each file corresponds to one route. Same import rules as | Allowed | Not allowed | |---|---| | `src/hooks/*` | `src/api/*` | -| `src/types/*` | `auth/tokenManager` (data functions — see exception below) | +| `src/types/*` | `auth/tokenManager` (data/token access — see exception below) | | `src/constants/*` | `authenticatedFetch`, `getOrgUuidFromToken` | | `src/utils/*` | Any named HTTP client | | `src/components/*` | | | `src/contexts/*` | | +| `auth/oauthState` (pure OAuth CSRF state — see below) | | | React Router (`useNavigate`, `useParams`) | | --- ## Accepted exception — OAuth CSRF helpers -Three pages import directly from `auth/tokenManager`: +`src/auth/oauthState.ts` is a separate module from `auth/tokenManager.ts`, holding only pure +localStorage/sessionStorage CSRF-state helpers — no token or network access. Pages may import +it directly: | Page | Imported symbols | Why | |---|---|---| -| `Project.tsx`, `CreateIntegrationOptions.tsx` | `generateAndSaveGitHubState`, `validateAndClearGitHubState` | GitHub OAuth popup CSRF state — pure localStorage utilities, no network call | +| `Project.tsx`, `CreateIntegrationOptions.tsx` | `generateAndSaveGitHubState`, `validateAndClearGitHubState` | GitHub OAuth popup CSRF state | | `OIDCCallback.tsx` | `validateAndClearOIDCState`, `getAndClearRedirectUrl` | OIDC redirect landing — one-shot state extraction on arrival | -These are CSRF state helpers, not data access. All other `tokenManager` functions (`getOrgUuidFromToken`, `authenticatedFetch`) must go through hooks. +`auth/tokenManager` itself (`getOrgUuidFromToken`, `authenticatedFetch`, ...) is never importable +from pages — ESLint blocks the whole module, with no per-file exception. Because the CSRF +helpers live in their own file, this is an allowlist by construction: a new function added to +`tokenManager.ts` can't become reachable from a page without also being added to +`oauthState.ts` first. --- diff --git a/ipaas/src/pages/CreateIntegrationOptions.tsx b/ipaas/src/pages/CreateIntegrationOptions.tsx index 699dfc181..9e8f748f1 100644 --- a/ipaas/src/pages/CreateIntegrationOptions.tsx +++ b/ipaas/src/pages/CreateIntegrationOptions.tsx @@ -22,7 +22,7 @@ import { useState, type JSX } from 'react'; import { useNavigate } from 'react-router'; import { useCreateComponent } from '../hooks/useComponents'; import { useChoreoSampleImages } from '../hooks/useRepository'; -import { generateAndSaveGitHubState, validateAndClearGitHubState } from '../auth/tokenManager'; +import { generateAndSaveGitHubState, validateAndClearGitHubState } from '../auth/oauthState'; import { useOrgUuid } from '../hooks/useOrgUuid'; import { useAuth } from '../auth/AuthContext'; import IDEMockup from '../components/IDEMockup/IDEMockup'; diff --git a/ipaas/src/pages/OIDCCallback.tsx b/ipaas/src/pages/OIDCCallback.tsx index 16518620b..f07b983b9 100644 --- a/ipaas/src/pages/OIDCCallback.tsx +++ b/ipaas/src/pages/OIDCCallback.tsx @@ -21,9 +21,9 @@ import type { JSX } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { Alert, Box, CircularProgress, Typography } from '@wso2/oxygen-ui'; import { useAuth } from '../auth/AuthContext'; -import { validateAndClearOIDCState, getAndClearRedirectUrl } from '../auth/tokenManager'; +import { validateAndClearOIDCState, getAndClearRedirectUrl } from '../auth/oauthState'; import { useFetchProjectsByOrgId } from '../hooks/useOrg'; -import { fetchProjects as fetchProjectsApi } from '#api/projects'; +import { useFetchProjects } from '../hooks/useProjects'; import { loginUrl, projectHomeUrl, projectsRedirectUrl, registerOrgUrl } from '../paths'; import { IS_CLOUD } from '../features'; @@ -33,7 +33,8 @@ export default function OIDCCallback(): JSX.Element { const { handleOIDCCallback } = useAuth(); const [error, setError] = useState(null); const handledRef = useRef(false); - const fetchProjects = useFetchProjectsByOrgId(); + const fetchProjectsByOrgId = useFetchProjectsByOrgId(); + const fetchProjects = useFetchProjects(); useEffect(() => { if (handledRef.current) return; @@ -109,7 +110,7 @@ export default function OIDCCallback(): JSX.Element { // back to the JWT-scoped fetchProjects which ignores the orgId argument. if (!navigatedToLastProject) { const numericId = window.API_CONFIG.asgardeoOrgNumericId ?? parseInt(localStorage.getItem('icp_org_numeric_id') ?? '0', 10); - const projects = IS_CLOUD ? (await fetchProjectsApi(0)).filter((p) => p.handler) : numericId > 0 ? (await fetchProjects(numericId)).filter((p) => p.handler) : []; + const projects = IS_CLOUD ? (await fetchProjects()).filter((p) => p.handler) : numericId > 0 ? (await fetchProjectsByOrgId(numericId)).filter((p) => p.handler) : []; if (projects.length > 0) { const recent = projects.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]; // Mark ToS accepted — this user already has projects, they've been through onboarding diff --git a/ipaas/src/pages/OrgHome.tsx b/ipaas/src/pages/OrgHome.tsx index 52c656312..91bb006bd 100644 --- a/ipaas/src/pages/OrgHome.tsx +++ b/ipaas/src/pages/OrgHome.tsx @@ -23,8 +23,7 @@ import { Alert, Box, Button, ButtonBase, Card, CardContent, CircularProgress, Fo import { ArrowRight, Settings, Users } from '@wso2/oxygen-ui-icons-react'; import { useOrgUuid } from '../hooks/useOrgUuid'; import { useCreateDefaultProject, useFetchProjectsByOrgId, useInitOrg } from '../hooks/useOrg'; -import { useCreateProject } from '../hooks/useProjects'; -import { fetchProjects as fetchProjectsApi } from '#api/projects'; +import { useCreateProject, useFetchProjects } from '../hooks/useProjects'; import { projectHomeUrl } from '../paths'; import { IS_CLOUD } from '../features'; import Projects from './Projects'; @@ -86,7 +85,8 @@ export default function OrgHome(): JSX.Element { const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const fetchProjects = useFetchProjectsByOrgId(); + const fetchProjectsByOrgId = useFetchProjectsByOrgId(); + const fetchProjects = useFetchProjects(); const initOrgMutation = useInitOrg(); const createProjectMutation = useCreateDefaultProject(); const createCloudProjectMutation = useCreateProject(); @@ -108,7 +108,7 @@ export default function OrgHome(): JSX.Element { // straight to the most recently updated project when one exists so // users land in a project view instead of the org-home spinner. if (IS_CLOUD) { - fetchProjectsApi(0) + fetchProjects() .then((projects) => { const usable = projects.filter((p) => p.handler); if (usable.length > 0) { @@ -124,7 +124,7 @@ export default function OrgHome(): JSX.Element { } if (!orgNumericId) return; // wait for AppLayout's ID-recovery to complete - fetchProjects(orgNumericId) + fetchProjectsByOrgId(orgNumericId) .then((projects) => { if (projects.some((p) => p.handler)) { localStorage.setItem(PERSONA_KEY, 'developer'); @@ -134,7 +134,7 @@ export default function OrgHome(): JSX.Element { } }) .catch(() => setStep('persona')); - }, [step, orgNumericId, fetchProjects, navigate, orgHandler]); + }, [step, orgNumericId, fetchProjects, fetchProjectsByOrgId, navigate, orgHandler]); if (step === 'checking') { return ( diff --git a/ipaas/src/pages/Project.tsx b/ipaas/src/pages/Project.tsx index c6f687fd7..5ae326777 100644 --- a/ipaas/src/pages/Project.tsx +++ b/ipaas/src/pages/Project.tsx @@ -65,7 +65,7 @@ import { useDeleteComponent, useCreateComponent } from '../hooks/useComponents'; import NotFound from '../components/NotFound'; import { formatDistanceToNow } from '../utils/time'; import { resourceUrl, broaden, narrow, newComponentUrl, type ProjectScope } from '../nav'; -import { generateAndSaveGitHubState, validateAndClearGitHubState } from '../auth/tokenManager'; +import { generateAndSaveGitHubState, validateAndClearGitHubState } from '../auth/oauthState'; import { useOrgUuid } from '../hooks/useOrgUuid'; import { useAuth } from '../auth/AuthContext'; import { componentOverviewUrl, importComponentUrl, browseSamplesUrl, prebuiltIntegrationsUrl, importComingSoonUrl, buildGitHubOAuthUrl } from '../paths'; diff --git a/ipaas/src/utils/componentType.test.ts b/ipaas/src/utils/componentType.test.ts new file mode 100644 index 000000000..466a67d46 --- /dev/null +++ b/ipaas/src/utils/componentType.test.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { getComponentTypeFlags, type ComponentTypeFlags } from './componentType'; + +const ALL_FALSE: ComponentTypeFlags = { + isProxy: false, + isService: false, + isRestApi: false, + isByoi: false, + isAutomation: false, + isCommitBased: false, + isImageBased: false, + isDeployable: false, +}; + +describe('getComponentTypeFlags', () => { + it.each([ + ['proxy', { ...ALL_FALSE, isProxy: true }], + ['gitProxy', { ...ALL_FALSE, isProxy: true }], + ['ballerinaService', { ...ALL_FALSE, isService: true, isCommitBased: true, isDeployable: true }], + ['miApiService', { ...ALL_FALSE, isService: true, isCommitBased: true, isDeployable: true }], + ['restAPI', { ...ALL_FALSE, isRestApi: true, isCommitBased: true, isDeployable: true }], + ['miRestApi', { ...ALL_FALSE, isRestApi: true, isCommitBased: true, isDeployable: true }], + ['byoiService', { ...ALL_FALSE, isByoi: true, isImageBased: true, isDeployable: true }], + ['scheduledTask', { ...ALL_FALSE, isAutomation: true, isCommitBased: true, isDeployable: true }], + ['miCronjob', { ...ALL_FALSE, isAutomation: true, isCommitBased: true, isDeployable: true }], + ['unknownType', ALL_FALSE], + ['', ALL_FALSE], + ] satisfies [string, ComponentTypeFlags][])('maps displayType %j to the expected flags', (displayType, expected) => { + expect(getComponentTypeFlags(displayType)).toEqual(expected); + }); + + it('proxy is not deployable even though it is excluded from isCommitBased/isImageBased', () => { + const flags = getComponentTypeFlags('proxy'); + expect(flags.isCommitBased).toBe(false); + expect(flags.isImageBased).toBe(false); + expect(flags.isDeployable).toBe(false); + }); + + it('componentSubType is accepted but does not change the result (reserved for future use)', () => { + const withoutSubType = getComponentTypeFlags('ballerinaService'); + const withSubType = getComponentTypeFlags('ballerinaService', 'aiAgent'); + expect(withSubType).toEqual(withoutSubType); + }); + + it('componentSubType is optional', () => { + expect(getComponentTypeFlags('miCronjob', null)).toEqual(getComponentTypeFlags('miCronjob')); + }); +}); diff --git a/ipaas/src/utils/identifyIntegration.test.ts b/ipaas/src/utils/identifyIntegration.test.ts new file mode 100644 index 000000000..6eab265ef --- /dev/null +++ b/ipaas/src/utils/identifyIntegration.test.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { identifyIntegration } from './identifyIntegration'; +import type { IntegrationType, IntegrationRuntime } from '../types/integration'; + +describe('identifyIntegration', () => { + describe('componentSubType-first rules (override displayType)', () => { + it.each([ + ['tailscale', 'restAPI', 'tailscale-vpn', 'unknown'], + ['ballerinaFileIntegration', 'ballerinaService', 'file-integration', 'ballerina'], + ['miFileIntegration', 'miApiService', 'file-integration', 'mi'], + ['ballerinaEventHandler', 'ballerinaService', 'event-integration', 'ballerina'], + ['miEventHandler', 'miApiService', 'event-integration', 'mi'], + ['aiAgent', 'ballerinaService', 'ai-agent', 'ballerina'], + ] satisfies [string, string, IntegrationType, IntegrationRuntime][])('componentSubType=%s wins over displayType=%s -> %s/%s', (componentSubType, displayType, type, runtime) => { + const identity = identifyIntegration(displayType, componentSubType); + expect(identity.type).toBe(type); + expect(identity.runtime).toBe(runtime); + }); + + it.each([ + ['proxy', 'mcp-proxy', 'unknown'], + ['gitProxy', 'mcp-proxy', 'unknown'], + ['ballerinaService', 'mcp-server', 'ballerina'], + ['miApiService', 'mcp-server', 'mi'], + ['restAPI', 'mcp-server', 'unknown'], + ] satisfies [string, IntegrationType, IntegrationRuntime][])('componentSubType=MCP with displayType=%s -> %s/%s', (displayType, type, runtime) => { + const identity = identifyIntegration(displayType, 'MCP'); + expect(identity.type).toBe(type); + expect(identity.runtime).toBe(runtime); + }); + + it.each([ + ['ballerinaService', 'ballerina'], + ['miApiService', 'mi'], + ['restAPI', 'unknown'], + ] satisfies [string, IntegrationRuntime][])('componentSubType=webhook with displayType=%s -> webhook/%s', (displayType, runtime) => { + const identity = identifyIntegration(displayType, 'webhook'); + expect(identity.type).toBe('webhook'); + expect(identity.runtime).toBe(runtime); + }); + }); + + describe('displayType fallback (componentSubType is null)', () => { + it.each([ + ['webhook', 'unknown'], + ['ballerinaWebhook', 'ballerina'], + ['miWebhook', 'mi'], + ['byocWebhook', 'unknown'], + ['buildpackWebhook', 'unknown'], + ] satisfies [string, IntegrationRuntime][])('standalone webhook displayType=%s -> webhook/%s', (displayType, runtime) => { + const identity = identifyIntegration(displayType, null); + expect(identity.type).toBe('webhook'); + expect(identity.runtime).toBe(runtime); + }); + + it.each([ + ['ballerinaService', 'integration-as-api', 'ballerina'], + ['restApi', 'integration-as-api', 'ballerina'], + ['miApiService', 'integration-as-api', 'mi'], + ['miRestApi', 'integration-as-api', 'mi'], + ['scheduledTask', 'automation', 'ballerina'], + ['miCronjob', 'automation', 'mi'], + ['ballerinaEventHandler', 'event-integration', 'ballerina'], + ['miEventHandler', 'event-integration', 'mi'], + ['somethingUnrecognized', 'unsupported', 'unknown'], + ['', 'unsupported', 'unknown'], + ] satisfies [string, IntegrationType, IntegrationRuntime][])('displayType=%s -> %s/%s', (displayType, type, runtime) => { + const identity = identifyIntegration(displayType, null); + expect(identity.type).toBe(type); + expect(identity.runtime).toBe(runtime); + }); + }); + + it('preserves the original displayType/componentSubType pair on raw', () => { + const identity = identifyIntegration('ballerinaService', 'webhook'); + expect(identity.raw).toEqual({ displayType: 'ballerinaService', componentSubType: 'webhook' }); + }); +}); diff --git a/ipaas/tsconfig.node.json b/ipaas/tsconfig.node.json index 8a67f62f4..7ad54d46b 100644 --- a/ipaas/tsconfig.node.json +++ b/ipaas/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/ipaas/vitest.config.ts b/ipaas/vitest.config.ts new file mode 100644 index 000000000..10b37a718 --- /dev/null +++ b/ipaas/vitest.config.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +// Standalone from vite.config.ts: unit tests are not a product build, so they +// always resolve #api/#product against `wip` — the reference implementation +// (see tsconfig.app.json) — regardless of which PRODUCT a dev/build session +// targets. +export default defineConfig({ + define: { + __PRODUCT__: JSON.stringify('wip'), + }, + resolve: { + alias: { + '#api': path.resolve(__dirname, 'src/api/wip'), + '#product': path.resolve(__dirname, 'src/product/wip'), + }, + }, + test: { + // jsdom (not 'node'): src/config/runtimeConfig.ts reads `window` at module + // scope, and future component/hook tests will need a DOM. Pin the origin + // so window.location.origin-derived defaults are deterministic. + environment: 'jsdom', + environmentOptions: { jsdom: { url: 'https://app.test' } }, + include: ['src/**/*.test.{ts,tsx}'], + // No global test API injection — test files import describe/it/expect + // from 'vitest' explicitly, same as every other import in this codebase. + globals: false, + }, +});