diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 874e9eba50..67c106d58b 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -451,6 +451,17 @@ modules: e2e: - ui/tests/home/** + - name: ui-registry + match: + - ui/actions/registry/** + - ui/app/**/registry/** + - ui/components/registry/** + - ui/lib/registry/** + - ui/tests/registry/** + tests: [] + e2e: + - ui/tests/registry/** + - name: ui-shadcn match: - ui/components/shadcn/** diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 2a384597c3..2bf6db8a5a 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -10,12 +10,12 @@ on: - master - "v5.*" paths: - - '.github/workflows/ui-e2e-tests-v2.yml' - - '.github/test-impact.yml' - - 'ui/**' - - 'api/**' # API changes can affect UI E2E - - '!ui/CHANGELOG.md' - - '!api/CHANGELOG.md' + - ".github/workflows/ui-e2e-tests-v2.yml" + - ".github/test-impact.yml" + - "ui/**" + - "api/**" # API changes can affect UI E2E + - "!ui/CHANGELOG.md" + - "!api/CHANGELOG.md" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -40,11 +40,11 @@ jobs: (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') runs-on: ubuntu-latest env: - AUTH_SECRET: 'fallback-ci-secret-for-testing' + AUTH_SECRET: "fallback-ci-secret-for-testing" AUTH_TRUST_HOST: true - NEXTAUTH_URL: 'http://localhost:3000' - AUTH_URL: 'http://localhost:3000' - UI_API_BASE_URL: 'http://localhost:8080/api/v1' + NEXTAUTH_URL: "http://localhost:3000" + AUTH_URL: "http://localhost:3000" + UI_API_BASE_URL: "http://localhost:8080/api/v1" E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -60,7 +60,7 @@ jobs: E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }} E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }} E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }} - E2E_KUBERNETES_CONTEXT: 'kind-kind' + E2E_KUBERNETES_CONTEXT: "kind-kind" E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} @@ -292,7 +292,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version-file: 'ui/.nvmrc' + node-version-file: "ui/.nvmrc" - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -337,60 +337,59 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm run test:e2e:install - - name: Run E2E tests + - name: Run standard E2E tests + id: standard-e2e working-directory: ./ui run: | if [[ "${RUN_ALL_TESTS}" == "true" ]]; then - echo "Running ALL E2E tests..." + echo "Running all standard E2E tests..." pnpm run test:e2e else - echo "Running targeted E2E tests: ${E2E_TEST_PATHS}" - # Convert glob patterns to playwright test paths - # e.g., "ui/tests/providers/**" -> "tests/providers" + echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}" TEST_PATHS="${E2E_TEST_PATHS}" - # Remove ui/ prefix and convert ** to empty (playwright handles recursion) TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) - # Drop auth setup helpers (not runnable test suites) - TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') - # Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**), - # expand to specific subdirs to avoid Playwright discovering setup files + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true) + if echo "$TEST_PATHS" | grep -qx 'tests/'; then - echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..." SPECIFIC_DIRS="" for dir in tests/*/; do - [[ "$dir" == "tests/setups/" ]] && continue + [[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n' done - # Replace "tests/" with specific dirs, keep other paths - TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/') + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true) TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}" TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u) fi - if [[ -z "$TEST_PATHS" ]]; then - echo "No runnable E2E test paths after filtering setups" - exit 0 - fi - # Filter out directories that don't contain any test files + VALID_PATHS="" - while IFS= read -r p; do - [[ -z "$p" ]] && continue - if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then - VALID_PATHS="${VALID_PATHS}${p}"$'\n' + while IFS= read -r path; do + [[ -z "$path" ]] && continue + if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then + VALID_PATHS="${VALID_PATHS}${path}"$'\n' else - echo "Skipping empty test directory: $p" + echo "Skipping empty test directory: $path" fi done <<< "$TEST_PATHS" VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true) - if [[ -z "$VALID_PATHS" ]]; then - echo "No test files found in any resolved paths — skipping E2E" - exit 0 + + if [[ -n "$VALID_PATHS" ]]; then + TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') + echo "Resolved standard test paths: $TEST_PATHS" + read -ra test_paths <<< "$TEST_PATHS" + pnpm exec playwright test "${test_paths[@]}" + else + echo "No standard E2E test paths selected." fi - TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') - echo "Resolved test paths: $TEST_PATHS" - read -ra test_paths <<< "$TEST_PATHS" - pnpm exec playwright test "${test_paths[@]}" fi + - name: Run Registry fixture E2E tests + if: | + !cancelled() && + (steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') && + (env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/')) + working-directory: ./ui + run: pnpm run test:e2e:registry + - name: Upload test reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: failure() diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index e2bec0f94b..a6ba5c9d54 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -40,6 +40,16 @@ The former build-time variables map to the new runtime variables as follows: `UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read. +## Registry UI Rollout and Rollback + +`UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`. + +Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry. + +The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again. + +To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts. + The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration. ## Enabling Third-Party Integrations diff --git a/ui/Dockerfile b/ui/Dockerfile index a7ff72e65f..278c8b8602 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -100,6 +100,8 @@ ENV HOSTNAME="0.0.0.0" # - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) # - optional: UI_API_DOCS_URL # - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments) +# - optional: UI_REGISTRY_ENABLED ("true" only after the Registry dependency, +# Cloud role grant, and controlled acceptance are ready; unset/false hides Registry) # - gated integrations (load only when *_ENABLED="true"; the value is then # required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*, # NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work: diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index 1f20392bb8..adf04ebcf1 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -23,7 +23,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ import { createNewUser, getUserByMe } from "./auth"; -const userMeResponse = (roleAttributes: Record) => ({ +const userMeResponse = (roleAttributes: Record) => ({ data: { type: "users", id: "019b1234-5678-7abc-9def-0123456789ab", @@ -43,7 +43,7 @@ const userMeResponse = (roleAttributes: Record) => ({ ], }); -const mockUserMe = (roleAttributes: Record) => { +const mockUserMe = (roleAttributes: Record) => { fetchMock.mockResolvedValue( new Response(JSON.stringify(userMeResponse(roleAttributes)), { status: 200, @@ -178,6 +178,30 @@ describe("auth actions", () => { expect(result.permissions.manage_users).toBe(true); }); + it("should carry an exact manage_registry permission into the session", async () => { + // Given + mockUserMe({ manage_registry: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_registry).toBe(true); + }); + + it.each([undefined, "true", "TRUE", 1])( + "should deny a malformed manage_registry value of %j", + async (manageRegistry) => { + // Given + mockUserMe({ manage_registry: manageRegistry }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_registry).toBe(false); + }, + ); it("should forward an abort signal when loading the current user", async () => { // Given mockUserMe({ manage_users: true }); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 6d275bbf92..0cd5df4d94 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -4,7 +4,7 @@ import { AuthError } from "next-auth"; import { signIn, signOut } from "@/auth.config"; import { apiBaseUrl } from "@/lib"; -import { UserMeError } from "@/lib/auth-errors"; +import { fetchCurrentUser } from "@/lib/auth/current-user"; import { addAuthEvent } from "@/lib/sentry-breadcrumbs"; import type { UtmParams } from "@/lib/utm"; import type { SignInFormData, SignUpFormData } from "@/types"; @@ -145,66 +145,15 @@ export const getUserByMe = async ( accessToken: string, signal?: AbortSignal, ) => { - const url = new URL(`${apiBaseUrl}/users/me?include=roles`); + const currentUser = await fetchCurrentUser(accessToken, { signal }); - try { - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Accept: "application/vnd.api+json", - Authorization: `Bearer ${accessToken}`, - }, - signal, - }); - - if (!response.ok) { - const errorMessage = - response.status === 401 - ? "Invalid or expired token" - : response.status === 403 - ? "Access denied" - : response.status === 404 - ? "User not found" - : "Unable to load user"; - throw new UserMeError(errorMessage, response.status); - } - - const parsedResponse = await response.json(); - - const userRole = parsedResponse.included?.find( - (item: any) => item.type === "roles", - ); - - const permissions = { - manage_users: userRole.attributes.manage_users || false, - manage_account: userRole.attributes.manage_account || false, - manage_providers: userRole.attributes.manage_providers || false, - manage_scans: userRole.attributes.manage_scans || false, - manage_ingestions: userRole.attributes.manage_ingestions || false, - manage_integrations: userRole.attributes.manage_integrations || false, - manage_billing: userRole.attributes.manage_billing || false, - manage_alerts: userRole.attributes.manage_alerts || false, - manage_lighthouse_ai_configuration: - userRole.attributes.manage_lighthouse_ai_configuration || false, - unlimited_visibility: userRole.attributes.unlimited_visibility || false, - }; - - return { - name: parsedResponse.data.attributes.name, - email: parsedResponse.data.attributes.email, - company: parsedResponse.data.attributes.company_name, - dateJoined: parsedResponse.data.attributes.date_joined, - permissions, - }; - } catch (error: unknown) { - if (error instanceof UserMeError) throw error; - - throw new UserMeError( - error instanceof Error - ? error.message - : "Network error or server unreachable", - ); - } + return { + name: currentUser.name, + email: currentUser.email, + company: currentUser.company, + dateJoined: currentUser.dateJoined, + permissions: currentUser.permissions, + }; }; export async function logOut() { diff --git a/ui/actions/providers/dynamic-provider-credentials.test.ts b/ui/actions/providers/dynamic-provider-credentials.test.ts new file mode 100644 index 0000000000..f8ab619bc8 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json"; +import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json"; +const { fetchMock, getProviderSchemas, getAuthHeaders, revalidatePath } = + vi.hoisted(() => ({ + fetchMock: vi.fn(), + getProviderSchemas: vi.fn(), + getAuthHeaders: vi.fn(), + revalidatePath: vi.fn(), + })); +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders, +})); +vi.mock("next/cache", () => ({ revalidatePath })); +vi.mock("./provider-schemas", () => ({ getProviderSchemas })); + +import { saveDynamicProviderCredentials } from "./dynamic-provider-credentials"; + +const input = { + providerId: "account", + secretType: "api_key", + secret: { token: "private-value" }, +}; +const response = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }); +const account = (secretId: string | null = null) => ({ + data: { + id: "account", + attributes: { provider: "acme" }, + relationships: { secret: { data: secretId ? { id: secretId } : null } }, + }, +}); + +describe("dynamic provider credential actions", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getAuthHeaders.mockResolvedValue({ Authorization: "Bearer test" }); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + description: openaiSchema.description, + properties: { + token: { type: "string", format: "password", writeOnly: true }, + }, + required: ["token"], + }, + }, + }); + }); + it("validates the account's current schema and sends JSON credentials without the builtin mapping", async () => { + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + expect(await saveDynamicProviderCredentials(input)).toEqual({ + status: "saved", + secretId: "saved", + }); + expect(getProviderSchemas).toHaveBeenCalledWith("acme"); + const [url, request] = fetchMock.mock.calls[1]; + expect(url).toBe("https://api.test/api/v1/providers/secrets"); + expect(JSON.parse(request.body).data).toEqual({ + type: "provider-secrets", + attributes: { + secret_type: "api_key", + secret: { token: "private-value" }, + }, + relationships: { + provider: { data: { id: "account", type: "providers" } }, + }, + }); + }); + it("updates the authoritative existing secret, including after a retry", async () => { + fetchMock + .mockResolvedValueOnce(response(account("existing"))) + .mockResolvedValueOnce(response({ data: { id: "existing" } })); + expect((await saveDynamicProviderCredentials(input)).status).toBe("saved"); + expect( + fetchMock.mock.calls[1][0].endsWith("/providers/secrets/existing"), + ).toBe(true); + expect(fetchMock.mock.calls[1][1].method).toBe("PATCH"); + }); + it("validates and sends Template credentials with their JSON types", async () => { + // Given + const templateAccount = account(); + templateAccount.data.attributes.provider = "template"; + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "template", + secretTypes: { static: templateSchema }, + }); + fetchMock + .mockResolvedValueOnce(response(templateAccount)) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + const secret = { + api_url: "https://api.example.test", + api_key: "fixture-key-not-a-secret", + verify_tls: false, + timeout_seconds: 60, + }; + + // When / Then + expect( + await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret, + }), + ).toEqual({ + status: "saved", + secretId: "saved", + }); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).data.attributes).toEqual( + { + secret_type: "static", + secret, + }, + ); + + // Server-side validation also rejects requests that bypass the form. + fetchMock.mockReset().mockResolvedValueOnce(response(templateAccount)); + expect( + await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret: { ...secret, timeout_seconds: 301 }, + }), + ).toMatchObject({ + status: "invalid", + errors: { timeout_seconds: expect.any(String) }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it.each([ + { ...input, secretType: "invented" }, + { ...input, secret: { token: "" } }, + { ...input, secret: { token: "x", unknown: "hidden" } }, + ])("does not write invalid credentials", async (values) => { + fetchMock.mockResolvedValueOnce(response(account())); + expect((await saveDynamicProviderCredentials(values)).status).not.toBe( + "saved", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("fails closed for an absent schema, revoked permission, and malformed accounts", async () => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: {}, + }); + fetchMock.mockResolvedValueOnce(response(account())); + expect((await saveDynamicProviderCredentials(input)).status).toBe( + "schema_unavailable", + ); + fetchMock.mockResolvedValueOnce(response({}, 403)); + expect((await saveDynamicProviderCredentials(input)).status).toBe( + "access_denied", + ); + fetchMock.mockResolvedValueOnce(response({})); + expect((await saveDynamicProviderCredentials(input)).status).toBe("error"); + expect(fetchMock.mock.calls.every(([, options]) => !options.method)).toBe( + true, + ); + }); + it("does not echo a rejected secret in errors", async () => { + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce( + response({ errors: [{ detail: "private-value invalid" }] }, 400), + ); + expect( + JSON.stringify(await saveDynamicProviderCredentials(input)), + ).not.toContain("private-value"); + }); +}); diff --git a/ui/actions/providers/dynamic-provider-credentials.ts b/ui/actions/providers/dynamic-provider-credentials.ts new file mode 100644 index 0000000000..79975fa2e7 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.ts @@ -0,0 +1,115 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseRegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema"; +import { validateCredentialValues } from "@/lib/provider-credentials/provider-credential-values"; +import { isKnownProviderType } from "@/types/providers"; + +import { getProviderSchemas } from "./provider-schemas"; + +const resourceId = z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/); +const inputSchema = z.object({ + providerId: resourceId, + secretType: z.string().min(1), + secret: z.unknown(), +}); +const accountSchema = z.object({ + data: z.object({ + id: resourceId, + attributes: z.object({ provider: z.string() }), + relationships: z.object({ + secret: z.object({ data: z.object({ id: resourceId }).nullable() }), + }), + }), +}); + +export type DynamicCredentialsResult = + | { status: "saved"; secretId: string } + | { status: "invalid"; errors: Record } + | { status: "access_denied" | "schema_unavailable" | "error" }; + +export async function saveDynamicProviderCredentials( + input: unknown, +): Promise { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) + return { + status: "invalid", + errors: { _form: "Check the provider and credential fields." }, + }; + const { providerId, secretType, secret } = parsed.data; + try { + const headers = await getAuthHeaders({ contentType: true }); + const accountResponse = await fetch( + `${apiBaseUrl}/providers/${encodeURIComponent(providerId)}`, + { headers, cache: "no-store" }, + ); + if (accountResponse.status === 401 || accountResponse.status === 403) + return { status: "access_denied" }; + if (!accountResponse.ok) return { status: "error" }; + const account = accountSchema.safeParse(await accountResponse.json()); + if ( + !account.success || + account.data.data.id !== providerId || + isKnownProviderType(account.data.data.attributes.provider) + ) + return { status: "error" }; + const schemas = await getProviderSchemas( + account.data.data.attributes.provider, + ); + if (schemas.status === "access_denied") return { status: "access_denied" }; + if ( + schemas.status !== "success" || + !Object.hasOwn(schemas.secretTypes, secretType) + ) + return { status: "schema_unavailable" }; + const schema = parseRegistryCredentialSchema( + schemas.secretTypes[secretType], + ); + if (!schema) return { status: "schema_unavailable" }; + const validated = validateCredentialValues(schema, secret); + if (!validated.valid) + return { status: "invalid", errors: validated.errors }; + + // Read the relationship again on every save so retries update a secret that + // was already created, including after a lost response. + const secretId = account.data.data.relationships.secret.data?.id; + const response = await fetch( + `${apiBaseUrl}/providers/secrets${secretId ? `/${encodeURIComponent(secretId)}` : ""}`, + { + method: secretId ? "PATCH" : "POST", + headers, + cache: "no-store", + body: JSON.stringify({ + data: { + type: "provider-secrets", + ...(secretId + ? { id: secretId } + : { + relationships: { + provider: { data: { id: providerId, type: "providers" } }, + }, + }), + attributes: { secret_type: secretType, secret: validated.secret }, + }, + }), + }, + ); + if (response.status === 401 || response.status === 403) + return { status: "access_denied" }; + // API validation details may echo credential values. Keep them out of both + // client errors and application logs. + if (!response.ok) return { status: "error" }; + const saved = z + .object({ data: z.object({ id: resourceId }) }) + .safeParse(await response.json()); + if (!saved.success) return { status: "error" }; + revalidatePath("/providers"); + return { status: "saved", secretId: saved.data.data.id }; + } catch { + return { status: "error" }; + } +} diff --git a/ui/actions/providers/index.ts b/ui/actions/providers/index.ts index 5532383f5f..d3580b7346 100644 --- a/ui/actions/providers/index.ts +++ b/ui/actions/providers/index.ts @@ -1 +1,2 @@ +export * from "./provider-schemas"; export * from "./providers"; diff --git a/ui/actions/providers/provider-schemas.adapter.test.ts b/ui/actions/providers/provider-schemas.adapter.test.ts new file mode 100644 index 0000000000..32c5e95fce --- /dev/null +++ b/ui/actions/providers/provider-schemas.adapter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + adaptProviderSchemas, + normalizeProviderType, +} from "./provider-schemas.adapter"; + +describe("provider schemas adapter", () => { + it("adapts a matching provider schema resource without interpreting schema keywords", () => { + // Given + const payload = { + data: { + type: "provider-schemas", + id: "acme", + attributes: { + secret_types: { + credentials: { + type: "object", + properties: { access_key: { type: "string" } }, + }, + }, + }, + }, + }; + + // When + const result = adaptProviderSchemas(payload, "acme"); + + // Then + expect(result).toEqual({ + status: "success", + providerType: "acme", + secretTypes: payload.data.attributes.secret_types, + }); + }); + + it.each([ + ["null", null], + ["string scalar", "secret"], + ["number scalar", 1], + ["boolean scalar", true], + ])("rejects %s secret_types values", (_description, secretType) => { + // Given + const payload = { + data: { + type: "provider-schemas", + id: "acme", + attributes: { secret_types: { credentials: secretType } }, + }, + }; + + // When + const result = adaptProviderSchemas(payload, "acme"); + + // Then + expect(result).toBeNull(); + }); + + it("rejects malformed or contradictory documents without reading schema keywords", () => { + // Given + const document = { + data: { + type: "provider-schemas", + id: "aws", + attributes: { secret_types: {} }, + }, + }; + + // When + const results = [ + { ...document, errors: [] }, + { data: { ...document.data, id: "aws " } }, + { data: { ...document.data, type: "providers" } }, + { data: { ...document.data, attributes: { secret_types: { key: [] } } } }, + ].map((payload) => adaptProviderSchemas(payload, "aws")); + + // Then + expect(results).toEqual([null, null, null, null]); + expect(normalizeProviderType(" AWS ")).toBe("aws"); + expect(normalizeProviderType(" ")).toBeNull(); + expect(normalizeProviderType("a".repeat(51))).toBeNull(); + }); +}); diff --git a/ui/actions/providers/provider-schemas.adapter.ts b/ui/actions/providers/provider-schemas.adapter.ts new file mode 100644 index 0000000000..9187afcf8f --- /dev/null +++ b/ui/actions/providers/provider-schemas.adapter.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { + PROVIDER_SCHEMA_STATUS, + type ProviderSchemasSuccessResult, +} from "@/types/provider-schema"; + +const providerTypeSchema = z.string().trim().toLowerCase().min(1).max(50); +const providerSchemasDocumentSchema = z.strictObject({ + data: z.strictObject({ + type: z.literal("provider-schemas"), + id: z.string().min(1).max(50), + attributes: z.strictObject({ + secret_types: z.record(z.string(), z.record(z.string(), z.unknown())), + }), + }), +}); + +export function normalizeProviderType(value: unknown): string | null { + const parsed = providerTypeSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export function adaptProviderSchemas( + payload: unknown, + normalizedProviderType: string, +): ProviderSchemasSuccessResult | null { + const parsed = providerSchemasDocumentSchema.safeParse(payload); + if (!parsed.success || parsed.data.data.id !== normalizedProviderType) { + return null; + } + + return { + status: PROVIDER_SCHEMA_STATUS.SUCCESS, + providerType: parsed.data.data.id, + secretTypes: parsed.data.data.attributes.secret_types, + }; +} diff --git a/ui/actions/providers/provider-schemas.test.ts b/ui/actions/providers/provider-schemas.test.ts new file mode 100644 index 0000000000..0f6ead52f6 --- /dev/null +++ b/ui/actions/providers/provider-schemas.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { authMock, fetchMock } = vi.hoisted(() => ({ + authMock: vi.fn(), + fetchMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" })); + +import { getProviderSchemas } from "./provider-schemas"; + +const schemaResponse = (providerType = "acme") => + new Response( + JSON.stringify({ + data: { + type: "provider-schemas", + id: providerType, + attributes: { secret_types: {} }, + }, + }), + { status: 200 }, + ); + +describe("getProviderSchemas", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + authMock.mockResolvedValue({ accessToken: "access-token" }); + fetchMock.mockResolvedValue(schemaResponse()); + }); + + it("requests the normalized provider schema with authenticated JSON:API headers", async () => { + // When + const result = await getProviderSchemas(" ACME "); + + // Then + expect(result).toEqual({ + status: "success", + providerType: "acme", + secretTypes: {}, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/provider-schemas/acme", + { + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: "Bearer access-token", + }, + }, + ); + }); + + it("does not fetch invalid input and encodes a normalized path segment", async () => { + // Given + fetchMock.mockResolvedValueOnce(schemaResponse("acme/team")); + + // When + const invalid = await Promise.all([ + getProviderSchemas(" "), + getProviderSchemas("a".repeat(51)), + ]); + const encoded = await getProviderSchemas(" ACME/TEAM "); + + // Then + expect(invalid).toEqual([{ status: "error" }, { status: "error" }]); + expect(encoded).toMatchObject({ + status: "success", + providerType: "acme/team", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/provider-schemas/acme%2Fteam", + expect.any(Object), + ); + }); + + it("denies an unauthenticated request without fetching", async () => { + // Given + authMock.mockResolvedValue({}); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "access_denied" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [401, { status: "access_denied" }], + [403, { status: "access_denied" }], + [404, { status: "not_found" }], + [409, { status: "unavailable" }], + [500, { status: "error" }], + ])("maps HTTP %i to a safe result", async (status, expected) => { + // Given + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [{ detail: "private detail" }] }), { + status, + }), + ); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual(expected); + expect(JSON.stringify(result)).not.toContain("private detail"); + }); + + it("returns a generic safe error when fetch rejects", async () => { + // Given + const rejection = new Error("connection detail must not leak"); + fetchMock.mockRejectedValueOnce(rejection); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(JSON.stringify(result)).not.toContain(rejection.message); + }); + + it("distinguishes a malformed success document from a transport failure", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [] })), + ); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "malformed" }); + }); +}); diff --git a/ui/actions/providers/provider-schemas.ts b/ui/actions/providers/provider-schemas.ts new file mode 100644 index 0000000000..afdf0868d2 --- /dev/null +++ b/ui/actions/providers/provider-schemas.ts @@ -0,0 +1,61 @@ +"use server"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl } from "@/lib"; +import { + PROVIDER_SCHEMA_STATUS, + type ProviderSchemasResult, +} from "@/types/provider-schema"; + +import { + adaptProviderSchemas, + normalizeProviderType, +} from "./provider-schemas.adapter"; + +export async function getProviderSchemas( + providerType: unknown, +): Promise { + const normalizedProviderType = normalizeProviderType(providerType); + if (!normalizedProviderType) return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + + let accessToken: string | undefined; + try { + accessToken = (await auth())?.accessToken?.trim(); + } catch { + return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + } + if (!accessToken) return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/provider-schemas/${encodeURIComponent(normalizedProviderType)}`, + { + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch { + return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + } + + if (response.status === 401 || response.status === 403) { + return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED }; + } + if (response.status === 404) { + return { status: PROVIDER_SCHEMA_STATUS.NOT_FOUND }; + } + if (response.status === 409) { + return { status: PROVIDER_SCHEMA_STATUS.UNAVAILABLE }; + } + if (!response.ok) return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + + const schema = adaptProviderSchemas( + await response.json().catch(() => undefined), + normalizedProviderType, + ); + return schema ?? { status: PROVIDER_SCHEMA_STATUS.MALFORMED }; +} diff --git a/ui/actions/providers/registry-provider.test.ts b/ui/actions/providers/registry-provider.test.ts new file mode 100644 index 0000000000..188cf43357 --- /dev/null +++ b/ui/actions/providers/registry-provider.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getInstalledRegistryProviderOptions, + addProvider, + getProviders, + updateProvider, +} = vi.hoisted(() => ({ + getInstalledRegistryProviderOptions: vi.fn(), + addProvider: vi.fn(), + getProviders: vi.fn(), + updateProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); +vi.mock("./providers", () => ({ addProvider, getProviders, updateProvider })); + +import { addRegistryProvider } from "./registry-provider"; + +const formData = (alias = "Test") => { + const form = new FormData(); + form.set("providerType", "acme"); + form.set("providerUid", "account"); + form.set("providerAlias", alias); + return form; +}; +describe("Registry provider account creation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [{ type: "acme", label: "Acme" }], + }); + getProviders.mockResolvedValue({ data: [] }); + }); + it("refuses removed artifacts and revoked permission before creating an account", async () => { + getInstalledRegistryProviderOptions + .mockResolvedValueOnce({ status: "access_denied" }) + .mockResolvedValueOnce({ status: "ready", options: [] }); + expect((await addRegistryProvider(formData()))?.errors).toBeDefined(); + expect((await addRegistryProvider(formData()))?.errors).toBeDefined(); + expect(addProvider).not.toHaveBeenCalled(); + }); + it("reuses a previously created account after a failed credential attempt or lost response", async () => { + const existing = { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Test" }, + }; + getProviders.mockResolvedValue({ data: [existing] }); + expect(await addRegistryProvider(formData())).toEqual({ data: existing }); + expect(addProvider).not.toHaveBeenCalled(); + expect(updateProvider).not.toHaveBeenCalled(); + }); + it.each(["Test", "", " Edited "])( + "saves alias %j before resuming credentials for an existing account", + async (alias) => { + // Given + const existing = { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Original" }, + }; + const updated = { + ...existing, + attributes: { ...existing.attributes, alias: alias.trim() }, + }; + getProviders.mockResolvedValue({ data: [existing] }); + updateProvider.mockResolvedValue({ data: updated }); + + // When + const result = await addRegistryProvider(formData(alias)); + + // Then + expect(result).toEqual({ data: updated }); + expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toEqual({ + providerId: "existing", + providerAlias: alias.trim(), + }); + expect(addProvider).not.toHaveBeenCalled(); + }, + ); + it("keeps alias update failures visible instead of resuming with stale details", async () => { + // Given + const failure = { + errors: [ + { + detail: "Alias is invalid", + source: { pointer: "/data/attributes/alias" }, + }, + ], + }; + getProviders.mockResolvedValue({ + data: [ + { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Original" }, + }, + ], + }); + updateProvider.mockResolvedValue(failure); + + // When / Then + await expect(addRegistryProvider(formData())).resolves.toEqual(failure); + expect(addProvider).not.toHaveBeenCalled(); + }); + it("creates a validated installed provider account", async () => { + addProvider.mockResolvedValue({ data: { id: "new" } }); + expect(await addRegistryProvider(formData())).toEqual({ + data: { id: "new" }, + }); + expect(addProvider).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/actions/providers/registry-provider.ts b/ui/actions/providers/registry-provider.ts new file mode 100644 index 0000000000..68fa43ce40 --- /dev/null +++ b/ui/actions/providers/registry-provider.ts @@ -0,0 +1,57 @@ +"use server"; + +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { createAddProviderFormSchema } from "@/types/formSchemas"; +import { isKnownProviderType } from "@/types/providers"; + +import { addProvider, getProviders, updateProvider } from "./providers"; + +export async function addRegistryProvider(formData: FormData) { + const unavailable = { + errors: [ + { + detail: + "This Registry provider is no longer available. Check your permissions and installed artifacts, then try again.", + source: { pointer: "/data/attributes/provider" }, + }, + ], + }; + try { + const discovery = await getInstalledRegistryProviderOptions(); + if (discovery.status !== "ready") return unavailable; + const values = createAddProviderFormSchema( + discovery.options.map((option) => option.type), + ).safeParse(Object.fromEntries(formData)); + if (!values.success || isKnownProviderType(values.data.providerType)) + return unavailable; + const { providerType, providerUid } = values.data; + const existing = await getProviders({ + filters: { "filter[provider]": providerType, "filter[uid]": providerUid }, + pageSize: 100, + }); + // A previous request may have created the account before its response was + // lost. Reuse that identity when returning to the credential step. + if (!existing?.data) return unavailable; + const account = existing.data.find( + (provider) => + provider.attributes.provider === providerType && + provider.attributes.uid === providerUid, + ); + if (account) { + const alias = values.data.providerAlias.trim(); + if ((account.attributes.alias ?? "") === alias) return { data: account }; + const update = new FormData(); + update.set(ProviderCredentialFields.PROVIDER_ID, account.id); + update.set(ProviderCredentialFields.PROVIDER_ALIAS, alias); + return await updateProvider(update); + } + const validated = new FormData(); + Object.entries(values.data).forEach(([key, value]) => { + if (value !== undefined) validated.set(key, value); + }); + return await addProvider(validated); + } catch { + return unavailable; + } +} diff --git a/ui/actions/registry/registry.adapter.test.ts b/ui/actions/registry/registry.adapter.test.ts new file mode 100644 index 0000000000..dc35be5060 --- /dev/null +++ b/ui/actions/registry/registry.adapter.test.ts @@ -0,0 +1,696 @@ +import { describe, expect, it } from "vitest"; + +import { + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_SUBMISSION, +} from "@/types/registry"; + +import { + adaptRegistryCredentialStatus, + adaptRegistryTenantArtifacts, + classifyRegistryFailure, + collectCompleteRegistryCatalog, + parseRegistryArtifactSubmission, +} from "./registry.adapter"; + +const credentialPayload = { + data: { + attributes: { + configured: true, + is_valid: true, + scopes: ["catalog:read"], + last_validated_at: "2026-03-20T12:00:00Z", + validation_status: "valid", + validation_pending: false, + key: "registry-secret-value", + masked_key: "reg_***", + pending_key: "queued-secret", + arbitrary_backend_detail: "do not expose", + }, + }, +}; + +const activeCredential = adaptRegistryCredentialStatus(credentialPayload); +const jsonError = (status: number, code: string) => + new Response( + JSON.stringify({ errors: [{ code, detail: "private detail" }] }), + { + status, + }, + ); + +describe("Registry adapter", () => { + it("reads the resolved installed version separately from the requested spec", () => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: " 1.0.0 ", + }, + }, + ], + }); + // Then + expect(artifacts).toEqual([ + expect.objectContaining({ + normalizedName: "template", + versionSpec: "latest", + resolvedVersion: "1.0.0", + }), + ]); + }); + + it.each([undefined, null, "", " "])( + "accepts an unknown resolved version %j", + (resolvedVersion) => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: resolvedVersion, + }, + }, + ], + }); + // Then + expect(artifacts).toMatchObject([{ resolvedVersion: undefined }]); + }, + ); + + it("maps only documented non-secret credential status fields", () => { + // Given + const malformedPayload = { data: { attributes: { configured: true } } }; + + // When + const status = adaptRegistryCredentialStatus(credentialPayload); + + // Then + expect(status).toEqual({ + configured: true, + isValid: true, + scopes: ["catalog:read"], + lastValidatedAt: "2026-03-20T12:00:00Z", + validationStatus: "valid", + validationPending: false, + }); + expect(adaptRegistryCredentialStatus(malformedPayload)).toBeNull(); + }); + + it("normalizes an absent credential status with nullable validation fields", () => { + // Given + const absentCredentialPayload = { + data: { + attributes: { + configured: false, + is_valid: false, + scopes: [], + last_validated_at: null, + validation_status: null, + validation_pending: false, + }, + }, + }; + + // When + const status = adaptRegistryCredentialStatus(absentCredentialPayload); + + // Then + expect(status).toEqual({ + configured: false, + isValid: false, + scopes: [], + lastValidatedAt: undefined, + validationStatus: undefined, + validationPending: false, + }); + }); + + it("accepts only a matching artifact 202 task and fixed Content-Location path", async () => { + // Given + const response = new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ); + + // When + const result = await parseRegistryArtifactSubmission(response); + + // Then + expect(result).toEqual({ + status: REGISTRY_SUBMISSION.PENDING, + taskId: "task-123", + }); + }); + + it("rejects a non-202 response or a mismatched task location", async () => { + // Given + const task = JSON.stringify({ data: { type: "tasks", id: "task-123" } }); + const wrongStatus = new Response(task, { status: 201 }); + const wrongLocation = new Response(task, { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other" }, + }); + + // When + const results = await Promise.all([ + parseRegistryArtifactSubmission(wrongStatus), + parseRegistryArtifactSubmission(wrongLocation), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_SUBMISSION.ERROR }, + { status: REGISTRY_SUBMISSION.ERROR }, + ]); + }); + + it("classifies every Registry 401 or 403 as access denied first", async () => { + // Given + const responses = [ + [401, REGISTRY_ENDPOINT.CREDENTIAL], + [403, REGISTRY_ENDPOINT.MUTATION], + [403, REGISTRY_ENDPOINT.PROVIDERS], + ] as const; + + // When + const results = await Promise.all( + responses.map(([status, endpoint]) => + classifyRegistryFailure( + jsonError(status, "registry_key_rejected"), + endpoint, + activeCredential, + ), + ), + ); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + ]); + }); + + it("maps only a 409 with an authoritative no-active credential to onboarding", async () => { + // Given + const noCredential = adaptRegistryCredentialStatus({ + data: { + attributes: { + configured: false, + is_valid: false, + scopes: [], + validation_pending: false, + }, + }, + }); + + // When + const results = await Promise.all( + [noCredential, null].map((credential) => + classifyRegistryFailure( + new Response(null, { status: 409 }), + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + credential, + ), + ), + ); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ONBOARDING }, + { status: REGISTRY_FAILURE.ERROR }, + ]); + }); + + it("maps only exact documented 502 and 503 status-code pairs", async () => { + // Given + const rejected = jsonError(502, "registry_key_rejected"); + const unavailable = jsonError(503, "registry_unavailable"); + + // When + const results = await Promise.all([ + classifyRegistryFailure( + rejected, + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + unavailable, + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + activeCredential, + ), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.RECONNECT }, + { status: REGISTRY_FAILURE.UNAVAILABLE }, + ]); + }); + + it("keeps wrong, malformed, and unrelated failures generic", async () => { + // Given + const malformed = new Response("key=private", { status: 503 }); + + // When + const results = await Promise.all([ + classifyRegistryFailure( + jsonError(502, "other_error"), + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + jsonError(502, "registry_unavailable"), + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + malformed, + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ERROR }, + { status: REGISTRY_FAILURE.ERROR }, + { status: REGISTRY_FAILURE.ERROR }, + ]); + }); + + it("degrades a non-terminal empty first catalog page", async () => { + // Given + const document = (page: number) => ({ + data: [], + meta: { pagination: { page, pages: 2, count: 0 } }, + }); + + // When + const result = await collectCompleteRegistryCatalog(async (page) => + document(page), + ); + + // Then + expect(result).toEqual({ + status: "incomplete", + reason: "invalid_page", + collectedCount: 0, + }); + }); + + it("accepts a terminal empty first catalog page", async () => { + // Given + const document = { + data: [], + meta: { pagination: { page: 1, pages: 1, count: 0 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toEqual({ status: "complete", artifacts: [] }); + }); + + it("maps the flat owner attributes tolerantly", async () => { + // Given + const document = { + data: [ + { + type: "registry-artifacts", + id: "core", + attributes: { + owner_name: "Prowler", + owner_slug: "prowler", + owner_type: "organization", + owner_logo_url: "https://cdn.example/prowler.png", + }, + }, + { + type: "registry-artifacts", + id: "plain-owner", + attributes: { + owner_name: "Ada", + owner_slug: "ada", + owner_type: "user", + owner_logo_url: null, + }, + }, + { + type: "registry-artifacts", + id: "ownerless", + attributes: { owner_name: " ", owner_logo_url: " " }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 3 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "core", + owners: [ + { + type: "organization", + name: "Prowler", + logoUrl: "https://cdn.example/prowler.png", + }, + ], + }, + { + normalizedName: "ownerless", + owners: [], + }, + { + normalizedName: "plain-owner", + owners: [{ type: "user", name: "Ada", logoUrl: undefined }], + }, + ], + }); + }); + + it("defaults omitted built-in status and maps explicit built-ins", async () => { + // Given + const document = { + data: [ + { type: "registry-artifacts", id: "installable", attributes: {} }, + { + type: "registry-artifacts", + id: "built-in", + attributes: { is_builtin: true }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 2 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { normalizedName: "built-in", isBuiltin: true }, + { normalizedName: "installable", isBuiltin: false }, + ], + }); + }); + + it("rejects malformed built-in values and preserves built-in duplicates", async () => { + // Given + const document = (data: unknown[]) => ({ + data, + meta: { pagination: { page: 1, pages: 1, count: data.length } }, + }); + const resource = (id: string, isBuiltin: unknown) => ({ + type: "registry-artifacts", + id, + attributes: { is_builtin: isBuiltin }, + }); + + // When + const explicitFalse = await collectCompleteRegistryCatalog(async () => + document([resource("installable", false)]), + ); + const malformed = await Promise.all( + [null, "true", 1].map((isBuiltin) => + collectCompleteRegistryCatalog(async () => + document([resource("malformed", isBuiltin)]), + ), + ), + ); + const duplicate = await collectCompleteRegistryCatalog(async (page) => ({ + data: [resource("built-in", page === 2)], + meta: { pagination: { page, pages: 2, count: 2 } }, + })); + + // Then + expect(explicitFalse).toMatchObject({ + status: "complete", + artifacts: [{ normalizedName: "installable", isBuiltin: false }], + }); + expect(malformed).toEqual([ + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + ]); + expect(duplicate).toMatchObject({ + status: "complete", + artifacts: [{ normalizedName: "built-in", isBuiltin: true }], + }); + }); + + it("preserves artifact counts, including zero, without inventing missing counts", async () => { + // Given + const resources = [ + { id: "aws", attributes: { check_count: 645, compliance_count: 45 } }, + { id: "openai", attributes: { check_count: 2, compliance_count: 0 } }, + { id: "missing", attributes: {} }, + { + id: "unknown", + attributes: { check_count: null, compliance_count: null }, + }, + { id: "aws", attributes: { check_count: 645 } }, + ].map((resource) => ({ + type: "registry-available-artifacts", + ...resource, + })); + + // When + const result = await collectCompleteRegistryCatalog(async () => ({ + data: resources, + meta: { pagination: { page: 1, pages: 1, count: resources.length } }, + })); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { normalizedName: "aws", checkCount: 645, complianceCount: 45 }, + { + normalizedName: "missing", + checkCount: undefined, + complianceCount: undefined, + }, + { normalizedName: "openai", checkCount: 2, complianceCount: 0 }, + { + normalizedName: "unknown", + checkCount: undefined, + complianceCount: undefined, + }, + ], + }); + }); + + it("preserves the declared provider when merging complementary catalog entries", async () => { + // Given + const fetchPage = async (page: number) => ({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: + page === 1 + ? { providers: ["aaa"], has_checks: true } + : { providers: ["zzz"], has_provider: true }, + }, + ], + meta: { pagination: { page, pages: 2, count: 2 } }, + }); + + // When + const result = await collectCompleteRegistryCatalog(fetchPage); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { hasProvider: true, providerSlug: "zzz", providers: ["aaa", "zzz"] }, + ], + }); + }); + + it("rejects duplicate catalog entries with conflicting declared providers", async () => { + // Given + const fetchPage = async (page: number) => ({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: { + has_provider: true, + providers: [page === 1 ? "aaa" : "zzz"], + }, + }, + ], + meta: { pagination: { page, pages: 2, count: 2 } }, + }); + + // When / Then + await expect( + collectCompleteRegistryCatalog(fetchPage), + ).resolves.toMatchObject({ + status: "incomplete", + reason: "conflicting_duplicate", + }); + }); + + it("traverses, merges, and degrades unsafe catalog data", async () => { + // Given + + const resource = ( + id: string, + attributes: Record = {}, + ) => ({ type: "registry-artifacts", id, attributes }); + + const document = ( + page: number, + pages: number, + count: number, + data: unknown[], + ) => ({ data, meta: { pagination: { page, pages, count } } }); + const requests: Array<[number, string | null, string | null]> = []; + + // When + + const complete = await collectCompleteRegistryCatalog( + async (page, query) => { + requests.push([ + page, + query.get("page[number]"), + query.get("page[size]"), + ]); + return page === 1 + ? document(1, 2, 3, [ + resource("core", { + name: "Core", + providers: ["AWS"], + is_verified: true, + version_count: 1, + total_downloads: 2, + owner_name: "Prowler", + owner_type: "organization", + }), + resource("zeta"), + ]) + : document(2, 2, 3, [ + resource("core", { + description: "Registry core", + latest_version: "2.0.0", + providers: ["gcp"], + is_official: true, + has_checks: true, + version_count: 3, + total_downloads: 8, + }), + ]); + }, + ); + + const limits = await Promise.all( + [999, 1000, 1001].map(async (pages) => { + let requests = 0; + const result = await collectCompleteRegistryCatalog(async (page) => { + requests += 1; + return document(page, pages, pages, [resource(`item-${page}`)]); + }); + return [pages, requests, result] as const; + }), + ); + + const failures = await Promise.all([ + collectCompleteRegistryCatalog(async () => ({ data: {}, meta: {} })), + collectCompleteRegistryCatalog(async () => + document(1, 1, 2, [resource("one")]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page === 1 ? 1 : 1, 2, 2, [resource(`item-${page}`)]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page, page === 1 ? 2 : 3, 2, [resource(`item-${page}`)]), + ), + collectCompleteRegistryCatalog(async () => + document(1, 1, 1, [resource("")]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page, 2, 2, [ + resource("duplicate", { name: page === 1 ? "One" : "Two" }), + ]), + ), + collectCompleteRegistryCatalog(async (page) => { + if (page === 2) throw new Error("offline"); + return document(1, 2, 2, [resource("first")]); + }), + ]); + + // Then + expect(requests).toEqual([ + [1, "1", "100"], + [2, "2", "100"], + ]); + + expect(complete).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "core", + name: "Core", + description: "Registry core", + latestVersion: "2.0.0", + providers: ["aws", "gcp"], + isVerified: true, + isOfficial: true, + hasChecks: true, + versionCount: 3, + totalDownloads: 8, + owners: [{ type: "organization", name: "Prowler" }], + }, + { normalizedName: "zeta" }, + ], + }); + expect(limits.map(([pages, requests]) => [pages, requests])).toEqual([ + [999, 999], + [1000, 1000], + [1001, 1], + ]); + expect(limits[2]?.[2]).toEqual({ + status: "incomplete", + reason: "guard_exhausted", + collectedCount: 1, + }); + expect( + failures.map((result) => + result.status === "incomplete" ? result.reason : undefined, + ), + ).toEqual([ + "invalid_page", + "count_mismatch", + "invalid_page", + "invalid_page", + "invalid_resource", + "conflicting_duplicate", + "page_failed", + ]); + failures.forEach((result) => + expect(result).not.toHaveProperty("artifacts"), + ); + }); +}); diff --git a/ui/actions/registry/registry.adapter.ts b/ui/actions/registry/registry.adapter.ts new file mode 100644 index 0000000000..46bd08d8e0 --- /dev/null +++ b/ui/actions/registry/registry.adapter.ts @@ -0,0 +1,441 @@ +import { z } from "zod"; + +import { isActiveRegistryCredential } from "@/lib/registry/credential-task"; +import { + REGISTRY_CATALOG, + REGISTRY_CATALOG_INCOMPLETE_REASON, + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_MUTATION, + REGISTRY_SUBMISSION, + type RegistryCatalogArtifact, + type RegistryCatalogResult, + type RegistryCredentialStatus, + type RegistryTaskSubmissionResult, + type RegistryEndpoint, + type RegistryFailureResult, + type RegistryMutationResult, + type RegistryTenantArtifact, +} from "@/types/registry"; + +const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/"; +const REGISTRY_ERROR_CODE = { + KEY_REJECTED: "registry_key_rejected", + UNAVAILABLE: "registry_unavailable", +} as const; +const REGISTRY_MUTATION_REFUSAL_COPY = { + no_installable_version: "No available version can be added.", + registry_artifact_not_found: "This artifact is no longer available.", + version_not_found: "This version is not available.", + version_not_processed: "This version is not ready to add yet.", + version_not_verified: "This version is not verified and cannot be added.", + version_yanked: "This version is no longer available.", +} as const; +const registryDiscoveryEndpoints = new Set([ + REGISTRY_ENDPOINT.PROVIDERS, + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, +]); + +const credentialStatusSchema = z.object({ + data: z.object({ + attributes: z.object({ + configured: z.boolean(), + is_valid: z.boolean(), + scopes: z.array(z.string()), + last_validated_at: z.string().nullish(), + validation_status: z.string().nullish(), + validation_pending: z.boolean(), + }), + }), +}); + +const taskSubmissionSchema = z.object({ + data: z.object({ + type: z.literal("tasks"), + id: z.string().min(1), + }), +}); + +const registryCollectionSchema = z.object({ data: z.array(z.unknown()) }); +const tenantArtifactsSchema = z.object({ + data: z.array( + z.object({ + type: z.string().trim().min(1), + id: z.string().trim().min(1), + attributes: z.object({ + version_spec: z.string().trim().min(1), + resolved_version: z.string().trim().nullish(), + inserted_at: z.string().optional(), + updated_at: z.string().optional(), + }), + }), + ), +}); + +const errorDocumentSchema = z.object({ + errors: z.array(z.object({ code: z.string().min(1) })).min(1), +}); + +export function adaptRegistryCredentialStatus( + payload: unknown, +): RegistryCredentialStatus | null { + const parsed = credentialStatusSchema.safeParse(payload); + if (!parsed.success) return null; + + const { attributes } = parsed.data.data; + return { + configured: attributes.configured, + isValid: attributes.is_valid, + scopes: attributes.scopes, + lastValidatedAt: attributes.last_validated_at ?? undefined, + validationStatus: attributes.validation_status ?? undefined, + validationPending: attributes.validation_pending, + }; +} + +export function adaptRegistryTenantArtifacts( + payload: unknown, +): RegistryTenantArtifact[] | null { + const parsed = tenantArtifactsSchema.safeParse(payload); + if (!parsed.success) return null; + + return parsed.data.data.map(({ attributes, id }) => ({ + normalizedName: id, + versionSpec: attributes.version_spec, + resolvedVersion: attributes.resolved_version || undefined, + insertedAt: attributes.inserted_at, + updatedAt: attributes.updated_at, + })); +} + +export function isRegistryCollection(payload: unknown) { + return registryCollectionSchema.safeParse(payload).success; +} + +export class RegistryCatalogPageError extends Error { + constructor(readonly failure: RegistryFailureResult) { + super("Registry catalog page request failed"); + } +} + +export const parseRegistryCredentialSubmission = ( + response: Response, +): Promise => + parseRegistryTaskSubmission(response); + +export const parseRegistryArtifactSubmission = ( + response: Response, +): Promise => + parseRegistryTaskSubmission(response); + +async function parseRegistryTaskSubmission( + response: Response, +): Promise { + if (response.status !== 202) return { status: REGISTRY_SUBMISSION.ERROR }; + + const parsed = taskSubmissionSchema.safeParse( + await response.json().catch(() => undefined), + ); + const taskId = parsed.success ? parsed.data.data.id : undefined; + const location = response.headers.get("Content-Location"); + if ( + !taskId || + location !== `${REGISTRY_TASK_PATH_PREFIX}${encodeURIComponent(taskId)}` + ) { + return { status: REGISTRY_SUBMISSION.ERROR }; + } + + return { status: REGISTRY_SUBMISSION.PENDING, taskId }; +} + +export async function classifyRegistryMutationRefusal( + response: Response, +): Promise | null> { + const code = await getRegistryErrorCode(response); + const message = code + ? REGISTRY_MUTATION_REFUSAL_COPY[ + code as keyof typeof REGISTRY_MUTATION_REFUSAL_COPY + ] + : undefined; + return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null; +} + +export async function classifyRegistryFailure( + response: Response, + endpoint: RegistryEndpoint, + credentialStatus: RegistryCredentialStatus | null, +): Promise { + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + if (!isRegistryDiscoveryEndpoint(endpoint)) { + return { status: REGISTRY_FAILURE.ERROR }; + } + + if ( + response.status === 409 && + credentialStatus !== null && + !isActiveRegistryCredential(credentialStatus) + ) { + return { status: REGISTRY_FAILURE.ONBOARDING }; + } + + const code = await getRegistryErrorCode(response); + if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) { + return { status: REGISTRY_FAILURE.RECONNECT }; + } + if (response.status === 503 && code === REGISTRY_ERROR_CODE.UNAVAILABLE) { + return { status: REGISTRY_FAILURE.UNAVAILABLE }; + } + + return { status: REGISTRY_FAILURE.ERROR }; +} + +function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) { + return registryDiscoveryEndpoints.has(endpoint); +} + +async function getRegistryErrorCode(response: Response) { + const parsed = errorDocumentSchema.safeParse( + await response + .clone() + .json() + .catch(() => undefined), + ); + return parsed.success ? parsed.data.errors[0]?.code : undefined; +} + +const REGISTRY_CATALOG_PAGE_SIZE = 100; +const REGISTRY_CATALOG_MAX_PAGES = 1000; +const safeInteger = z.number().int().nonnegative().safe(); +const catalogPageSchema = z.object({ + data: z.array(z.unknown()), + meta: z.object({ + pagination: z.object({ + page: safeInteger, + pages: safeInteger, + count: safeInteger, + }), + }), +}); +const catalogAttributesSchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + latest_version: z.string().optional(), + providers: z.array(z.string().trim().min(1)).optional(), + owner_name: z.string().optional(), + owner_type: z.string().optional(), + owner_logo_url: z.string().nullable().optional(), + is_verified: z.boolean().optional(), + is_official: z.boolean().optional(), + is_builtin: z.boolean().optional(), + is_meta: z.boolean().optional(), + has_provider: z.boolean().optional(), + has_checks: z.boolean().optional(), + has_compliance: z.boolean().optional(), + check_count: safeInteger.nullish(), + compliance_count: safeInteger.nullish(), + version_count: safeInteger.optional(), + total_downloads: safeInteger.optional(), +}); +const catalogResourceSchema = z.object({ + type: z.string().trim().min(1), + id: z.string().trim().min(1), + attributes: catalogAttributesSchema, +}); +type RegistryCatalogPageFetcher = ( + page: number, + searchParams: URLSearchParams, +) => Promise; + +export async function collectCompleteRegistryCatalog( + fetchPage: RegistryCatalogPageFetcher, +): Promise { + const resources: unknown[] = []; + let expectedPages: number | undefined; + let expectedCount: number | undefined; + for (let page = 1; ; page += 1) { + let payload: unknown; + try { + payload = await fetchPage( + page, + new URLSearchParams({ + "page[number]": String(page), + "page[size]": String(REGISTRY_CATALOG_PAGE_SIZE), + }), + ); + } catch (error) { + if (error instanceof RegistryCatalogPageError) throw error; + return incomplete("PAGE_FAILED", resources.length); + } + const parsed = catalogPageSchema.safeParse(payload); + if (!parsed.success) return incomplete("INVALID_PAGE", resources.length); + const { count, page: responsePage, pages } = parsed.data.meta.pagination; + if ( + responsePage !== page || + (expectedPages !== undefined && + (pages !== expectedPages || count !== expectedCount)) + ) + return incomplete("INVALID_PAGE", resources.length); + expectedPages ??= pages; + expectedCount ??= count; + if (page === 1 && pages > 1 && count === 0 && parsed.data.data.length === 0) + return incomplete("INVALID_PAGE", resources.length); + if (pages === 0) + return page === 1 && count === 0 && parsed.data.data.length === 0 + ? { status: REGISTRY_CATALOG.COMPLETE, artifacts: [] } + : incomplete("INVALID_PAGE", resources.length); + resources.push(...parsed.data.data); + if (pages > REGISTRY_CATALOG_MAX_PAGES) + return incomplete("GUARD_EXHAUSTED", resources.length); + if (page === pages) break; + if (page > pages) return incomplete("INVALID_PAGE", resources.length); + } + const merged = mergeCatalogResources(resources); + return merged.status === REGISTRY_CATALOG.INCOMPLETE || + resources.length === expectedCount + ? merged + : incomplete("COUNT_MISMATCH", resources.length); +} + +function mergeCatalogResources(resources: unknown[]): RegistryCatalogResult { + const artifacts = new Map(); + for (const resource of resources) { + const artifact = adaptCatalogArtifact(resource); + if (!artifact) return incomplete("INVALID_RESOURCE", resources.length); + const prior = artifacts.get(artifact.normalizedName); + const next = prior ? mergeArtifacts(prior, artifact) : artifact; + if (!next) return incomplete("CONFLICTING_DUPLICATE", resources.length); + artifacts.set(next.normalizedName, next); + } + return { + status: REGISTRY_CATALOG.COMPLETE, + artifacts: Array.from(artifacts.values()).sort((left, right) => + compare(left.normalizedName, right.normalizedName), + ), + }; +} + +function adaptCatalogArtifact( + resource: unknown, +): RegistryCatalogArtifact | null { + const parsed = catalogResourceSchema.safeParse(resource); + if (!parsed.success) return null; + const { attributes: a, id } = parsed.data; + return { + normalizedName: id, + name: text(a.name), + description: text(a.description), + latestVersion: text(a.latest_version), + providers: unique( + a.providers?.map((provider) => provider.toLowerCase()) ?? [], + ), + ...(a.has_provider === true && a.providers?.[0] + ? { providerSlug: a.providers[0].toLowerCase() } + : {}), + owners: flatOwner(a), + isVerified: a.is_verified ?? false, + isOfficial: a.is_official ?? false, + isBuiltin: a.is_builtin ?? false, + isMeta: a.is_meta ?? false, + hasProvider: a.has_provider ?? false, + hasChecks: a.has_checks ?? false, + hasCompliance: a.has_compliance ?? false, + checkCount: a.check_count ?? undefined, + complianceCount: a.compliance_count ?? undefined, + versionCount: a.version_count ?? 0, + totalDownloads: a.total_downloads ?? 0, + }; +} + +function mergeArtifacts( + left: RegistryCatalogArtifact, + right: RegistryCatalogArtifact, +): RegistryCatalogArtifact | null { + const [name, description, latestVersion, providerSlug] = [ + mergeText(left.name, right.name), + mergeText(left.description, right.description), + mergeText(left.latestVersion, right.latestVersion), + mergeText(left.providerSlug, right.providerSlug), + ]; + if ( + [name, description, latestVersion, providerSlug].some( + (value) => value === null, + ) + ) + return null; + return { + ...left, + name: name ?? undefined, + description: description ?? undefined, + latestVersion: latestVersion ?? undefined, + providerSlug: providerSlug ?? undefined, + providers: unique([...left.providers, ...right.providers]), + owners: uniqueOwners([...left.owners, ...right.owners]), + isVerified: left.isVerified || right.isVerified, + isOfficial: left.isOfficial || right.isOfficial, + isBuiltin: left.isBuiltin || right.isBuiltin, + isMeta: left.isMeta || right.isMeta, + hasProvider: left.hasProvider || right.hasProvider, + hasChecks: left.hasChecks || right.hasChecks, + hasCompliance: left.hasCompliance || right.hasCompliance, + checkCount: mergeCount(left.checkCount, right.checkCount), + complianceCount: mergeCount(left.complianceCount, right.complianceCount), + versionCount: Math.max(left.versionCount, right.versionCount), + totalDownloads: Math.max(left.totalDownloads, right.totalDownloads), + }; +} + +function incomplete( + reason: keyof typeof REGISTRY_CATALOG_INCOMPLETE_REASON, + collectedCount: number, +): RegistryCatalogResult { + return { + status: REGISTRY_CATALOG.INCOMPLETE, + reason: REGISTRY_CATALOG_INCOMPLETE_REASON[reason], + collectedCount, + }; +} +function text(value: string | undefined) { + return value?.trim() || undefined; +} +function mergeText(left: string | undefined, right: string | undefined) { + return left && right && left !== right ? null : (left ?? right); +} +function mergeCount(left: number | undefined, right: number | undefined) { + if (left === undefined) return right; + if (right === undefined) return left; + return Math.max(left, right); +} +function unique(values: string[]) { + return Array.from(new Set(values)).sort(compare); +} +function flatOwner( + a: z.infer, +): RegistryCatalogArtifact["owners"] { + const name = text(a.owner_name); + if (!name) return []; + return [ + { + name, + type: text(a.owner_type) ?? "", + logoUrl: text(a.owner_logo_url ?? undefined), + }, + ]; +} +function uniqueOwners(owners: RegistryCatalogArtifact["owners"]) { + return Array.from( + new Map( + owners.map((owner) => [`${owner.type}\u0000${owner.name}`, owner]), + ).values(), + ).sort((left, right) => + compare( + `${left.type}\u0000${left.name}`, + `${right.type}\u0000${right.name}`, + ), + ); +} +function compare(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/ui/actions/registry/registry.test.ts b/ui/actions/registry/registry.test.ts new file mode 100644 index 0000000000..f81ee78d23 --- /dev/null +++ b/ui/actions/registry/registry.test.ts @@ -0,0 +1,1138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + authMock, + evaluateAccessMock, + evaluateProviderAccessMock, + fetchMock, + pollTaskUntilSettledMock, +} = vi.hoisted(() => ({ + authMock: vi.fn(), + evaluateAccessMock: vi.fn(), + evaluateProviderAccessMock: vi.fn(), + fetchMock: vi.fn(), + pollTaskUntilSettledMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" })); +vi.mock("@/actions/task/poll", () => ({ + pollTaskUntilSettled: pollTaskUntilSettledMock, +})); +vi.mock("@/lib/registry/access.server", () => ({ + evaluateRegistryAccess: evaluateAccessMock, + evaluateRegistryProviderAccess: evaluateProviderAccessMock, +})); + +import { + addRegistryArtifact, + confirmRegistryArtifactAddition, + disconnectRegistryCredential, + getRegistryBootstrap, + getInstalledRegistryProviderOptions, + refreshRegistryCollections, + removeRegistryArtifact, + refreshRegistryCredential, + submitRegistryCredential, +} from "./registry"; + +const activeCredential = { + configured: true, + isValid: true, + scopes: ["catalog:read"], + validationPending: false, +}; +const noCredential = { + configured: false, + isValid: false, + scopes: [], + validationPending: false, +}; +const pendingCredential = { + configured: true, + isValid: false, + scopes: [], + validationPending: true, +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); +const credentialResponse = (credential = activeCredential) => + jsonResponse({ + data: { + attributes: { + configured: credential.configured, + is_valid: credential.isValid, + scopes: credential.scopes, + validation_pending: credential.validationPending, + }, + }, + }); +const tenantArtifactsResponse = () => + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "prowler-aws", + attributes: { + version_spec: "latest", + inserted_at: "2026-03-20T12:00:00Z", + }, + }, + ], + }); +const catalogResponse = () => + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "prowler-aws", + attributes: { name: "Prowler AWS", providers: ["aws"] }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + authMock.mockResolvedValue({ accessToken: "access-token" }); + evaluateAccessMock.mockResolvedValue({ status: "eligible" }); + evaluateProviderAccessMock.mockResolvedValue({ status: "eligible" }); + fetchMock.mockReset(); + pollTaskUntilSettledMock.mockReset(); +}); + +function mockRequestDeadlines() { + // Native AbortSignal.timeout uses real timers; drive it with the test clock. + vi.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), milliseconds); + return controller.signal; + }); +} + +describe("installed Registry provider discovery", () => { + function mockDiscovery({ + emptyMetadata = false, + failedEndpoint, + failureStatus = 500, + }: { + emptyMetadata?: boolean; + failedEndpoint?: string; + failureStatus?: number; + } = {}) { + fetchMock.mockImplementation((url: string) => { + const endpoint = new URL(url).pathname.split("/").pop(); + if (endpoint === failedEndpoint) return jsonResponse({}, failureStatus); + if (endpoint === "available-artifacts") + return jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "acme-package", + attributes: { + name: "Acme package", + providers: ["acme"], + has_provider: true, + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }); + if (endpoint === "artifacts") + return jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "acme-package", + attributes: { version_spec: "latest" }, + }, + ], + }); + if (endpoint === "providers") + return jsonResponse({ + data: emptyMetadata + ? [] + : [ + { + id: "acme", + attributes: { + name: "Acme Cloud", + logo_url: "https://media.registry.test/acme.svg", + }, + }, + ], + }); + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + } + + it("joins catalog declarations, installed membership and provider metadata", async () => { + mockDiscovery(); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [ + { + type: "acme", + label: "Acme Cloud", + logoUrl: "https://media.registry.test/acme.svg", + }, + ], + }); + }); + + it("allows installed-provider discovery without Registry management access", async () => { + // Given + mockDiscovery({ emptyMetadata: true }); + evaluateAccessMock.mockResolvedValue({ status: "ineligible" }); + // When / Then + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [{ type: "acme", label: "Acme package" }], + }); + expect(evaluateProviderAccessMock).toHaveBeenCalledWith("access-token"); + expect(evaluateAccessMock).not.toHaveBeenCalled(); + }); + + it.each(["ineligible", "unknown"])( + "denies installed-provider discovery when provider access is %s", + async (status) => { + // Given + evaluateProviderAccessMock.mockResolvedValue({ status }); + // When / Then + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("uses the declared provider and artifact name when metadata is empty", async () => { + mockDiscovery({ emptyMetadata: true }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [{ type: "acme", label: "Acme package" }], + }); + }); + + it.each(["available-artifacts", "artifacts", "providers"])( + "returns an error when the %s read fails", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }, + ); + + it.each(["available-artifacts", "artifacts", "providers"])( + "preserves access denial from the %s read", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint, failureStatus: 403 }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + }, + ); +}); + +describe("Registry guarded reads", () => { + it("recovers when a Registry read stalls", async () => { + // Given: the upstream responds only when its request is aborted. + vi.useFakeTimers(); + try { + mockRequestDeadlines(); + fetchMock.mockImplementation( + (_url, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + const settled = vi.fn(); + + // When + void refreshRegistryCredential().then(settled); + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(settled).toHaveBeenCalledWith({ status: "error" }); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + [ + "credential submission", + () => submitRegistryCredential("registry-test-key"), + ], + ["credential disconnection", disconnectRegistryCredential], + [ + "artifact addition", + () => addRegistryArtifact({ normalizedName: "external-package" }), + ], + ["artifact removal", () => removeRegistryArtifact("external-package")], + ])("recovers when %s stalls", async (_name, action) => { + // Given: prerequisite reads succeed, but the mutation never responds. + vi.useFakeTimers(); + try { + mockRequestDeadlines(); + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (init?.method === "POST" || init?.method === "DELETE") { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }); + } + if (url.includes("available-artifacts")) { + return Promise.resolve( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: { has_provider: true, is_builtin: false }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + } + return Promise.resolve(credentialResponse(noCredential)); + }); + const settled = vi.fn(); + + // When + void action().then(settled); + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(settled).toHaveBeenCalledWith({ status: "error" }); + } finally { + vi.useRealTimers(); + } + }); + + it("denies Registry management actions before any Registry endpoint call", async () => { + // Given + evaluateAccessMock.mockResolvedValue({ status: "ineligible" }); + const actions = [ + getRegistryBootstrap, + refreshRegistryCredential, + refreshRegistryCollections, + () => submitRegistryCredential("registry-test-key"), + disconnectRegistryCredential, + ]; + + // When + const results = await Promise.all(actions.map((action) => action())); + + // Then + expect(results).toEqual(actions.map(() => ({ status: "access_denied" }))); + expect(evaluateAccessMock).toHaveBeenCalledTimes(actions.length); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["unknown", "ineligible"])( + "does not fetch Registry collections when access is %s", + async (status) => { + // Given + evaluateAccessMock.mockResolvedValue({ status }); + + // When + const results = await Promise.all([ + getRegistryBootstrap(), + refreshRegistryCredential(), + refreshRegistryCollections(), + ]); + + // Then + expect(results).toEqual([ + { status: "access_denied" }, + { status: "access_denied" }, + { status: status === "unknown" ? "error" : "access_denied" }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("bootstraps in credential, tenant-artifact, then complete-catalog order", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse()) + .mockResolvedValueOnce(tenantArtifactsResponse()) + .mockResolvedValueOnce(catalogResponse()); + + // When + const result = await getRegistryBootstrap(); + + // Then + expect(result).toEqual({ + status: "ready", + state: { + status: "ready", + credential: activeCredential, + catalog: { + status: "complete", + artifacts: [ + expect.objectContaining({ normalizedName: "prowler-aws" }), + ], + }, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }, + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/artifacts", + "https://api.test/api/v1/registry/available-artifacts?page%5Bnumber%5D=1&page%5Bsize%5D=100", + ]); + fetchMock.mock.calls.forEach(([, options]) => { + expect(options).toMatchObject({ + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: "Bearer access-token", + }, + }); + }); + }); + + it.each([ + [noCredential, "onboarding"], + [pendingCredential, "validation_pending"], + ] as const)( + "blocks catalog bootstrap as %s credential is authoritative", + async (credential, expectedStatus) => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(credential)) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await getRegistryBootstrap(); + + // Then + expect(result).toEqual({ + status: "ready", + state: { + status: expectedStatus, + credential, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }, + ); + + it("returns only a non-secret status read after a fresh guard", async () => { + // Given + fetchMock.mockResolvedValueOnce(credentialResponse()); + + // When + const result = await refreshRegistryCredential(); + + // Then + expect(result).toEqual({ status: "status", credential: activeCredential }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("returns fresh complete collections", async () => { + // Given + fetchMock + .mockResolvedValueOnce(catalogResponse()) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await refreshRegistryCollections(); + + // Then + expect(result).toEqual({ + status: "complete", + catalog: { + status: "complete", + artifacts: [expect.objectContaining({ normalizedName: "prowler-aws" })], + }, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }); + expect(evaluateAccessMock).toHaveBeenCalledWith("access-token"); + }); + + it("maps a discovery 409 to onboarding after an authoritative no-credential read", async () => { + // Given + fetchMock + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 409)) + .mockResolvedValueOnce(credentialResponse(noCredential)); + + // When + const result = await refreshRegistryCollections(); + + // Then + expect(result).toEqual({ status: "onboarding" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps documented read recovery without exposing retained or partial catalog data", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 502), + ); + + // When + const reconnect = await refreshRegistryCollections(); + + // Then + expect(reconnect).toEqual({ status: "reconnect" }); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 503), + ); + + // When + const unavailable = await refreshRegistryCollections(); + + // Then + expect(unavailable).toEqual({ status: "unavailable" }); + expect(unavailable).not.toHaveProperty("catalog"); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "other_failure" }] }, 502), + ); + + // When + const generic = await refreshRegistryCollections(); + + // Then + expect(generic).toEqual({ status: "error" }); + }); + + it("keeps a transient access check failure retryable when refreshing collections", async () => { + // Given: the API cannot answer the permission check during a transient outage. + evaluateAccessMock.mockResolvedValueOnce({ status: "unknown" }); + + // When / Then: preserve the current page instead of treating the outage as revocation. + expect(await refreshRegistryCollections()).toEqual({ status: "error" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps Registry 401 and 403 to access denial before any recovery classification", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 401), + ); + + // When + const credential = await refreshRegistryCredential(); + + // Then + expect(credential).toEqual({ status: "access_denied" }); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 403), + ); + + // When + const collections = await refreshRegistryCollections(); + + // Then + expect(collections).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("rechecks access between separate actions after permission revocation", async () => { + // Given + evaluateAccessMock + .mockResolvedValueOnce({ status: "eligible" }) + .mockResolvedValueOnce({ status: "ineligible" }); + fetchMock.mockResolvedValueOnce(credentialResponse()); + + // When + const first = await refreshRegistryCredential(); + const second = await refreshRegistryCollections(); + + // Then + expect(first).toEqual({ status: "status", credential: activeCredential }); + expect(second).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(evaluateAccessMock).toHaveBeenCalledTimes(2); + }); + + it("returns the accepted validation task immediately without server-side polling", async () => { + // Given + const key = " registry-test-key "; + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ + status: "submitted", + taskId: "task-123", + priorConfigured: false, + }); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-credentials", + attributes: { api_key: key.trim() }, + }, + }), + cache: "no-store", + method: "POST", + }), + ); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("marks an accepted replacement as superseding a configured credential", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(activeCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-456" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-456" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential("registry-replacement-key"); + + // Then + expect(result).toEqual({ + status: "submitted", + taskId: "task-456", + priorConfigured: true, + }); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + }); + + it("re-reads credential and preserves authoritative My artifacts after disconnect", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await disconnectRegistryCredential(); + + // Then + expect(result).toEqual({ + status: "disconnected", + credential: noCredential, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/artifacts", + ]); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ cache: "no-store", method: "DELETE" }), + ); + }); + + it("rejects a task-binding mismatch without returning the key or a task", async () => { + // Given + const key = "registry-test-key"; + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other-task" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("rejects malformed accepted task data without a task identity", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "not-a-task", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential("registry-test-key"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).not.toHaveProperty("taskId"); + }); + + it("preserves an active credential after a rejected replacement", async () => { + // Given + const key = "registry-replacement-key"; + fetchMock + .mockResolvedValueOnce(credentialResponse(activeCredential)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 500)); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ + status: "replacement_failed", + credential: activeCredential, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("handles action authorization failures safely", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 401)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 403)); + + // When + const rejected = await submitRegistryCredential("registry-test-key"); + const disconnected = await disconnectRegistryCredential(); + + // Then + expect(rejected).toEqual({ status: "access_denied" }); + expect(disconnected).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +const installCatalogMock = vi.fn(); +describe("Registry artifact mutations", () => { + beforeEach(() => { + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes: { + has_provider: true, + is_builtin: false, + providers: ["acme"], + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + vi.stubGlobal("fetch", (url: string, options?: RequestInit) => + url.includes("/available-artifacts") + ? installCatalogMock(url, options) + : fetchMock(url, options), + ); + }); + + it.each([ + { has_provider: true, is_builtin: true }, + { has_provider: false, is_builtin: false }, + ])( + "refuses ineligible catalog entries before POST: %j", + async (attributes) => { + // Given + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + }); + // Then + expect(result).toMatchObject({ status: "refused" }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("returns an accepted Add task without reading My artifacts", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ); + + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + versionSpec: " 2.0.0 ", + }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "artifact-task" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: "later-guard", + version_spec: "2.0.0", + }, + }, + }), + cache: "no-store", + method: "POST", + }), + ); + }); + + it("defaults Add to latest", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ); + + // When + const result = await addRegistryArtifact({ normalizedName: "later-guard" }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "artifact-task" }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: "later-guard", + version_spec: "latest", + }, + }, + }), + }), + ); + }); + + it("rejects invalid accepted task bindings without reading My artifacts", async () => { + // Given + const document = JSON.stringify({ + data: { type: "tasks", id: "artifact-task" }, + }); + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { type: "tasks" } }), { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }), + ) + .mockResolvedValueOnce(new Response(document, { status: 202 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "other", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(document, { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other" }, + }), + ); + + // When + const outcomes = await Promise.all( + ["missing-id", "missing-location", "wrong-type", "wrong-location"].map( + () => addRegistryArtifact({ normalizedName: "later-guard" }), + ), + ); + + // Then + expect(outcomes).toEqual(Array(4).fill({ status: "error" })); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("keeps a missing Registry credential synchronous", async () => { + // Given + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [] }, 409)); + + // When + const outcome = await addRegistryArtifact({ + normalizedName: "later-guard", + }); + + // Then + expect(outcome).toEqual({ status: "onboarding" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ["registry_artifact_not_found", "This artifact is no longer available."], + ["version_yanked", "This version is no longer available."], + [ + "version_not_verified", + "This version is not verified and cannot be added.", + ], + ["version_not_processed", "This version is not ready to add yet."], + ["version_not_found", "This version is not available."], + ["no_installable_version", "No available version can be added."], + ])("keeps membership unchanged for %s", async (code, message) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse( + { errors: [{ code }] }, + code === "registry_artifact_not_found" ? 404 : 400, + ), + ); + + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + versionSpec: "2.0.0", + }); + + // Then + expect(result).toEqual({ status: "refused", message }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports an in-use artifact when Remove returns 409 without refreshing membership", async () => { + // Given + fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 })); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: "in_use" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + [401, "access_denied"], + [403, "access_denied"], + [400, "error"], + [500, "error"], + ])("preserves the Remove failure for HTTP %s", async (status, expected) => { + // Given + fetchMock.mockResolvedValueOnce(new Response(null, { status })); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: expected }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports a Remove network failure without refreshing membership", async () => { + // Given + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("encodes the deletion identity and confirms Remove after an absent refresh", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse({ data: [] })); + + // When + const result = await removeRegistryArtifact("guard/with space"); + + // Then + expect(result).toEqual({ status: "confirmed", tenantArtifacts: [] }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts/guard%2Fwith%20space", + expect.objectContaining({ cache: "no-store", method: "DELETE" }), + ); + }); + + it.each(["1.0.0", null, undefined])( + "does not confirm an update when the installed version is %j", + async (resolvedVersion) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: resolvedVersion, + }, + }, + ], + }), + ); + // When + const result = await confirmRegistryArtifactAddition("template", "1.1.0"); + // Then + expect(result).toEqual({ status: "refresh_failed" }); + }, + ); + + it("confirms an update only after reading its resolved target version", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { version_spec: "latest", resolved_version: "1.1.0" }, + }, + ], + }), + ); + // When / Then + expect( + await confirmRegistryArtifactAddition("template", "1.1.0"), + ).toMatchObject({ + status: "confirmed", + tenantArtifacts: [ + { normalizedName: "template", resolvedVersion: "1.1.0" }, + ], + }); + }); + + it.each([ + [ + "Add", + () => addRegistryArtifact({ normalizedName: "later-guard" }), + { data: [] }, + { status: "error" }, + 1, + ], + [ + "Remove", + () => removeRegistryArtifact("later-guard"), + { + data: [ + { + type: "registry-artifacts", + id: "later-guard", + attributes: { version_spec: "latest" }, + }, + ], + }, + { status: "refresh_failed" }, + 2, + ], + ])( + "keeps membership unchanged when %s refresh contradicts acceptance", + async (_name, mutate, refreshedArtifacts, expected, calls) => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse(refreshedArtifacts)); + + // When + const result = await mutate(); + + // Then + expect(result).toEqual(expected); + expect(fetchMock).toHaveBeenCalledTimes(calls); + }, + ); +}); diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts new file mode 100644 index 0000000000..a67c83e57f --- /dev/null +++ b/ui/actions/registry/registry.ts @@ -0,0 +1,578 @@ +"use server"; + +import { z } from "zod"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl } from "@/lib"; +import { REGISTRY_ACCESS } from "@/lib/registry/access"; +import { + evaluateRegistryAccess, + evaluateRegistryProviderAccess, +} from "@/lib/registry/access.server"; +import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts"; +import { isActiveRegistryCredential } from "@/lib/registry/credential-task"; +import { + buildRegistryProviderOptions, + type RegistryProviderOption, +} from "@/lib/registry/provider-options"; +import { + REGISTRY_ARTIFACT_ACTION, + REGISTRY_ARTIFACT_REMOVAL, + REGISTRY_BOOTSTRAP_STATE, + REGISTRY_CATALOG, + REGISTRY_CREDENTIAL_ACTION, + REGISTRY_CREDENTIAL_READ, + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_SUBMISSION, + type RegistryAddArtifactInput, + type RegistryArtifactRemovalResult, + type RegistryBootstrapResult, + type RegistryBootstrapState, + type RegistryCollectionsResult, + type RegistryCredentialActionResult, + type RegistryCredentialReadResult, + type RegistryCredentialStatus, + type RegistryCredentialSubmitResult, + type RegistryFailureResult, + type RegistryMutationResult, +} from "@/types/registry"; + +import { + adaptRegistryCredentialStatus, + adaptRegistryTenantArtifacts, + classifyRegistryFailure, + classifyRegistryMutationRefusal, + collectCompleteRegistryCatalog, + isRegistryCollection, + parseRegistryArtifactSubmission, + parseRegistryCredentialSubmission, + RegistryCatalogPageError, +} from "./registry.adapter"; + +const REGISTRY_REQUEST_TIMEOUT_MS = 15_000; + +async function getRegistryAccess(): Promise { + const accessToken = (await auth())?.accessToken; + const access = await evaluateRegistryAccess(accessToken); + return access.status === REGISTRY_ACCESS.ELIGIBLE && accessToken?.trim() + ? accessToken + : null; +} + +async function readRegistryResponse( + accessToken: string, + resource: string, + endpoint: (typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT], + credential: RegistryCredentialStatus | null = null, + searchParams?: URLSearchParams, +): Promise { + const url = new URL(`${apiBaseUrl}/registry/${resource}`); + if (searchParams) url.search = searchParams.toString(); + + let response: Response; + try { + response = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }, + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.ok) return response; + + return endpoint === REGISTRY_ENDPOINT.PROVIDERS || + endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS + ? classifyDiscoveryFailure(response, endpoint, accessToken, credential) + : classifyRegistryFailure(response, endpoint, credential); +} + +async function readRegistryCredential(accessToken: string) { + const result = await readRegistryResponse( + accessToken, + "credential", + REGISTRY_ENDPOINT.CREDENTIAL, + ); + if (!(result instanceof Response)) return result; + + const credential = adaptRegistryCredentialStatus( + await result.json().catch(() => undefined), + ); + return credential + ? { status: REGISTRY_CREDENTIAL_READ.STATUS, credential } + : { status: REGISTRY_FAILURE.ERROR }; +} + +async function readRegistryTenantArtifacts(accessToken: string) { + const result = await readRegistryResponse( + accessToken, + "artifacts", + REGISTRY_ENDPOINT.MUTATION, + ); + if (!(result instanceof Response)) return result; + + const tenantArtifacts = adaptRegistryTenantArtifacts( + await result.json().catch(() => undefined), + ); + return tenantArtifacts + ? { status: "ready" as const, tenantArtifacts } + : { status: REGISTRY_FAILURE.ERROR }; +} + +async function classifyDiscoveryFailure( + response: Response, + endpoint: + | typeof REGISTRY_ENDPOINT.PROVIDERS + | typeof REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + if (response.status === 409 && credential === null) { + const currentCredential = await readRegistryCredential(accessToken); + if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return currentCredential; + } + credential = + currentCredential.status === REGISTRY_CREDENTIAL_READ.STATUS + ? currentCredential.credential + : null; + } + return classifyRegistryFailure(response, endpoint, credential); +} + +async function readRegistryProviders( + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + const result = await readRegistryResponse( + accessToken, + "providers", + REGISTRY_ENDPOINT.PROVIDERS, + credential, + ); + if (!(result instanceof Response)) return result; + const payload = await result.json().catch(() => undefined); + const metadata = z + .object({ + data: z.array( + z.object({ + id: z.string(), + attributes: z + .object({ + name: z.string().optional(), + logo_url: z.string().nullable().optional(), + }) + .optional(), + }), + ), + }) + .safeParse(payload); + return isRegistryCollection(payload) + ? { + status: "ready" as const, + providers: metadata.success + ? metadata.data.data.map((provider) => ({ + type: provider.id, + label: provider.attributes?.name || provider.id, + ...(provider.attributes?.logo_url + ? { logoUrl: provider.attributes.logo_url } + : {}), + })) + : [], + } + : { status: REGISTRY_FAILURE.ERROR }; +} + +export async function getInstalledRegistryProviderOptions(): Promise< + | { status: "ready"; options: RegistryProviderOption[] } + | { status: "access_denied" | "error" } +> { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryProviderAccess(access); + if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE) + return { status: "access_denied" }; + const [catalog, installed, providers] = await Promise.all([ + readCompleteRegistryCatalog(access, null), + readRegistryTenantArtifacts(access), + readRegistryProviders(access, null), + ]); + if ( + [catalog.status, installed.status, providers.status].some( + (status) => status === REGISTRY_FAILURE.ACCESS_DENIED, + ) + ) + return { status: "access_denied" }; + if ( + catalog.status !== REGISTRY_CATALOG.COMPLETE || + installed.status !== "ready" || + providers.status !== "ready" + ) + return { status: "error" }; + return { + status: "ready", + options: buildRegistryProviderOptions( + catalog.artifacts, + installed.tenantArtifacts, + providers.providers, + ), + }; +} + +async function readCompleteRegistryCatalog( + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + try { + return await collectCompleteRegistryCatalog(async (_page, searchParams) => { + const result = await readRegistryResponse( + accessToken, + "available-artifacts", + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + credential, + searchParams, + ); + if (!(result instanceof Response)) + throw new RegistryCatalogPageError(result); + return result.json(); + }); + } catch (error) { + return error instanceof RegistryCatalogPageError + ? error.failure + : { status: REGISTRY_FAILURE.ERROR }; + } +} + +async function confirmRegistryMutation( + accessToken: string, + normalizedName: string, + shouldBePresent: boolean, + expectedVersion?: string, +): Promise { + const tenantArtifacts = await readRegistryTenantArtifacts(accessToken); + if (tenantArtifacts.status === REGISTRY_FAILURE.ACCESS_DENIED) + return tenantArtifacts; + if ( + tenantArtifacts.status !== "ready" || + tenantArtifacts.tenantArtifacts.some( + (artifact) => artifact.normalizedName === normalizedName, + ) !== shouldBePresent || + (expectedVersion !== undefined && + tenantArtifacts.tenantArtifacts.find( + (artifact) => artifact.normalizedName === normalizedName, + )?.resolvedVersion !== expectedVersion.trim()) + ) { + return { status: "refresh_failed" }; + } + return { + status: "confirmed", + tenantArtifacts: tenantArtifacts.tenantArtifacts, + }; +} + +function bootstrapReady( + state: RegistryBootstrapState, +): RegistryBootstrapResult { + return { status: REGISTRY_BOOTSTRAP_STATE.READY, state }; +} + +function bootstrapFailure( + failure: RegistryFailureResult, +): RegistryBootstrapResult { + if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + return bootstrapReady({ + status: + failure.status === REGISTRY_FAILURE.ONBOARDING + ? REGISTRY_BOOTSTRAP_STATE.ERROR + : failure.status, + }); +} + +export async function getRegistryBootstrap(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const credentialRead = await readRegistryCredential(access); + if (credentialRead.status !== REGISTRY_CREDENTIAL_READ.STATUS) { + return bootstrapFailure(credentialRead); + } + const tenantArtifactsRead = await readRegistryTenantArtifacts(access); + if (tenantArtifactsRead.status !== "ready") { + return bootstrapFailure(tenantArtifactsRead); + } + + const { credential } = credentialRead; + const { tenantArtifacts } = tenantArtifactsRead; + if (!isActiveRegistryCredential(credential)) { + return bootstrapReady({ + status: credential.validationPending + ? REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING + : REGISTRY_BOOTSTRAP_STATE.ONBOARDING, + credential, + tenantArtifacts, + }); + } + + const catalog = await readCompleteRegistryCatalog(access, credential); + if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) { + return bootstrapReady({ + status: REGISTRY_BOOTSTRAP_STATE.INCOMPLETE, + catalog, + }); + } + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) { + return bootstrapFailure(catalog); + } + + return bootstrapReady({ + status: REGISTRY_BOOTSTRAP_STATE.READY, + credential, + catalog, + tenantArtifacts, + }); +} + +export async function refreshRegistryCredential(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + return readRegistryCredential(access); +} + +export async function refreshRegistryCollections(): Promise { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryAccess(access); + if (permission.status === REGISTRY_ACCESS.UNKNOWN) + return { status: REGISTRY_FAILURE.ERROR }; + if (permission.status !== REGISTRY_ACCESS.ELIGIBLE || !access?.trim()) + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const catalog = await readCompleteRegistryCatalog(access, null); + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) return catalog; + const tenantArtifactsRead = await readRegistryTenantArtifacts(access); + return tenantArtifactsRead.status === "ready" + ? { + status: REGISTRY_CATALOG.COMPLETE, + catalog, + tenantArtifacts: tenantArtifactsRead.tenantArtifacts, + } + : tenantArtifactsRead; +} + +export async function addRegistryArtifact({ + normalizedName, + versionSpec, +}: RegistryAddArtifactInput): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const; + if ( + typeof normalizedName !== "string" || + !normalizedName.trim() || + (versionSpec !== undefined && typeof versionSpec !== "string") + ) + return { status: REGISTRY_FAILURE.ERROR }; + const catalog = await readCompleteRegistryCatalog(access, null); + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) { + return catalog.status === REGISTRY_CATALOG.INCOMPLETE + ? { status: REGISTRY_FAILURE.ERROR } + : catalog; + } + const artifact = catalog.artifacts.find( + (entry) => entry.normalizedName === normalizedName, + ); + if (!artifact || !isRegistryArtifactInstallable(artifact)) + return { + status: "refused", + message: "Only external provider artifacts can be added.", + }; + const selectedVersion = versionSpec?.trim() || "latest"; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/artifacts`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: normalizedName, + version_spec: selectedVersion, + }, + }, + }), + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR } as const; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const; + } + if (response.status === 409) { + return { status: REGISTRY_FAILURE.ONBOARDING }; + } + if (!response.ok) { + return ( + (await classifyRegistryMutationRefusal(response)) ?? { + status: REGISTRY_FAILURE.ERROR, + } + ); + } + + const submission = await parseRegistryArtifactSubmission(response); + return submission.status === REGISTRY_SUBMISSION.PENDING + ? { status: REGISTRY_ARTIFACT_ACTION.SUBMITTED, taskId: submission.taskId } + : { status: REGISTRY_FAILURE.ERROR }; +} + +export async function confirmRegistryArtifactAddition( + normalizedName: string, + expectedVersion?: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + if ( + expectedVersion !== undefined && + (typeof expectedVersion !== "string" || !expectedVersion.trim()) + ) { + return { status: REGISTRY_FAILURE.ERROR }; + } + return confirmRegistryMutation(access, normalizedName, true, expectedVersion); +} + +export async function removeRegistryArtifact( + normalizedName: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/registry/artifacts/${encodeURIComponent(normalizedName)}`, + { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + }, + ); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + if (response.status === 409) { + return { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE }; + } + if (!response.ok) return { status: REGISTRY_FAILURE.ERROR }; + + return confirmRegistryMutation(access, normalizedName, false); +} + +export async function submitRegistryCredential( + key: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const priorCredential = await readRegistryCredential(access); + if (priorCredential.status !== REGISTRY_CREDENTIAL_READ.STATUS) { + return priorCredential; + } + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/credential`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + body: JSON.stringify({ + data: { + type: "registry-credentials", + attributes: { api_key: key.trim() }, + }, + }), + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + // The task settles client-side through the task watcher; this action only + // hands back the verified task identity so the caller can watch it. + const submission = await parseRegistryCredentialSubmission(response); + if (submission.status !== REGISTRY_SUBMISSION.PENDING) { + return priorCredential.credential.configured + ? { + status: REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED, + credential: priorCredential.credential, + } + : { status: REGISTRY_FAILURE.ERROR }; + } + + return { + status: REGISTRY_CREDENTIAL_ACTION.SUBMITTED, + taskId: submission.taskId, + priorConfigured: priorCredential.credential.configured, + }; +} + +export async function disconnectRegistryCredential(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/credential`, { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + const credential = await readRegistryCredential(access); + const tenantArtifacts = await readRegistryTenantArtifacts(access); + if (credential.status !== REGISTRY_CREDENTIAL_READ.STATUS) return credential; + if (tenantArtifacts.status !== "ready") return tenantArtifacts; + if (!response.ok) return { status: REGISTRY_FAILURE.ERROR }; + + return { + status: REGISTRY_CREDENTIAL_ACTION.DISCONNECTED, + credential: credential.credential, + tenantArtifacts: tenantArtifacts.tenantArtifacts, + }; +} diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 3b253cce58..6c79579b99 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -50,6 +50,7 @@ const makeRoleFormData = () => { formData.set("manage_scans", "false"); formData.set("manage_alerts", "true"); formData.set("manage_lighthouse_ai_configuration", "true"); + formData.set("manage_registry", "true"); formData.set("unlimited_visibility", "false"); return formData; }; @@ -73,6 +74,36 @@ describe("role actions", () => { vi.unstubAllEnvs(); }); + it("includes manage_registry when creating and updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await addRole(makeRoleFormData()); + const createAttributes = lastRequestBody().data.attributes; + await updateRole(makeRoleFormData(), "role-1"); + const updateAttributes = lastRequestBody().data.attributes; + + // Then + expect(createAttributes.manage_registry).toBe(true); + expect(updateAttributes.manage_registry).toBe(true); + }); + + it("omits manage_registry when creating and updating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await addRole(makeRoleFormData()); + const createAttributes = lastRequestBody().data.attributes; + await updateRole(makeRoleFormData(), "role-1"); + const updateAttributes = lastRequestBody().data.attributes; + + // Then + expect(createAttributes).not.toHaveProperty("manage_registry"); + expect(updateAttributes).not.toHaveProperty("manage_registry"); + }); + it("includes manage_alerts when creating a role in Prowler Cloud", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 972d6d12e8..00302165e9 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -116,6 +116,8 @@ export const addRole = async (formData: FormData) => { formData.get("manage_alerts") === "true"; payload.data.attributes.manage_lighthouse_ai_configuration = formData.get("manage_lighthouse_ai_configuration") === "true"; + payload.data.attributes.manage_registry = + formData.get("manage_registry") === "true"; } // Add provider groups relationships only if there are items @@ -175,6 +177,8 @@ export const updateRole = async (formData: FormData, roleId: string) => { formData.get("manage_alerts") === "true"; payload.data.attributes.manage_lighthouse_ai_configuration = formData.get("manage_lighthouse_ai_configuration") === "true"; + payload.data.attributes.manage_registry = + formData.get("manage_registry") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 4d0d06e092..a753c99c1a 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -6,6 +6,7 @@ import { ReactNode, Suspense } from "react"; import { getProviders } from "@/actions/providers"; import { getScansByState } from "@/actions/scans/scans"; +import { auth } from "@/auth.config"; import MainLayout from "@/components/layout/main-layout/main-layout"; import { OnboardingCheckpointWatcher, @@ -20,6 +21,8 @@ import { GlobalSidePanel } from "@/components/side-panel"; import { FeedbackSurvey } from "@/components/survey/feedback-survey"; import { fontMono, fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; +import { REGISTRY_ACCESS } from "@/lib/registry/access"; +import { evaluateRegistryAccess } from "@/lib/registry/access.server"; import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import { StoreInitializer } from "@/store/ui/store-initializer"; @@ -56,6 +59,13 @@ export default async function RootLayout({ // Skip Cloud-only onboarding fetches and orchestrators in OSS. const cloudEnabled = isCloud(); + // One-time server-side Registry gate per request: only an ELIGIBLE answer + // shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started + // here so it resolves in parallel with the Cloud onboarding fetches. + const registryAccessPromise = auth().then((session) => + evaluateRegistryAccess(session?.accessToken), + ); + // Fail-open: unknown scan state is treated as "has data" so the banner never blocks // progression on a fetch error. let hasCompletedScan = true; @@ -78,6 +88,9 @@ export default async function RootLayout({ : undefined; } + const registryEligible = + (await registryAccessPromise).status === REGISTRY_ACCESS.ELIGIBLE; + return ( @@ -98,7 +111,9 @@ export default async function RootLayout({ {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */} - + {cloudEnabled && ( <> diff --git a/ui/app/(prowler)/registry/page.tsx b/ui/app/(prowler)/registry/page.tsx new file mode 100644 index 0000000000..d5e3ab63de --- /dev/null +++ b/ui/app/(prowler)/registry/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; + +import { getRegistryBootstrap } from "@/actions/registry/registry"; +import { RegistryExplorer } from "@/components/registry/registry-explorer"; +import { ContentLayout } from "@/components/shadcn/content-layout/content-layout"; +import { getRegistryPresentation } from "@/lib/registry/presentation"; +import { readEnv } from "@/lib/runtime-env"; +import { REGISTRY_FAILURE } from "@/types/registry"; + +export const dynamic = "force-dynamic"; + +export default async function RegistryPage() { + const bootstrap = await getRegistryBootstrap(); + if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile"); + + return ( + + + + ); +} diff --git a/ui/auth.config.test.ts b/ui/auth.config.test.ts index 5815f31496..0bb714cc93 100644 --- a/ui/auth.config.test.ts +++ b/ui/auth.config.test.ts @@ -39,6 +39,7 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = { manage_integrations: false, manage_billing: false, manage_alerts: false, + manage_registry: false, manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; @@ -46,6 +47,7 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = { const ELEVATED_PERMISSIONS: RolePermissionAttributes = { ...RESTRICTED_PERMISSIONS, manage_users: true, + manage_registry: true, manage_scans: true, }; @@ -174,6 +176,25 @@ describe("authConfig JWT callback", () => { }); }); + it("should default manage_registry to false when a sign-in user omits it", async () => { + // Given + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const result = await jwtCallback({ + token: {}, + account: {} as Parameters[0]["account"], + user: { + accessToken: "access-token", + refreshToken: "refresh-token", + } as Parameters[0]["user"], + }); + + // Then + expect(result.user?.permissions.manage_registry).toBe(false); + }); + it("should report a tenant switch failure while preserving the current session", async () => { // Given vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index c741534d24..f3ba8bbe18 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -61,6 +61,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = { manage_billing: false, manage_alerts: false, manage_lighthouse_ai_configuration: false, + manage_registry: false, unlimited_visibility: false, }; diff --git a/ui/changelog.d/registry-private-cloud.added.md b/ui/changelog.d/registry-private-cloud.added.md new file mode 100644 index 0000000000..0f5b9fb6d3 --- /dev/null +++ b/ui/changelog.d/registry-private-cloud.added.md @@ -0,0 +1 @@ +Registry marketplace and external provider onboarding for Private Cloud, with permission-based access independent of billing, confirmed artifact installation, schema-driven credentials, connection checks, and scan launch diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx index 911068b1cc..8e33a31b86 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useUIStore } from "@/store/ui/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { AppSidebarContent } from "./app-sidebar-content"; @@ -57,6 +58,7 @@ describe("AppSidebarContent", () => { openCloudUpgradeMock.mockClear(); openLaunchScanModalMock.mockClear(); useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + useUIStore.setState({ registryEligible: false }); }); afterEach(() => { @@ -89,6 +91,32 @@ describe("AppSidebarContent", () => { expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0); }); + it("shows Registry navigation when the server marked this request eligible", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + useUIStore.setState({ registryEligible: true }); + + // When + render(); + + // Then + expect(screen.getByRole("link", { name: /Registry/ })).toHaveAttribute( + "href", + "/registry", + ); + }); + + it("hides Registry navigation without a server eligibility decision", () => { + // Given / When + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + render(); + + // Then + expect( + screen.queryByRole("link", { name: /Registry/ }), + ).not.toBeInTheDocument(); + }); + it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx index 8d5e869793..f157e2f7ae 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -8,6 +8,7 @@ import { ProwlerBrand } from "@/components/icons"; import { useAuth } from "@/hooks"; import { useRuntimeConfig } from "@/hooks/use-runtime-config"; import { isCloud } from "@/lib/shared/env"; +import { useUIStore } from "@/store/ui/store"; import { useAppSidebarMode } from "./app-sidebar-mode-store"; import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle"; @@ -24,6 +25,8 @@ interface AppSidebarContentProps { export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { const pathname = usePathname(); const { permissions } = useAuth(); + // One-time server decision per request, seeded by the root layout. + const registryEligible = useUIStore((state) => state.registryEligible); const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig(); const mode = useAppSidebarMode((state) => state.mode); const isCloudEnvironment = isCloud(); @@ -31,6 +34,7 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { pathname, apiDocsUrl, cloudBillingEnabled, + registryEligible, permissions, }); const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT; diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts index 05ff31abae..aa0904d3b9 100644 --- a/ui/components/layout/app-sidebar/navigation-config.ts +++ b/ui/components/layout/app-sidebar/navigation-config.ts @@ -5,6 +5,7 @@ import { GitBranch, LayoutGrid, MessageCircleQuestion, + Package, Settings, ShieldCheck, SquareChartGantt, @@ -32,6 +33,7 @@ interface NavigationConfigOptions { pathname: string; apiDocsUrl?: string | null; cloudBillingEnabled?: boolean; + registryEligible?: boolean; permissions?: RolePermissionAttributes; } @@ -108,6 +110,7 @@ export function getNavigationConfig({ pathname, apiDocsUrl = null, cloudBillingEnabled = false, + registryEligible = false, permissions, }: NavigationConfigOptions): NavigationSection[] { const isCloudEnvironment = isCloud(); @@ -180,6 +183,18 @@ export function getNavigationConfig({ icon: Warehouse, active: isRouteActive(pathname, "/resources"), }, + ...(registryEligible + ? [ + { + kind: NAVIGATION_ITEM_KIND.LINK, + href: "/registry", + label: "Registry", + icon: Package, + active: isRouteActive(pathname, "/registry"), + highlight: true, + } as const, + ] + : []), ], }, { diff --git a/ui/components/providers/radio-group-provider.test.tsx b/ui/components/providers/radio-group-provider.test.tsx new file mode 100644 index 0000000000..34987d5a41 --- /dev/null +++ b/ui/components/providers/radio-group-provider.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; + +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; +import type { AddProviderFormValues } from "@/types/formSchemas"; + +import { RadioGroupProvider } from "./radio-group-provider"; + +function Selector({ + registryOptions = [{ type: "acme", label: "Acme Cloud" }], +}: { + registryOptions?: RegistryProviderOption[]; +}) { + const form = useForm(); + return ( + + ); +} + +describe("provider selector", () => { + it("preserves selected provider across tabs and supports searching by type", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + await user.type( + screen.getByRole("textbox", { name: "Search providers" }), + " ACME_SLUG ", + ); + + // Then + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + + // When + await user.click(screen.getByRole("button", { name: "Clear search" })); + await user.click(screen.getByRole("tab", { name: "All providers" })); + + // Then + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toHaveAttribute("aria-selected", "true"); + }); + + it("keeps both tabs available when no Registry providers are installed", async () => { + // Given + const user = userEvent.setup(); + const { rerender } = render(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + expect(screen.getByRole("tab", { name: "All providers" })).toBeEnabled(); + + // When / Then: discovery can refresh the installed options. + rerender(); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + rerender(); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + }); + + it("filters Registry providers and preserves search across tabs", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + expect( + screen.queryByRole("option", { name: /Amazon Web Services/ }), + ).not.toBeInTheDocument(); + + // When + await user.type( + screen.getByRole("textbox", { name: "Search providers" }), + "amazon", + ); + expect( + screen.getByText('No providers found matching "amazon"'), + ).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "All providers" })); + + // Then + expect( + screen.getByRole("textbox", { name: "Search providers" }), + ).toHaveValue("amazon"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect( + screen.queryByRole("option", { name: /Acme Cloud Registry/ }), + ).not.toBeInTheDocument(); + }); + + it("adds Registry-labelled providers alongside the incorporated options", () => { + render(); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + }); +}); diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index de8d941f3a..0da4bf2d83 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -2,117 +2,38 @@ import { FC, useState } from "react"; import { Control, Controller } from "react-hook-form"; -import { z } from "zod"; - -import { SearchInput } from "@/components/shadcn"; -import { FormMessage } from "@/components/shadcn/form"; -import { cn } from "@/lib/utils"; -import { addProviderFormSchema } from "@/types"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "../icons/providers-badge"; + ProviderTypeIcon, + PROVIDER_TYPE_DATA, +} from "@/components/icons/providers-badge/provider-type-icon"; +import { Badge, SearchInput } from "@/components/shadcn"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/shadcn/avatar"; +import { FormMessage } from "@/components/shadcn/form"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn/tabs/tabs"; +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; +import { cn } from "@/lib/utils"; +import type { AddProviderFormValues } from "@/types/formSchemas"; -const PROVIDERS = [ - { - value: "aws", - label: "Amazon Web Services", - badge: AWSProviderBadge, - }, - { - value: "gcp", - label: "Google Cloud Platform", - badge: GCPProviderBadge, - }, - { - value: "azure", - label: "Microsoft Azure", - badge: AzureProviderBadge, - }, - { - value: "m365", - label: "Microsoft 365", - badge: M365ProviderBadge, - }, - { - value: "mongodbatlas", - label: "MongoDB Atlas", - badge: MongoDBAtlasProviderBadge, - }, - { - value: "kubernetes", - label: "Kubernetes", - badge: KS8ProviderBadge, - }, - { - value: "github", - label: "GitHub", - badge: GitHubProviderBadge, - }, - { - value: "googleworkspace", - label: "Google Workspace", - badge: GoogleWorkspaceProviderBadge, - }, - { - value: "iac", - label: "Infrastructure as Code", - badge: IacProviderBadge, - }, - { - value: "image", - label: "Container Registry", - badge: ImageProviderBadge, - }, - { - value: "oraclecloud", - label: "Oracle Cloud Infrastructure", - badge: OracleCloudProviderBadge, - }, - { - value: "alibabacloud", - label: "Alibaba Cloud", - badge: AlibabaCloudProviderBadge, - }, - { - value: "cloudflare", - label: "Cloudflare", - badge: CloudflareProviderBadge, - }, - { - value: "openstack", - label: "OpenStack", - badge: OpenStackProviderBadge, - }, - { - value: "vercel", - label: "Vercel", - badge: VercelProviderBadge, - }, - { - value: "okta", - label: "Okta", - badge: OktaProviderBadge, - }, -] as const; +const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map( + ([value, { label }]) => ({ value, label }), +); + +const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const; +type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB]; interface RadioGroupProviderProps { - control: Control>; + control: Control; + registryOptions?: RegistryProviderOption[]; isInvalid: boolean; errorMessage?: string; } @@ -121,25 +42,53 @@ export const RadioGroupProvider: FC = ({ control, isInvalid, errorMessage, + registryOptions = [], }) => { const [searchTerm, setSearchTerm] = useState(""); + const [activeTab, setActiveTab] = useState(PROVIDER_TAB.ALL); + const options = [ + ...PROVIDERS.map((provider) => ({ + value: provider.value as string, + label: provider.label as string, + registry: false, + logoUrl: undefined as string | undefined, + })), + ...registryOptions.map((provider) => ({ + value: provider.type, + label: provider.label, + registry: true, + logoUrl: provider.logoUrl, + })), + ]; + const tabProviders = + activeTab === PROVIDER_TAB.REGISTRY + ? options.filter((provider) => provider.registry) + : options; const lowerSearch = searchTerm.trim().toLowerCase(); const filteredProviders = lowerSearch - ? PROVIDERS.filter( + ? tabProviders.filter( (provider) => provider.label.toLowerCase().includes(lowerSearch) || provider.value.toLowerCase().includes(lowerSearch), ) - : PROVIDERS; + : tabProviders; return ( ( -
-
+ setActiveTab(value as ProviderTab)} + > + + All providers + Registry + +
= ({ />
-
+
= ({ > {filteredProviders.length > 0 ? ( filteredProviders.map((provider) => { - const BadgeComponent = provider.badge; const isSelected = field.value === provider.value; return ( @@ -165,6 +113,7 @@ export const RadioGroupProvider: FC = ({ key={provider.value} type="button" role="option" + aria-label={`${provider.label}${provider.registry ? " Registry" : ""}`} aria-selected={isSelected} onClick={() => field.onChange(provider.value)} className={cn( @@ -183,28 +132,54 @@ export const RadioGroupProvider: FC = ({
- + {provider.registry ? ( + + + + + + + ) : ( + + )} {provider.label} + {provider.registry && ( + Registry + )}
); }) ) : (

- No providers found matching "{searchTerm}" + {lowerSearch ? ( + <>No providers found matching "{searchTerm}" + ) : ( + "No Registry providers available." + )}

)}
-
+ {errorMessage && ( {errorMessage} )} -
+ )} /> ); diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 78c59ac764..548dda2e82 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -251,7 +251,7 @@ export function getColumnProviders( entityId={provider.attributes.uid} nameAction={ provider.attributes.is_dynamic ? ( - Custom + Registry ) : undefined } /> diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index 5fa6f3f409..c65863abd2 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -1,3 +1,17 @@ +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions: vi + .fn() + .mockResolvedValue({ status: "access_denied" }), +})); +vi.mock("@/actions/providers/provider-schemas", () => ({ + getProviderSchemas: vi.fn(), +})); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials: vi.fn(), +})); import { Row } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -327,7 +341,7 @@ describe("DataTableRowActions", () => { expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); - it("allows rename/delete and operational actions for a dynamic provider but hides credential management", async () => { + it("allows credential editing and operational actions for a dynamic provider", async () => { // Given a dynamic provider outside the configurable set, with the advanced // schedule capability enabled (so Edit Scan Schedule can show). const user = userEvent.setup(); @@ -354,9 +368,9 @@ describe("DataTableRowActions", () => { expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("View Scan Jobs")).toBeInTheDocument(); expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument(); - // ...but credential management is hidden (no bespoke wizard for dynamic types) + // Existing dynamic accounts use the same wizard with schema-based credentials. expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); - expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); + expect(screen.getByText("Update Credentials")).toBeInTheDocument(); }); it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => { diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 1866a44f73..bcbbd8e583 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -52,7 +52,6 @@ import { OrgFlowType, } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; -import { isConfigurableProvider } from "@/types/providers"; import { isProvidersOrganizationRow, PROVIDERS_GROUP_KIND, @@ -355,8 +354,7 @@ export function DataTableRowActions({ const provider = isOrganizationRow ? null : rowData; const providerId = provider?.id ?? ""; const providerType = provider?.attributes.provider ?? ""; - // Only predefined providers can manage credentials from the UI - const canManageCredentials = isConfigurableProvider(providerType); + const canManageCredentials = Boolean(providerType); const providerUid = provider?.attributes.uid ?? ""; const providerAlias = provider?.attributes.alias ?? null; const providerSecretId = provider?.relationships.secret.data?.id ?? null; diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx new file mode 100644 index 0000000000..40414c3688 --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -0,0 +1,230 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Toaster } from "@/components/shadcn/toast/Toaster"; +import { resetToasts } from "@/components/shadcn/toast/use-toast"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; + +import { ProviderWizardModal } from "./provider-wizard-modal"; + +const { addRegistryProvider, getInstalledRegistryProviderOptions } = vi.hoisted( + () => ({ + addRegistryProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), + }), +); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), +})); +vi.mock("@/actions/providers/providers", () => ({ addProvider: vi.fn() })); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider, +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); +vi.mock( + "@/components/providers/workflow/forms", + async () => import("../workflow/forms/connect-account-form"), +); +vi.mock("@/hooks/use-scroll-hint", () => ({ + useScrollHint: () => ({ showScrollHint: false }), +})); +vi.mock("@/lib/tours/use-driver-tour", () => ({ + advanceActiveTour: vi.fn(), + endActiveTour: vi.fn(), +})); +vi.mock("./steps/credentials-step", () => ({ + CredentialsStep: () =>

Credential details

, +})); +vi.mock("./steps/test-connection-step", () => ({ + TestConnectionStep: () => null, +})); +vi.mock("./steps/launch-step", () => ({ LaunchStep: () => null })); +vi.mock("../organizations/azure-org-setup-form", () => ({ + AzureOrgSetupForm: () => null, +})); +vi.mock("../organizations/gcp-org-setup-form", () => ({ + GcpOrgSetupForm: () => null, +})); +vi.mock("../organizations/org-setup-form", () => ({ + OrgSetupForm: () => null, +})); +vi.mock("../organizations/org-account-selection", () => ({ + OrgAccountSelection: () => null, +})); +vi.mock("../organizations/org-launch-scan", () => ({ + OrgLaunchScan: () => null, +})); + +const createdAccount = { + data: { + id: "account", + attributes: { provider: "acme", uid: "acme-account", alias: null }, + }, +}; + +async function enterAccountDetails() { + const user = userEvent.setup(); + render( + <> + + + , + ); + await user.click( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ); + await user.type( + screen.getByRole("textbox", { name: "Provider UID" }), + "acme-account", + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(), + ); + return user; +} + +describe("provider wizard account creation", () => { + beforeEach(() => { + useProviderWizardStore.getState().reset(); + resetToasts(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [{ type: "acme", label: "Acme Cloud" }], + }); + }); + + it("shows progress, blocks repeat clicks, and advances after creation", async () => { + // Given + let resolveCreation!: (value: typeof createdAccount) => void; + addRegistryProvider.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreation = resolve; + }), + ); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + const pending = await screen.findByRole("button", { + name: "Creating provider...", + }); + expect(pending).toBeDisabled(); + expect(pending).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); + await user.dblClick(pending); + expect(addRegistryProvider).toHaveBeenCalledOnce(); + + // When / Then + await act(async () => resolveCreation(createdAccount)); + expect(await screen.findByText("Credential details")).toBeVisible(); + }); + + it("restores Next after a failed creation and retries the same account", async () => { + // Given + const failure = { errors: [{ detail: "Creation failed. Try again." }] }; + let resolveCreation!: (value: typeof failure) => void; + addRegistryProvider + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreation = resolve; + }), + ) + .mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + await screen.findByRole("button", { name: "Creating provider..." }); + await act(async () => resolveCreation(failure)); + + // Then + expect(await screen.findByText(failure.errors[0].detail)).toBeVisible(); + const next = screen.getByRole("button", { name: "Next" }); + await waitFor(() => expect(next).toBeEnabled()); + expect(next).not.toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "acme-account", + ); + + // When / Then + await user.click(next); + expect(await screen.findByText("Credential details")).toBeVisible(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + expect( + Object.fromEntries(addRegistryProvider.mock.calls[1][0]), + ).toMatchObject({ providerType: "acme", providerUid: "acme-account" }); + }); + + it("shows provider conflicts in the account step and allows retrying", async () => { + // Given + const detail = + "The artifact 'acme' is not installed on this deployment yet. Install it again and retry."; + addRegistryProvider + .mockResolvedValueOnce({ + errors: [ + { + status: "409", + detail, + source: { pointer: "/data/attributes/provider" }, + }, + ], + }) + .mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent(detail); + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "acme-account", + ); + const next = await screen.findByRole("button", { name: "Next" }); + await waitFor(() => expect(next).toBeEnabled()); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + + // When / Then: the provider becomes available and the same account retries. + await user.click(next); + expect(await screen.findByText("Credential details")).toBeVisible(); + expect(screen.queryByText(detail)).not.toBeInTheDocument(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + }); + + it("keeps native providers available during a Registry discovery error and retries", async () => { + // Given + getInstalledRegistryProviderOptions.mockRejectedValueOnce( + new Error("Unavailable"), + ); + const user = userEvent.setup(); + render(); + await screen.findByText("Registry providers could not be loaded"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Retry Registry providers" }), + ); + + // Then + expect( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ).toBeVisible(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index d39a491b17..fab1a4440f 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { ExternalLink, Info } from "lucide-react"; +import { ExternalLink, Info, Loader2 } from "lucide-react"; import { AzureOrgSetupForm } from "@/components/providers/organizations/azure-org-setup-form"; import { GcpOrgSetupForm } from "@/components/providers/organizations/gcp-org-setup-form"; @@ -405,7 +405,11 @@ export function ProviderWizardModal({ : "button" } form={resolvedFooterConfig.actionFormId} - disabled={resolvedFooterConfig.actionDisabled} + disabled={ + resolvedFooterConfig.actionDisabled || + resolvedFooterConfig.actionLoading + } + aria-busy={resolvedFooterConfig.actionLoading || undefined} onClick={ resolvedFooterConfig.actionType === WIZARD_FOOTER_ACTION_TYPE.BUTTON @@ -413,6 +417,9 @@ export function ProviderWizardModal({ : undefined } > + {resolvedFooterConfig.actionLoading && ( + + )} {resolvedFooterConfig.actionLabel} )} diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx index a26013aba3..649f0d1562 100644 --- a/ui/components/providers/wizard/steps/connect-step.tsx +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -63,6 +63,7 @@ export function ConnectStep({ onBack: () => backHandlerRef.current?.(), showAction: uiState.showAction, actionLabel: uiState.actionLabel, + actionLoading: uiState.isLoading, actionDisabled: uiState.actionDisabled || uiState.isLoading, actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, actionFormId: formId, diff --git a/ui/components/providers/wizard/steps/credentials-step.test.tsx b/ui/components/providers/wizard/steps/credentials-step.test.tsx index 7f4610b212..8fa035ea33 100644 --- a/ui/components/providers/wizard/steps/credentials-step.test.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.test.tsx @@ -1,3 +1,6 @@ +vi.mock("./dynamic-credentials-step", () => ({ + DynamicCredentialsStep: () =>
dynamic-credentials-form
, +})); import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx index 371f356d30..75836a25e3 100644 --- a/ui/components/providers/wizard/steps/credentials-step.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { getProviderFormType } from "@/lib/provider-helpers"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { ProviderType } from "@/types/providers"; +import { isKnownProviderType, ProviderType } from "@/types/providers"; import { AddViaCredentialsForm, @@ -23,6 +23,7 @@ import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/gi import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365"; import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form"; +import { DynamicCredentialsStep } from "./dynamic-credentials-step"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, @@ -34,7 +35,22 @@ interface CredentialsStepProps { onFooterChange: (config: WizardFooterConfig) => void; } -export function CredentialsStep({ +export function CredentialsStep(props: CredentialsStepProps) { + const providerId = useProviderWizardStore((state) => state.providerId); + const providerType = useProviderWizardStore((state) => state.providerType); + if (providerId && providerType && !isKnownProviderType(providerType)) { + return ( + + ); + } + return ; +} + +function BuiltinCredentialsStep({ onNext, onBack, onFooterChange, diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx new file mode 100644 index 0000000000..ce28a075b1 --- /dev/null +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx @@ -0,0 +1,365 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json"; +import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ProviderSchemasResult } from "@/types/provider-schema"; + +const { getProviderSchemas, saveDynamicProviderCredentials, toast } = + vi.hoisted(() => ({ + getProviderSchemas: vi.fn(), + saveDynamicProviderCredentials: vi.fn(), + toast: vi.fn(), + })); +vi.mock("@/actions/providers/provider-schemas", () => ({ getProviderSchemas })); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials, +})); +vi.mock("@/components/shadcn/toast", () => ({ useToast: () => ({ toast }) })); + +import { DynamicCredentialsStep } from "./dynamic-credentials-step"; + +beforeAll(() => { + for (const method of [ + "hasPointerCapture", + "setPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, method, { + configurable: true, + value: vi.fn(() => false), + }); + } +}); + +const props = { + providerId: "account", + providerType: "acme", + onNext: vi.fn(), + onBack: vi.fn(), + onFooterChange: vi.fn(), +}; +const schema = { + type: "object", + description: openaiSchema.description, + properties: { + token: { + type: "string", + title: "API token", + format: "password", + writeOnly: true, + }, + }, + required: ["token"], +}; + +describe("dynamic credentials in the provider wizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + useProviderWizardStore.getState().reset(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { api_key: schema }, + }); + saveDynamicProviderCredentials.mockResolvedValue({ + status: "saved", + secretId: "secret", + }); + }); + it("saves through the dynamic action and never persists entered secrets", async () => { + render(); + const field = await screen.findByLabelText(/API token/); + fireEvent.change(field, { target: { value: "only-in-memory" } }); + expect(JSON.stringify(sessionStorage)).not.toContain("only-in-memory"); + expect(JSON.stringify(localStorage)).not.toContain("only-in-memory"); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "only-in-memory" }, + }); + expect(useProviderWizardStore.getState().secretId).toBe("secret"); + expect(field).toHaveValue(""); + }); + it("masks the OpenAI API key and submits the original credential values", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "openai", + secretTypes: { api_key: openaiSchema }, + }); + render(); + const apiKey = await screen.findByLabelText(/Platform Api Key/); + const organization = screen.getByLabelText(/Organization Id/); + const baseUrl = screen.getByLabelText(/Base Url/); + + // When + await user.type(organization, "org-fixture"); + await user.type(apiKey, "fixture-key-not-a-secret"); + + // Then + expect(apiKey).toHaveAttribute("type", "password"); + expect(apiKey).toHaveAttribute("autocomplete", "new-password"); + expect(organization).toHaveAttribute("type", "text"); + expect(baseUrl).toHaveAttribute("type", "text"); + expect( + screen.queryByRole("button", { name: /show|reveal/i }), + ).not.toBeInTheDocument(); + + // When / Then: this form submits from the wizard's external footer. + act(() => apiKey.closest("form")!.requestSubmit()); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { + organization_id: "org-fixture", + platform_api_key: "fixture-key-not-a-secret", + base_url: "https://api.openai.com/v1", + }, + }); + }); + it("renders and submits the installed Template credential form with typed values", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "template", + secretTypes: { static: templateSchema }, + }); + render(); + const apiUrl = await screen.findByLabelText(/API URL/); + const apiKey = screen.getByLabelText(/API Key/); + const verifyTls = screen.getByRole("checkbox", { name: "Verify TLS" }); + const timeout = screen.getByRole("spinbutton", { name: "Timeout" }); + + // Then + expect(apiUrl).toHaveAttribute("placeholder", "https://api.acme.com"); + expect(apiKey).toHaveAttribute("type", "password"); + expect(screen.getByLabelText("CA Bundle").tagName).toBe("TEXTAREA"); + expect(verifyTls).toBeChecked(); + expect(timeout).toHaveValue(30); + expect(timeout).toHaveAttribute("min", "1"); + expect(timeout).toHaveAttribute("max", "300"); + expect(timeout).toHaveAttribute("step", "1"); + expect( + screen.getByRole("combobox", { name: "Authentication Scheme" }), + ).toHaveTextContent("bearer"); + expect(apiUrl).toHaveValue(""); + + // When: false must remain a boolean and numeric input must become a number. + await user.type(apiUrl, "https://api.example.test"); + await user.type(apiKey, "fixture-key-not-a-secret"); + await user.click(verifyTls); + await user.clear(timeout); + await user.type(timeout, "60"); + act(() => apiKey.closest("form")!.requestSubmit()); + + // Then + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "static", + secret: { + api_url: "https://api.example.test", + api_key: "fixture-key-not-a-secret", + verify_tls: false, + timeout_seconds: 60, + auth_scheme: "bearer", + }, + }); + }); + it.each<{ result: ProviderSchemasResult; title: string }>([ + { + result: { status: "success", providerType: "acme", secretTypes: {} }, + title: "Credential form unavailable", + }, + { + result: { + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + properties: { nested: { type: "object" } }, + }, + }, + }, + title: "Credential form not supported", + }, + { + result: { status: "access_denied" }, + title: "Access required", + }, + { + result: { status: "unavailable" }, + title: "Provider installation unavailable", + }, + ])( + "explains $title without allowing credential submission", + async ({ result, title }) => { + getProviderSchemas.mockResolvedValue(result); + render(); + expect( + await screen.findByRole("button", { name: "Try again" }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent(title); + expect(screen.queryByLabelText(/API token/)).not.toBeInTheDocument(); + expect(saveDynamicProviderCredentials).not.toHaveBeenCalled(); + }, + ); + it("explains a loading failure and recovers when retried", async () => { + // Given + getProviderSchemas.mockRejectedValueOnce(new Error("Network unavailable")); + const user = userEvent.setup(); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load credential form", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Check your connection and try again.", + ); + expect(screen.getByRole("link", { name: "Open Registry" })).toHaveAttribute( + "href", + "/registry", + ); + + // When + await user.click(screen.getByRole("button", { name: "Try again" })); + + // Then + expect(await screen.findByLabelText(/API token/)).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("clears credentials when changing providers", async () => { + const view = render(); + fireEvent.change(await screen.findByLabelText(/API token/), { + target: { value: "previous-secret" }, + }); + view.rerender( + , + ); + await waitFor(() => + expect(screen.getByLabelText(/API token/)).toHaveValue(""), + ); + }); + it("clears credentials when switching authentication methods", async () => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { api_key: schema, personal_token: schema }, + }); + render(); + fireEvent.change(await screen.findByLabelText(/API token/), { + target: { value: "previous-method-secret" }, + }); + const user = userEvent.setup(); + await user.click( + screen.getByRole("combobox", { name: "Authentication method" }), + ); + await user.click(screen.getByRole("option", { name: "personal token" })); + expect(screen.getByLabelText(/API token/)).toHaveValue(""); + expect(JSON.stringify(sessionStorage)).not.toContain( + "previous-method-secret", + ); + expect(JSON.stringify(localStorage)).not.toContain( + "previous-method-secret", + ); + }); + it("rejects double submission and retries a failed save for the same account", async () => { + let rejectSave!: (error: Error) => void; + saveDynamicProviderCredentials.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + render(); + const field = await screen.findByLabelText(/API token/); + fireEvent.change(field, { target: { value: "retry-secret" } }); + fireEvent.submit(field.closest("form")!); + fireEvent.submit(field.closest("form")!); + expect(saveDynamicProviderCredentials).toHaveBeenCalledOnce(); + rejectSave(new Error("Network unavailable")); + await screen.findByText( + "Could not save the credentials. Check your connection and retry.", + ); + expect(props.onNext).not.toHaveBeenCalled(); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledTimes(2); + expect(saveDynamicProviderCredentials).toHaveBeenLastCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "retry-secret" }, + }); + }); + it("keeps other field and form errors visible while editing one credential", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + ...schema, + properties: { + ...schema.properties, + project: { type: "string", title: "Project" }, + }, + required: ["token", "project"], + }, + }, + }); + saveDynamicProviderCredentials.mockResolvedValueOnce({ + status: "invalid", + errors: { + token: "Token was rejected", + project: "Project is unavailable", + _form: "Review the credential fields", + }, + }); + render(); + const token = await screen.findByLabelText(/API token/); + await user.type(token, "fixture-token"); + await user.type(screen.getByLabelText(/Project/), "fixture-project"); + act(() => token.closest("form")!.requestSubmit()); + expect(await screen.findByText("Token was rejected")).toBeVisible(); + + // When + await user.type(token, "-edited"); + + // Then + expect(screen.queryByText("Token was rejected")).not.toBeInTheDocument(); + expect(screen.getByText("Project is unavailable")).toBeVisible(); + expect(screen.getByText("Review the credential fields")).toBeVisible(); + + // When / Then: submitting again replaces the earlier validation errors. + act(() => token.closest("form")!.requestSubmit()); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect( + screen.queryByText("Project is unavailable"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Review the credential fields"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx new file mode 100644 index 0000000000..35fc5f0daa --- /dev/null +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +import { saveDynamicProviderCredentials } from "@/actions/providers/dynamic-provider-credentials"; +import { getProviderSchemas } from "@/actions/providers/provider-schemas"; +import { RegistryCredentialFields } from "@/components/providers/workflow/provider-credential-fields"; +import { Button } from "@/components/shadcn/button/button"; +import { Field, FieldLabel } from "@/components/shadcn/field/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { useToast } from "@/components/shadcn/toast"; +import { StatusAlert } from "@/components/shared/status-alert"; +import { + parseRegistryCredentialSchema, + type RegistryCredentialSchema, +} from "@/lib/provider-credentials/provider-credential-schema"; +import { + getCredentialDefaults, + validateCredentialValues, +} from "@/lib/provider-credentials/provider-credential-values"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ProviderSchemasResult } from "@/types/provider-schema"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + type WizardFooterConfig, +} from "./footer-controls"; + +interface DynamicCredentialsStepProps { + providerId: string; + providerType: string; + onNext: () => void; + onBack: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +function credentialFormError(status: ProviderSchemasResult["status"]) { + switch (status) { + case "access_denied": + return { + title: "Access required", + description: + "Your session may have expired or you may not have permission. Sign in again or contact your administrator.", + }; + case "unavailable": + return { + title: "Provider installation unavailable", + description: + "Install this provider's artifact again in Registry, then try again.", + }; + case "not_found": + return { + title: "Credential form unavailable", + description: + "This provider does not provide a credential form. Contact its publisher or your administrator.", + }; + case "success": + case "malformed": + return { + title: "Credential form not supported", + description: + "We could not display this provider's credential form. Contact its publisher or your administrator.", + }; + default: + return { + title: "Could not load credential form", + description: "Check your connection and try again.", + }; + } +} + +function DynamicCredentialForm({ + providerId, + secretType, + schema, + onNext, + onBack, + onFooterChange, + onLoadingChange, +}: Omit & { + secretType: string; + schema: RegistryCredentialSchema; + onLoadingChange: (value: boolean) => void; +}) { + const { toast } = useToast(); + const setSecretId = useProviderWizardStore((state) => state.setSecretId); + // Credentials belong only to this form. A new account or authentication + // method mounts a fresh instance; no values enter the persisted wizard store. + const [values, setValues] = useState(() => getCredentialDefaults(schema)); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const inFlight = useRef(false); + const mounted = useRef(true); + const formId = "provider-wizard-dynamic-credentials-form"; + const valid = validateCredentialValues(schema, values).valid; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + onFooterChange({ + showBack: true, + backLabel: "Back", + backDisabled: saving, + onBack, + showAction: true, + actionLabel: "Authenticate", + actionDisabled: saving || !valid, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + }, [onBack, onFooterChange, saving, valid]); + + return ( +
{ + event.preventDefault(); + if (inFlight.current) return; + const validation = validateCredentialValues(schema, values); + setErrors(validation.errors); + if (!validation.valid) return; + inFlight.current = true; + setSaving(true); + onLoadingChange(true); + try { + const result = await saveDynamicProviderCredentials({ + providerId, + secretType, + secret: validation.secret, + }); + if (!mounted.current) return; + if (result.status === "saved") { + setValues({}); + setSecretId(result.secretId); + toast({ + title: "Credentials saved", + description: "Test the provider connection to continue.", + }); + onNext(); + } else if (result.status === "invalid") { + setErrors(result.errors); + } else { + const description = + result.status === "schema_unavailable" + ? "The credential schema is unavailable. Check the installed artifact in Registry and reload the form." + : result.status === "access_denied" + ? "You no longer have permission to update these credentials. Contact an administrator." + : "Check your credentials and try again. Your provider account is already created."; + setErrors({ _form: description }); + toast({ + variant: "destructive", + title: "Credentials could not be saved", + description, + }); + } + } catch { + if (mounted.current) { + const description = + "Could not save the credentials. Check your connection and retry."; + setErrors({ _form: description }); + toast({ + variant: "destructive", + title: "Credentials could not be saved", + description, + }); + } + } finally { + inFlight.current = false; + if (mounted.current) { + setSaving(false); + onLoadingChange(false); + } + } + }} + > +
+ {errors._form && ( + + {errors._form} + + )} + { + setValues((current) => ({ ...current, [name]: value })); + setErrors((current) => { + const next = { ...current }; + delete next[name]; + return next; + }); + }} + /> +
+
+ ); +} + +function DynamicCredentialsContent(props: DynamicCredentialsStepProps) { + const { providerType, onBack, onFooterChange } = props; + const [schemas, setSchemas] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(""); + const [attempt, setAttempt] = useState(0); + const [saving, setSaving] = useState(false); + useEffect(() => { + let active = true; + setSchemas(null); + setSelectedMethod(""); + getProviderSchemas(providerType) + .then((result) => { + if (active) setSchemas(result); + }) + .catch(() => { + if (active) setSchemas({ status: "error" }); + }); + return () => { + active = false; + }; + }, [providerType, attempt]); + + const methods = + schemas?.status === "success" ? Object.keys(schemas.secretTypes) : []; + const secretType = selectedMethod || methods[0]; + const schema = + schemas?.status === "success" && secretType + ? parseRegistryCredentialSchema(schemas.secretTypes[secretType]) + : null; + useEffect(() => { + if (!schema) + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: false, + actionLabel: "Authenticate", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + }); + }, [schema, onBack, onFooterChange]); + + if (!schemas) + return ( +
+ + +
+ ); + + const error = credentialFormError( + schemas.status === "success" && methods.length === 0 + ? "not_found" + : schemas.status, + ); + + return ( +
+ {methods.length > 1 && ( + + + Authentication method + + + + )} + {schema ? ( + + ) : ( +
+ + {error.description} + +
+ + +
+
+ )} +
+ ); +} + +export function DynamicCredentialsStep(props: DynamicCredentialsStepProps) { + return ( + + ); +} diff --git a/ui/components/providers/wizard/steps/footer-controls.ts b/ui/components/providers/wizard/steps/footer-controls.ts index ff41ae8061..7bdd0d5d58 100644 --- a/ui/components/providers/wizard/steps/footer-controls.ts +++ b/ui/components/providers/wizard/steps/footer-controls.ts @@ -22,6 +22,7 @@ export interface WizardFooterConfig { onSecondaryAction?: () => void; showAction: boolean; actionLabel: string; + actionLoading?: boolean; actionDisabled?: boolean; actionType: WizardFooterActionType; actionFormId?: string; diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx new file mode 100644 index 0000000000..7e74064f8e --- /dev/null +++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { addProvider, updateProvider, getInstalledRegistryProviderOptions } = + vi.hoisted(() => ({ + addProvider: vi.fn(), + updateProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), + })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("@/actions/providers/providers", () => ({ + addProvider, + updateProvider, +})); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); + +import { ConnectAccountForm } from "./connect-account-form"; + +describe("provider account aliases", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "access_denied", + }); + }); + + it("saves an alias changed after an account was already created", async () => { + // Given + const onSuccess = vi.fn(); + const user = userEvent.setup(); + const account = { + id: "existing", + attributes: { provider: "github", uid: "octocat", alias: "Original" }, + }; + addProvider.mockResolvedValue({ data: account }); + updateProvider.mockResolvedValue({ + data: { + ...account, + attributes: { ...account.attributes, alias: "Edited" }, + }, + }); + render(); + await user.click(screen.getByRole("option", { name: "GitHub" })); + await user.type( + screen.getByRole("textbox", { name: "Username/Organization" }), + "octocat", + ); + const alias = screen.getByRole("textbox", { + name: "Provider alias (optional)", + }); + await user.type(alias, "Original"); + await user.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + + // When + await user.clear(alias); + await user.type(alias, "Edited"); + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + await waitFor(() => + expect(onSuccess).toHaveBeenLastCalledWith({ + id: "existing", + providerType: "github", + uid: "octocat", + alias: "Edited", + }), + ); + expect(addProvider).toHaveBeenCalledOnce(); + expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toMatchObject({ + providerId: "existing", + providerAlias: "Edited", + }); + }); +}); diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index a46de4871d..2ccd6531ff 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -3,20 +3,25 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useForm, UseFormReturn } from "react-hook-form"; -import { z } from "zod"; -import { addProvider } from "@/actions/providers/providers"; +import { addProvider, updateProvider } from "@/actions/providers/providers"; +import { addRegistryProvider } from "@/actions/providers/registry-provider"; +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector"; import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector"; import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector"; import { WizardInputField } from "@/components/providers/workflow/forms/fields"; import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs"; import { Button, useToast } from "@/components/shadcn"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert"; import { Form } from "@/components/shadcn/form"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; import { - addProviderFormSchema, + createAddProviderFormSchema, + AddProviderFormValues, ApiError, KnownProviderType, ProviderType, @@ -26,10 +31,11 @@ import { OrgFlowType, toOrgFlowType, } from "@/types/organizations"; +import { isKnownProviderType } from "@/types/providers"; import { RadioGroupProvider } from "../../radio-group-provider"; -export type FormValues = z.infer; +export type FormValues = AddProviderFormValues; export interface ConnectAccountSuccessData { id: string; @@ -209,7 +215,41 @@ export const ConnectAccountForm = ({ const [method, setMethod] = useState<"single" | null>(null); const router = useRouter(); - const formSchema = addProviderFormSchema; + const [registryOptions, setRegistryOptions] = useState< + RegistryProviderOption[] + >([]); + const [registryError, setRegistryError] = useState(false); + const [providerError, setProviderError] = useState(null); + const [discoveryAttempt, setDiscoveryAttempt] = useState(0); + const submitting = useRef(false); + const createdAccount = useRef(null); + + useEffect(() => { + let active = true; + const load = async () => { + try { + const result = await getInstalledRegistryProviderOptions(); + if (!active) return; + setRegistryOptions(result.status === "ready" ? result.options : []); + setRegistryError(result.status === "error"); + } catch { + if (active) { + setRegistryOptions([]); + setRegistryError(true); + } + } + }; + void load(); + window.addEventListener("registry-artifacts-changed", load); + return () => { + active = false; + window.removeEventListener("registry-artifacts-changed", load); + }; + }, [discoveryAttempt]); + + const formSchema = createAddProviderFormSchema( + registryOptions.map((option) => option.type), + ); const form = useForm({ resolver: zodResolver(formSchema), @@ -229,6 +269,22 @@ export const ConnectAccountForm = ({ const isLoading = form.formState.isSubmitting; const onSubmitClient = async (values: FormValues) => { + if (submitting.current) return; + const existingAccount = + createdAccount.current?.providerType === values.providerType && + createdAccount.current.uid === values.providerUid + ? createdAccount.current + : null; + if ( + existingAccount && + (existingAccount.alias ?? "") === (values.providerAlias?.trim() ?? "") && + onSuccess + ) { + onSuccess(existingAccount); + return; + } + submitting.current = true; + setProviderError(null); const formValues = { ...values }; const formData = new FormData(); @@ -237,7 +293,20 @@ export const ConnectAccountForm = ({ ); try { - const data = await addProvider(formData); + let data; + if (existingAccount) { + const update = new FormData(); + update.set(ProviderCredentialFields.PROVIDER_ID, existingAccount.id); + update.set( + ProviderCredentialFields.PROVIDER_ALIAS, + values.providerAlias?.trim() ?? "", + ); + data = await updateProvider(update); + } else { + data = await (isKnownProviderType(values.providerType) + ? addProvider(formData) + : addRegistryProvider(formData)); + } if (data?.errors && data.errors.length > 0) { data.errors.forEach((error: ApiError) => { @@ -246,10 +315,9 @@ export const ConnectAccountForm = ({ switch (pointer) { case "/data/attributes/provider": - form.setError("providerType", { - type: "server", - message: errorMessage, - }); + // Provider selection is hidden here; keep failures visible and + // retryable when availability changes without editing the form. + setProviderError(errorMessage); break; case "/data/attributes/uid": case "/data/attributes/__all__": @@ -280,12 +348,13 @@ export const ConnectAccountForm = ({ } = data.data; if (onSuccess) { - onSuccess({ + createdAccount.current = { id, providerType: createdProviderType, uid: uid || values.providerUid, alias: alias ?? values.providerAlias ?? null, - }); + }; + onSuccess(createdAccount.current); return; } @@ -301,10 +370,13 @@ export const ConnectAccountForm = ({ ? error.message : "Something went wrong. Please try again.", }); + } finally { + submitting.current = false; } }; const handleBackStep = () => { + setProviderError(null); applyBackStep({ prevStep, method, @@ -327,6 +399,7 @@ export const ConnectAccountForm = ({ useEffect(() => { onBackHandlerChange?.(() => { + setProviderError(null); applyBackStep({ prevStep, method, @@ -352,7 +425,7 @@ export const ConnectAccountForm = ({ onUiStateChange?.({ showBack: prevStep === 2, showAction: prevStep === 2 && showUidForm, - actionLabel: "Next", + actionLabel: isLoading ? "Creating provider..." : "Next", actionDisabled: !canSubmit || isLoading, isLoading, }); @@ -375,7 +448,26 @@ export const ConnectAccountForm = ({ {/* Step 1: Provider selection */} {prevStep === 1 && (
+ {registryError && ( + + Registry providers could not be loaded + + Built-in providers are available. Check the Registry + connection and try again. + + + + )} + {providerError && ( + + Unable to create provider + {providerError} + + )} {isLoading ? ( - + ) : ( )} - {isLoading ? "Loading" : "Next"} + {isLoading ? "Creating provider..." : "Next"} )}
diff --git a/ui/components/providers/workflow/provider-credential-fields.test.tsx b/ui/components/providers/workflow/provider-credential-fields.test.tsx new file mode 100644 index 0000000000..6925925f23 --- /dev/null +++ b/ui/components/providers/workflow/provider-credential-fields.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import type { RegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema"; + +import { RegistryCredentialFields } from "./provider-credential-fields"; + +const schema: RegistryCredentialSchema = { + fields: [ + { + name: "api_key", + label: "API Key", + description: "Issued from the console.", + kind: "password", + required: true, + }, + { + name: "scheme", + label: "Scheme", + kind: "select", + options: ["bearer", "basic"], + required: false, + }, + { name: "notes", label: "Notes", kind: "textarea", required: false }, + ], +}; + +beforeAll(() => { + for (const name of [ + "hasPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value: () => false, + }); + } +}); + +describe("RegistryCredentialFields", () => { + it("renders accessible controlled credential fields and emits changes", async () => { + // Given + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // When + await user.type(screen.getByLabelText(/API Key/), "x"); + await user.click(screen.getByRole("combobox", { name: "Scheme" })); + await user.keyboard("{ArrowDown}{Enter}"); + + // Then + const apiKey = screen.getByLabelText(/API Key/); + const description = screen.getByText("Issued from the console."); + const error = screen.getByRole("alert"); + expect(apiKey).toHaveAttribute("type", "password"); + expect(apiKey).toHaveAttribute("autocomplete", "new-password"); + + expect(apiKey).toHaveAttribute( + "aria-describedby", + `${description.id} ${error.id}`, + ); + expect(apiKey.id).toMatch(/-0-control$/); + expect(apiKey).toHaveAttribute("aria-invalid", "true"); + expect(apiKey).toBeRequired(); + expect(description.id).toMatch(/-0-description$/); + expect(error).toHaveTextContent("A key is required."); + expect(error.id).toMatch(/-0-error$/); + expect(onChange).toHaveBeenCalledWith("api_key", "x"); + expect(onChange).toHaveBeenCalledWith("scheme", "basic"); + }); + + it("uses unique index-based IDs for hostile field names and instances", () => { + // Given + + const hostileSchema: RegistryCredentialSchema = { + fields: [ + { + name: "x-description", + label: "First", + kind: "text", + required: false, + }, + { + name: "registry-credential-x", + label: "Second", + description: "Second description.", + kind: "text", + required: false, + }, + ], + }; + + const { container } = render( + <> + + + + , + ); + + // When / Then + expect(screen.getByLabelText("First").id).toMatch(/-0-control$/); + + expect(screen.getByText("Second description.").id).toMatch( + /-1-description$/, + ); + const ids = Array.from(container.querySelectorAll("[id]"), ({ id }) => id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/ui/components/providers/workflow/provider-credential-fields.tsx b/ui/components/providers/workflow/provider-credential-fields.tsx new file mode 100644 index 0000000000..157f4a0d7e --- /dev/null +++ b/ui/components/providers/workflow/provider-credential-fields.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { type ChangeEvent, useId } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; +import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field"; +import { Input } from "@/components/shadcn/input/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import type { + RegistryCredentialSchema, + RegistryCredentialValue, +} from "@/lib/provider-credentials/provider-credential-schema"; + +interface RegistryCredentialFieldsProps { + readonly errors: Readonly>; + readonly onChange: (name: string, value: RegistryCredentialValue) => void; + readonly schema: RegistryCredentialSchema; + readonly values: Readonly< + Record + >; +} + +export function RegistryCredentialFields({ + errors, + onChange, + schema, + values, +}: RegistryCredentialFieldsProps) { + const instanceId = useId(); + + return ( +
+ {schema.fields.map((field, index) => { + const error = errors[field.name]; + const fieldId = `registry-credential-${instanceId}-${index}`; + const id = `${fieldId}-control`; + const descriptionId = field.description + ? `${fieldId}-description` + : undefined; + const errorId = error ? `${fieldId}-error` : undefined; + const describedBy = + [descriptionId, errorId].filter(Boolean).join(" ") || undefined; + const invalid = error ? true : undefined; + const value = values[field.name]; + const textControlProps = { + "aria-describedby": describedBy, + "aria-invalid": invalid, + id, + + onChange: ( + event: ChangeEvent, + ) => onChange(field.name, event.target.value), + required: field.required, + placeholder: field.placeholder, + spellCheck: false, + value: + typeof value === "string" || typeof value === "number" ? value : "", + }; + + return ( + + {field.kind === "checkbox" ? ( +
+ + onChange(field.name, checked === true) + } + /> + + {field.label} + {field.required && } + +
+ ) : ( + + {field.label} + {field.required && } + + )} + {field.kind === "checkbox" ? null : field.kind === "select" ? ( + + ) : field.kind === "textarea" ? ( +