mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): complete Registry provider onboarding for Private Cloud (#12494)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
974f4251dd
commit
2198ba2d84
@@ -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/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({
|
||||
|
||||
import { createNewUser, getUserByMe } from "./auth";
|
||||
|
||||
const userMeResponse = (roleAttributes: Record<string, boolean>) => ({
|
||||
const userMeResponse = (roleAttributes: Record<string, unknown>) => ({
|
||||
data: {
|
||||
type: "users",
|
||||
id: "019b1234-5678-7abc-9def-0123456789ab",
|
||||
@@ -43,7 +43,7 @@ const userMeResponse = (roleAttributes: Record<string, boolean>) => ({
|
||||
],
|
||||
});
|
||||
|
||||
const mockUserMe = (roleAttributes: Record<string, boolean>) => {
|
||||
const mockUserMe = (roleAttributes: Record<string, unknown>) => {
|
||||
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 });
|
||||
|
||||
+9
-60
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> }
|
||||
| { status: "access_denied" | "schema_unavailable" | "error" };
|
||||
|
||||
export async function saveDynamicProviderCredentials(
|
||||
input: unknown,
|
||||
): Promise<DynamicCredentialsResult> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./provider-schemas";
|
||||
export * from "./providers";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<ProviderSchemasResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("<html>key=private</html>", { 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<string, unknown> = {},
|
||||
) => ({ 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<RegistryEndpoint>([
|
||||
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<RegistryTaskSubmissionResult> =>
|
||||
parseRegistryTaskSubmission(response);
|
||||
|
||||
export const parseRegistryArtifactSubmission = (
|
||||
response: Response,
|
||||
): Promise<RegistryTaskSubmissionResult> =>
|
||||
parseRegistryTaskSubmission(response);
|
||||
|
||||
async function parseRegistryTaskSubmission(
|
||||
response: Response,
|
||||
): Promise<RegistryTaskSubmissionResult> {
|
||||
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<Extract<RegistryMutationResult, { status: "refused" }> | 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<RegistryFailureResult> {
|
||||
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<unknown>;
|
||||
|
||||
export async function collectCompleteRegistryCatalog(
|
||||
fetchPage: RegistryCatalogPageFetcher,
|
||||
): Promise<RegistryCatalogResult> {
|
||||
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<string, RegistryCatalogArtifact>();
|
||||
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<typeof catalogAttributesSchema>,
|
||||
): 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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string | null> {
|
||||
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<Response | RegistryFailureResult> {
|
||||
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<RegistryMutationResult> {
|
||||
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<RegistryBootstrapResult> {
|
||||
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<RegistryCredentialReadResult> {
|
||||
const access = await getRegistryAccess();
|
||||
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
return readRegistryCredential(access);
|
||||
}
|
||||
|
||||
export async function refreshRegistryCollections(): Promise<RegistryCollectionsResult> {
|
||||
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<RegistryMutationResult> {
|
||||
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<RegistryMutationResult> {
|
||||
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<RegistryArtifactRemovalResult> {
|
||||
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<RegistryCredentialSubmitResult> {
|
||||
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<RegistryCredentialActionResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<html suppressHydrationWarning lang="en">
|
||||
<head>
|
||||
@@ -98,7 +111,9 @@ export default async function RootLayout({
|
||||
<NavigationProgress />
|
||||
</Suspense>
|
||||
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
|
||||
<StoreInitializer values={{ hasProviders: hasProviders ?? false }} />
|
||||
<StoreInitializer
|
||||
values={{ hasProviders: hasProviders ?? false, registryEligible }}
|
||||
/>
|
||||
{cloudEnabled && (
|
||||
<>
|
||||
<OnboardingGate hasProviders={hasProviders} />
|
||||
|
||||
@@ -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 (
|
||||
<ContentLayout title="Registry" icon="lucide:package">
|
||||
<RegistryExplorer
|
||||
initialState={bootstrap.state}
|
||||
registryKeyUrl={
|
||||
getRegistryPresentation(readEnv("UI_REGISTRY_URL")).keyUrl
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof jwtCallback>[0]["account"],
|
||||
user: {
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
} as Parameters<typeof jwtCallback>[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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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(<AppSidebarContent />);
|
||||
|
||||
// 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(<AppSidebarContent />);
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<AddProviderFormValues>();
|
||||
return (
|
||||
<RadioGroupProvider
|
||||
control={form.control}
|
||||
isInvalid={false}
|
||||
registryOptions={registryOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("provider selector", () => {
|
||||
it("preserves selected provider across tabs and supports searching by type", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Selector
|
||||
registryOptions={[{ type: "acme_slug", label: "Acme Cloud" }]}
|
||||
/>,
|
||||
);
|
||||
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(<Selector registryOptions={[]} />);
|
||||
|
||||
// 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(<Selector />);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Acme Cloud Registry/ }),
|
||||
).toBeVisible();
|
||||
rerender(<Selector registryOptions={[]} />);
|
||||
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(<Selector />);
|
||||
|
||||
// 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(<Selector />);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Acme Cloud Registry/ }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Amazon Web Services/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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<z.infer<typeof addProviderFormSchema>>;
|
||||
control: Control<AddProviderFormValues>;
|
||||
registryOptions?: RegistryProviderOption[];
|
||||
isInvalid: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -121,25 +42,53 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
control,
|
||||
isInvalid,
|
||||
errorMessage,
|
||||
registryOptions = [],
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ProviderTab>(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 (
|
||||
<Controller
|
||||
name="providerType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col px-4">
|
||||
<div className="relative z-10 shrink-0 pb-4">
|
||||
<Tabs
|
||||
className="flex flex-col px-4"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as ProviderTab)}
|
||||
>
|
||||
<TabsList aria-label="Provider source">
|
||||
<TabsTrigger value={PROVIDER_TAB.ALL}>All providers</TabsTrigger>
|
||||
<TabsTrigger value={PROVIDER_TAB.REGISTRY}>Registry</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="relative z-10 shrink-0 py-4">
|
||||
<SearchInput
|
||||
aria-label="Search providers"
|
||||
placeholder="Search providers..."
|
||||
@@ -149,7 +98,7 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<TabsContent value={activeTab}>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select a provider"
|
||||
@@ -157,7 +106,6 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
>
|
||||
{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<RadioGroupProviderProps> = ({
|
||||
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<RadioGroupProviderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<BadgeComponent size={26} />
|
||||
{provider.registry ? (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={
|
||||
provider.logoUrl?.startsWith("https://")
|
||||
? provider.logoUrl
|
||||
: undefined
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<ProviderTypeIcon
|
||||
type={provider.value}
|
||||
size={26}
|
||||
/>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<ProviderTypeIcon type={provider.value} size={26} />
|
||||
)}
|
||||
<span className="text-text-neutral-primary text-sm leading-6">
|
||||
{provider.label}
|
||||
</span>
|
||||
{provider.registry && (
|
||||
<Badge variant="tag">Registry</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary py-4 text-sm">
|
||||
No providers found matching "{searchTerm}"
|
||||
{lowerSearch ? (
|
||||
<>No providers found matching "{searchTerm}"</>
|
||||
) : (
|
||||
"No Registry providers available."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{errorMessage && (
|
||||
<FormMessage className="text-text-error-primary">
|
||||
{errorMessage}
|
||||
</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -251,7 +251,7 @@ export function getColumnProviders(
|
||||
entityId={provider.attributes.uid}
|
||||
nameAction={
|
||||
provider.attributes.is_dynamic ? (
|
||||
<Badge variant="info">Custom</Badge>
|
||||
<Badge variant="info">Registry</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => <p>Credential details</p>,
|
||||
}));
|
||||
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(
|
||||
<>
|
||||
<ProviderWizardModal open onOpenChange={vi.fn()} />
|
||||
<Toaster />
|
||||
</>,
|
||||
);
|
||||
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(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
<Loader2 aria-hidden className="animate-spin" />
|
||||
)}
|
||||
{resolvedFooterConfig.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
vi.mock("./dynamic-credentials-step", () => ({
|
||||
DynamicCredentialsStep: () => <div>dynamic-credentials-form</div>,
|
||||
}));
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<DynamicCredentialsStep
|
||||
{...props}
|
||||
providerId={providerId}
|
||||
providerType={providerType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <BuiltinCredentialsStep {...props} />;
|
||||
}
|
||||
|
||||
function BuiltinCredentialsStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
|
||||
@@ -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(<DynamicCredentialsStep {...props} />);
|
||||
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(<DynamicCredentialsStep {...props} providerType="openai" />);
|
||||
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(<DynamicCredentialsStep {...props} providerType="template" />);
|
||||
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(<DynamicCredentialsStep {...props} />);
|
||||
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(<DynamicCredentialsStep {...props} />);
|
||||
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(<DynamicCredentialsStep {...props} />);
|
||||
fireEvent.change(await screen.findByLabelText(/API token/), {
|
||||
target: { value: "previous-secret" },
|
||||
});
|
||||
view.rerender(
|
||||
<DynamicCredentialsStep
|
||||
{...props}
|
||||
providerId="other"
|
||||
providerType="other"
|
||||
/>,
|
||||
);
|
||||
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(<DynamicCredentialsStep {...props} />);
|
||||
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(<DynamicCredentialsStep {...props} />);
|
||||
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(<DynamicCredentialsStep {...props} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<DynamicCredentialsStepProps, "providerType"> & {
|
||||
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<Record<string, string>>({});
|
||||
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 (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={async (event) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={saving} className="flex flex-col gap-4">
|
||||
{errors._form && (
|
||||
<StatusAlert variant="error" title="Credentials could not be saved">
|
||||
{errors._form}
|
||||
</StatusAlert>
|
||||
)}
|
||||
<RegistryCredentialFields
|
||||
schema={schema}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={(name, value) => {
|
||||
setValues((current) => ({ ...current, [name]: value }));
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicCredentialsContent(props: DynamicCredentialsStepProps) {
|
||||
const { providerType, onBack, onFooterChange } = props;
|
||||
const [schemas, setSchemas] = useState<ProviderSchemasResult | null>(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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading credential schema"
|
||||
className="space-y-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const error = credentialFormError(
|
||||
schemas.status === "success" && methods.length === 0
|
||||
? "not_found"
|
||||
: schemas.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{methods.length > 1 && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="registry-auth-method">
|
||||
Authentication method
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={secretType}
|
||||
disabled={saving}
|
||||
onValueChange={setSelectedMethod}
|
||||
>
|
||||
<SelectTrigger id="registry-auth-method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{methods.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{method.replaceAll("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
{schema ? (
|
||||
<DynamicCredentialForm
|
||||
key={`${secretType}/${attempt}`}
|
||||
{...props}
|
||||
secretType={secretType}
|
||||
schema={schema}
|
||||
onLoadingChange={setSaving}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<StatusAlert variant="error" title={error.title}>
|
||||
{error.description}
|
||||
</StatusAlert>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
<RotateCcw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
<Button variant="link" asChild>
|
||||
<Link href="/registry">Open Registry</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicCredentialsStep(props: DynamicCredentialsStepProps) {
|
||||
return (
|
||||
<DynamicCredentialsContent
|
||||
key={`${props.providerId}/${props.providerType}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface WizardFooterConfig {
|
||||
onSecondaryAction?: () => void;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionLoading?: boolean;
|
||||
actionDisabled?: boolean;
|
||||
actionType: WizardFooterActionType;
|
||||
actionFormId?: string;
|
||||
|
||||
@@ -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(<ConnectAccountForm onSuccess={onSuccess} />);
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof addProviderFormSchema>;
|
||||
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<string | null>(null);
|
||||
const [discoveryAttempt, setDiscoveryAttempt] = useState(0);
|
||||
const submitting = useRef(false);
|
||||
const createdAccount = useRef<ConnectAccountSuccessData | null>(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<FormValues>({
|
||||
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 && (
|
||||
<div data-tour-id="add-provider-provider-type">
|
||||
{registryError && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Registry providers could not be loaded</AlertTitle>
|
||||
<AlertDescription>
|
||||
Built-in providers are available. Check the Registry
|
||||
connection and try again.
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDiscoveryAttempt((attempt) => attempt + 1)
|
||||
}
|
||||
>
|
||||
Retry Registry providers
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<RadioGroupProvider
|
||||
registryOptions={registryOptions}
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.providerType}
|
||||
errorMessage={form.formState.errors.providerType?.message}
|
||||
@@ -423,6 +515,12 @@ export const ConnectAccountForm = ({
|
||||
{prevStep === 2 && showUidForm && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
{providerError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Unable to create provider</AlertTitle>
|
||||
<AlertDescription>{providerError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<WizardInputField
|
||||
control={form.control}
|
||||
name="providerUid"
|
||||
@@ -465,13 +563,14 @@ export const ConnectAccountForm = ({
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading || undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
<Loader2 aria-hidden className="animate-spin" />
|
||||
) : (
|
||||
<ChevronRightIcon size={24} />
|
||||
)}
|
||||
{isLoading ? "Loading" : "Next"}
|
||||
{isLoading ? "Creating provider..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<RegistryCredentialFields
|
||||
errors={{ api_key: "A key is required." }}
|
||||
onChange={onChange}
|
||||
schema={schema}
|
||||
values={{ api_key: "", scheme: "bearer", notes: "" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<>
|
||||
<RegistryCredentialFields
|
||||
errors={{}}
|
||||
onChange={vi.fn()}
|
||||
schema={hostileSchema}
|
||||
values={{}}
|
||||
/>
|
||||
<RegistryCredentialFields
|
||||
errors={{}}
|
||||
onChange={vi.fn()}
|
||||
schema={schema}
|
||||
values={{}}
|
||||
/>
|
||||
<RegistryCredentialFields
|
||||
errors={{}}
|
||||
onChange={vi.fn()}
|
||||
schema={schema}
|
||||
values={{}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string | undefined>>;
|
||||
readonly onChange: (name: string, value: RegistryCredentialValue) => void;
|
||||
readonly schema: RegistryCredentialSchema;
|
||||
readonly values: Readonly<
|
||||
Record<string, RegistryCredentialValue | undefined>
|
||||
>;
|
||||
}
|
||||
|
||||
export function RegistryCredentialFields({
|
||||
errors,
|
||||
onChange,
|
||||
schema,
|
||||
values,
|
||||
}: RegistryCredentialFieldsProps) {
|
||||
const instanceId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{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<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => onChange(field.name, event.target.value),
|
||||
required: field.required,
|
||||
placeholder: field.placeholder,
|
||||
spellCheck: false,
|
||||
value:
|
||||
typeof value === "string" || typeof value === "number" ? value : "",
|
||||
};
|
||||
|
||||
return (
|
||||
<Field key={field.name}>
|
||||
{field.kind === "checkbox" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
aria-required={field.required}
|
||||
checked={value === true}
|
||||
id={id}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(field.name, checked === true)
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor={id}>
|
||||
{field.label}
|
||||
{field.required && <span aria-hidden="true"> *</span>}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
) : (
|
||||
<FieldLabel htmlFor={id}>
|
||||
{field.label}
|
||||
{field.required && <span aria-hidden="true"> *</span>}
|
||||
</FieldLabel>
|
||||
)}
|
||||
{field.kind === "checkbox" ? null : field.kind === "select" ? (
|
||||
<Select
|
||||
onValueChange={(nextValue) => onChange(field.name, nextValue)}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
aria-label={field.label}
|
||||
aria-required={field.required}
|
||||
id={id}
|
||||
>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.kind === "textarea" ? (
|
||||
<Textarea autoComplete="off" {...textControlProps} />
|
||||
) : (
|
||||
<Input
|
||||
autoComplete={
|
||||
field.kind === "password" ? "new-password" : "off"
|
||||
}
|
||||
type={
|
||||
field.kind === "integer"
|
||||
? "number"
|
||||
: field.kind === "password"
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
min={field.minimum}
|
||||
max={field.maximum}
|
||||
step={field.kind === "integer" ? 1 : undefined}
|
||||
{...textControlProps}
|
||||
/>
|
||||
)}
|
||||
{field.description && (
|
||||
<p
|
||||
className="text-text-neutral-secondary text-sm"
|
||||
id={descriptionId}
|
||||
>
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<FieldError id={errorId} role="alert">
|
||||
{error}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, type RefObject, useEffect, useRef } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { DialogFooter } from "@/components/shadcn/dialog";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import { Modal } from "@/components/shadcn/modal/modal";
|
||||
|
||||
interface RegistryAccessDialogCommonProps {
|
||||
errorMessage?: string;
|
||||
registryKeyUrl?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (key: string) => Promise<void>;
|
||||
open: boolean;
|
||||
pending: boolean;
|
||||
returnFocusRef: RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
type ConnectRegistryAccessDialogProps = RegistryAccessDialogCommonProps & {
|
||||
mode: "connect";
|
||||
onDisconnect?: never;
|
||||
};
|
||||
|
||||
type ManageRegistryAccessDialogProps = RegistryAccessDialogCommonProps & {
|
||||
mode: "manage";
|
||||
onDisconnect: () => Promise<void>;
|
||||
};
|
||||
|
||||
type RegistryAccessDialogProps =
|
||||
| ConnectRegistryAccessDialogProps
|
||||
| ManageRegistryAccessDialogProps;
|
||||
|
||||
export function RegistryAccessDialog({
|
||||
errorMessage,
|
||||
registryKeyUrl,
|
||||
mode,
|
||||
onDisconnect,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
open,
|
||||
pending,
|
||||
returnFocusRef,
|
||||
}: RegistryAccessDialogProps) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const keyInputRef = useRef<HTMLInputElement>(null);
|
||||
const wasPendingRef = useRef(pending);
|
||||
const actionLabel = mode === "connect" ? "Connect" : "Replace key";
|
||||
|
||||
// Re-enabling the form after a watched validation settles loses focus from
|
||||
// the disabled input; hand it back so a retry can start from the keyboard.
|
||||
useEffect(() => {
|
||||
if (wasPendingRef.current && !pending) keyInputRef.current?.focus();
|
||||
wasPendingRef.current = pending;
|
||||
}, [pending]);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const key = new FormData(event.currentTarget).get("registry-key");
|
||||
if (typeof key !== "string" || key.trim().length === 0) return;
|
||||
|
||||
formRef.current?.reset();
|
||||
await onSubmit(key.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
description="Your Registry API key links this workspace to the Prowler artifact registry. It is validated asynchronously and never stored by this browser."
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
keyInputRef.current?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
returnFocusRef.current?.focus();
|
||||
}}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
size="md"
|
||||
title={
|
||||
mode === "connect"
|
||||
? "Connect Registry API key"
|
||||
: "Manage Registry access"
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-2 text-sm" htmlFor="registry-key">
|
||||
<span>Registry key</span>
|
||||
<Input
|
||||
aria-describedby={errorMessage ? "registry-key-error" : undefined}
|
||||
autoComplete="new-password"
|
||||
disabled={pending}
|
||||
id="registry-key"
|
||||
name="registry-key"
|
||||
ref={keyInputRef}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
{errorMessage && (
|
||||
<p
|
||||
className="text-text-error-primary text-sm"
|
||||
id="registry-key-error"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
{registryKeyUrl && (
|
||||
<Button
|
||||
asChild
|
||||
className="self-start"
|
||||
size="link-sm"
|
||||
variant="link"
|
||||
>
|
||||
<a
|
||||
href={registryKeyUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Where do I find my key?
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter
|
||||
className={mode === "manage" ? "sm:justify-between" : undefined}
|
||||
>
|
||||
{mode === "manage" && (
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={onDisconnect}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={pending} type="submit">
|
||||
{pending ? "Connecting…" : actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { page, userEvent } from "vitest/browser";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import {
|
||||
RegistryArtifactCard,
|
||||
RegistryTenantArtifactCard,
|
||||
} from "./registry-artifact-card";
|
||||
import { RegistryArtifactGrid } from "./registry-artifact-grid";
|
||||
import type { RegistryMarketplaceArtifact } from "./registry-explorer.model";
|
||||
|
||||
const artifact: RegistryMarketplaceArtifact = {
|
||||
normalizedName: "prowler-provider-aws",
|
||||
name: "AWS security",
|
||||
description: "Security checks and compliance frameworks for AWS resources.",
|
||||
latestVersion: "5.15.0",
|
||||
providers: ["aws"],
|
||||
isVerified: true,
|
||||
isOfficial: true,
|
||||
isBuiltin: true,
|
||||
isMeta: false,
|
||||
hasProvider: true,
|
||||
hasChecks: true,
|
||||
hasCompliance: true,
|
||||
checkCount: 645,
|
||||
complianceCount: 45,
|
||||
versionCount: 1,
|
||||
totalDownloads: 0,
|
||||
owners: [{ name: "Prowler", type: "organization" }],
|
||||
isAdded: false,
|
||||
updateAvailable: false,
|
||||
};
|
||||
|
||||
describe("Registry card metadata layout", () => {
|
||||
it("keeps Added when the installed version is unknown", async () => {
|
||||
// Given / When
|
||||
const screen = await render(
|
||||
<RegistryArtifactCard
|
||||
artifact={{ ...artifact, isBuiltin: false, isAdded: true }}
|
||||
onAdd={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Then
|
||||
await expect
|
||||
.element(screen.getByText("Added", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByText("Unknown", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: /Update/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
it("offers Update with installed and available versions instead of Added", async () => {
|
||||
// Given
|
||||
const onAdd = vi.fn();
|
||||
const screen = await render(
|
||||
<RegistryArtifactCard
|
||||
artifact={{
|
||||
...artifact,
|
||||
isBuiltin: false,
|
||||
isAdded: true,
|
||||
resolvedVersion: "1.0.0",
|
||||
updateAvailable: true,
|
||||
}}
|
||||
onAdd={onAdd}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// When
|
||||
await screen
|
||||
.getByRole("button", { name: "Update AWS security to 5.15.0" })
|
||||
.click();
|
||||
// Then
|
||||
expect(onAdd).toHaveBeenCalledOnce();
|
||||
await expect
|
||||
.element(screen.getByText("Added", { exact: true }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Installed", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByText("1.0.0", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByText("Available", { exact: true }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
localStorage.removeItem("theme");
|
||||
await page.viewport(1280, 800);
|
||||
});
|
||||
|
||||
it("identifies each provider logo on hover and keyboard focus", async () => {
|
||||
// Given
|
||||
const screen = await render(
|
||||
<RegistryArtifactCard
|
||||
artifact={{ ...artifact, providers: ["aws", "gcp", "template"] }}
|
||||
onAdd={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When / Then: each visible provider uses its own display name.
|
||||
for (const name of ["AWS", "Google Cloud", "Template"]) {
|
||||
const logo = screen.getByRole("img", { name, exact: true });
|
||||
await logo.hover();
|
||||
await expect.element(screen.getByRole("tooltip")).toHaveTextContent(name);
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await expect.element(screen.getByRole("tooltip")).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
// When / Then: keyboard users can discover the same names.
|
||||
await userEvent.tab();
|
||||
await expect
|
||||
.element(screen.getByRole("img", { name: "AWS", exact: true }))
|
||||
.toHaveFocus();
|
||||
await expect.element(screen.getByRole("tooltip")).toHaveTextContent("AWS");
|
||||
await userEvent.tab();
|
||||
await expect
|
||||
.element(screen.getByRole("img", { name: "Google Cloud", exact: true }))
|
||||
.toHaveFocus();
|
||||
await expect
|
||||
.element(screen.getByRole("tooltip"))
|
||||
.toHaveTextContent("Google Cloud");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ width: 320, theme: "dark" },
|
||||
{ width: 768, theme: "dark" },
|
||||
{ width: 1440, theme: "dark" },
|
||||
{ width: 320, theme: "light" },
|
||||
{ width: 1440, theme: "light" },
|
||||
])(
|
||||
"contains long metadata at $width px in $theme mode",
|
||||
async ({ width, theme }) => {
|
||||
// Given: the real grid includes normal, long, and catalog-less cards.
|
||||
await page.viewport(width, 1000);
|
||||
localStorage.setItem("theme", theme);
|
||||
const longVersion =
|
||||
"2026.123456789.123456789-preview.0123456789abcdef0123456789abcdef";
|
||||
const onAdd = vi.fn();
|
||||
const screen = await render(
|
||||
<main className="p-4">
|
||||
<RegistryArtifactGrid isEmpty={false} emptyMessage="No artifacts">
|
||||
<li>
|
||||
<RegistryArtifactCard
|
||||
artifact={artifact}
|
||||
onAdd={onAdd}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<RegistryArtifactCard
|
||||
artifact={{
|
||||
...artifact,
|
||||
name: "Long metadata",
|
||||
normalizedName: "long-metadata",
|
||||
latestVersion: longVersion,
|
||||
resolvedVersion: `${longVersion}-previous`,
|
||||
isAdded: true,
|
||||
updateAvailable: true,
|
||||
checkCount: Number.MAX_SAFE_INTEGER,
|
||||
complianceCount: 123456789,
|
||||
totalDownloads: 9876543210,
|
||||
isBuiltin: false,
|
||||
owners: [],
|
||||
}}
|
||||
onAdd={onAdd}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<RegistryTenantArtifactCard
|
||||
normalizedName="Catalog unavailable"
|
||||
resolvedVersion={longVersion}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
</li>
|
||||
</RegistryArtifactGrid>
|
||||
</main>,
|
||||
);
|
||||
|
||||
// Then: values stay complete, contained, and grouped in each card's footer.
|
||||
const metadataBlocks = screen.getByRole("group", {
|
||||
name: "Artifact metadata",
|
||||
});
|
||||
await expect.element(metadataBlocks.nth(2)).toBeVisible();
|
||||
await expect
|
||||
.element(
|
||||
metadataBlocks
|
||||
.nth(1)
|
||||
.getByText("9,007,199,254,740,991", { exact: true }),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(
|
||||
metadataBlocks.nth(1).getByText("9,876,543,210", { exact: true }),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(metadataBlocks.nth(2).getByText(longVersion, { exact: true }))
|
||||
.toBeVisible();
|
||||
for (const item of screen.getByRole("listitem").elements()) {
|
||||
const card = item.querySelector('[data-slot="card"]')!;
|
||||
const metadata = item.querySelector("dl")!;
|
||||
const bounds = card.getBoundingClientRect();
|
||||
expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth);
|
||||
for (const value of Array.from(metadata.querySelectorAll("dt, dd"))) {
|
||||
expect(value.scrollWidth).toBeLessThanOrEqual(value.clientWidth);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(value);
|
||||
for (const line of Array.from(range.getClientRects())) {
|
||||
expect(line.left).toBeGreaterThanOrEqual(bounds.left);
|
||||
expect(line.right).toBeLessThanOrEqual(bounds.right);
|
||||
}
|
||||
}
|
||||
const description = item.querySelector("p");
|
||||
expect(metadata.getBoundingClientRect().top).toBeGreaterThan(
|
||||
description!.getBoundingClientRect().bottom,
|
||||
);
|
||||
}
|
||||
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
|
||||
|
||||
// When / Then: wrapping does not obstruct the card action.
|
||||
await screen
|
||||
.getByRole("button", { name: `Update Long metadata to ${longVersion}` })
|
||||
.click();
|
||||
expect(onAdd).toHaveBeenCalledOnce();
|
||||
await screen.getByRole("main").screenshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
Download,
|
||||
ListChecks,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from "@/components/shadcn/avatar/avatar";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getProviderDisplayName, isKnownProviderType } from "@/types/providers";
|
||||
import type { RegistryArtifactOwner } from "@/types/registry";
|
||||
|
||||
import {
|
||||
REGISTRY_CAPABILITY_LABELS,
|
||||
type RegistryMarketplaceArtifact,
|
||||
} from "./registry-explorer.model";
|
||||
|
||||
interface RegistryArtifactCardProps {
|
||||
artifact: RegistryMarketplaceArtifact;
|
||||
pendingAddName?: string;
|
||||
onAdd: () => void;
|
||||
onRemove: (trigger: HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
function capabilitySummary(artifact: RegistryMarketplaceArtifact) {
|
||||
const labels = [
|
||||
artifact.hasProvider && REGISTRY_CAPABILITY_LABELS.provider,
|
||||
artifact.hasChecks && REGISTRY_CAPABILITY_LABELS.checks,
|
||||
artifact.hasCompliance && REGISTRY_CAPABILITY_LABELS.compliance,
|
||||
].filter((label) => label !== false);
|
||||
return labels.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum provider logos rendered in the footer cluster before collapsing
|
||||
* the remainder into a "+N" overflow badge (registry.dev card reference).
|
||||
*/
|
||||
const MAX_PROVIDER_LOGOS = 4;
|
||||
|
||||
interface RegistryProviderClusterProps {
|
||||
providers: string[];
|
||||
}
|
||||
|
||||
function RegistryProviderCluster({ providers }: RegistryProviderClusterProps) {
|
||||
if (providers.length === 0) return null;
|
||||
|
||||
const displayNames = providers.map(getProviderDisplayName);
|
||||
const visibleProviders = providers.slice(0, MAX_PROVIDER_LOGOS);
|
||||
const overflowCount = providers.length - visibleProviders.length;
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* Icons alone must never be the only carrier of the provider names. */}
|
||||
<span className="sr-only">
|
||||
{providers.length === 1
|
||||
? `Provider: ${displayNames[0]}`
|
||||
: `Providers: ${displayNames.join(", ")}`}
|
||||
</span>
|
||||
{providers.length > 1 && (
|
||||
<span aria-hidden className="text-text-neutral-secondary text-xs">
|
||||
{providers.length} providers
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
{visibleProviders.map((provider) => (
|
||||
<Tooltip key={provider} delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={getProviderDisplayName(provider)}
|
||||
tabIndex={0}
|
||||
className="focus-visible:outline-button-primary inline-flex shrink-0 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
{isKnownProviderType(provider) ? (
|
||||
<ProviderTypeIcon size={16} type={provider} />
|
||||
) : (
|
||||
<Badge size="sm" variant="tag">
|
||||
{getProviderDisplayName(provider)}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{getProviderDisplayName(provider)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
{overflowCount > 0 && (
|
||||
<span aria-hidden className="text-text-neutral-secondary text-xs">
|
||||
+{overflowCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface RegistryOwnerRowProps {
|
||||
isOfficial: boolean;
|
||||
isVerified: boolean;
|
||||
owner?: RegistryArtifactOwner;
|
||||
}
|
||||
|
||||
function RegistryOwnerRow({
|
||||
isOfficial,
|
||||
isVerified,
|
||||
owner,
|
||||
}: RegistryOwnerRowProps) {
|
||||
if (!owner && !isOfficial && !isVerified) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{owner &&
|
||||
(owner.name.trim().toLowerCase() === "prowler" ? (
|
||||
<ProwlerShort aria-label="Prowler" role="img" size={20} />
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Avatar aria-hidden className="size-5">
|
||||
<AvatarImage alt="" src={owner.logoUrl} />
|
||||
<AvatarFallback>{owner.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-text-neutral-secondary truncate text-xs">
|
||||
{owner.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{isOfficial && (
|
||||
<Badge variant="tag">
|
||||
<ShieldCheck aria-hidden />
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
{isVerified && (
|
||||
<Badge variant="success">
|
||||
<BadgeCheck aria-hidden />
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RegistryArtifactMetadataProps {
|
||||
complianceCount?: number;
|
||||
checkCount?: number;
|
||||
version?: string;
|
||||
isAdded?: boolean;
|
||||
availableVersion?: string;
|
||||
downloads?: number;
|
||||
}
|
||||
|
||||
function RegistryArtifactMetadata({
|
||||
complianceCount,
|
||||
checkCount,
|
||||
version,
|
||||
isAdded,
|
||||
availableVersion,
|
||||
downloads,
|
||||
}: RegistryArtifactMetadataProps) {
|
||||
const items = [
|
||||
{
|
||||
label: REGISTRY_CAPABILITY_LABELS.compliance,
|
||||
value: complianceCount,
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
label: REGISTRY_CAPABILITY_LABELS.checks,
|
||||
value: checkCount,
|
||||
icon: ListChecks,
|
||||
},
|
||||
{ label: isAdded ? "Installed" : "Version", value: version, icon: Tag },
|
||||
{ label: "Available", value: availableVersion, icon: Tag },
|
||||
{ label: "Downloads", value: downloads, icon: Download },
|
||||
].filter(({ value }) => value !== undefined && value !== "");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<dl
|
||||
role="group"
|
||||
aria-label="Artifact metadata"
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary grid grid-cols-1 gap-x-4 gap-y-3 border-t pt-3",
|
||||
items.length > 1 && "grid-cols-2",
|
||||
items.length === 3 && "@sm:grid-cols-3",
|
||||
items.length === 4 && "@sm:grid-cols-4",
|
||||
items.length === 5 && "@sm:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{items.map(({ label, value, icon: Icon }) => (
|
||||
<div key={label} className="min-w-0 space-y-1">
|
||||
<dt className="text-text-neutral-secondary flex items-center gap-1.5 text-xs">
|
||||
<Icon
|
||||
aria-hidden
|
||||
className="text-text-neutral-tertiary size-3.5 shrink-0"
|
||||
/>
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"text-text-neutral-primary text-sm leading-5 font-medium wrap-anywhere tabular-nums",
|
||||
Icon === Tag && "font-mono",
|
||||
)}
|
||||
>
|
||||
{typeof value === "number"
|
||||
? value.toLocaleString("en-US")
|
||||
: value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegistryArtifactCard({
|
||||
artifact,
|
||||
pendingAddName,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: RegistryArtifactCardProps) {
|
||||
const displayName = artifact.name ?? artifact.normalizedName;
|
||||
const subtitle = [
|
||||
artifact.providers.map(getProviderDisplayName).join(", "),
|
||||
capabilitySummary(artifact),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3" padding="md" variant="inner">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-bg-neutral-tertiary text-text-neutral-secondary flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
>
|
||||
{/* Artifacts can span several providers, so the header shows a
|
||||
neutral package mark instead of any single provider logo. */}
|
||||
<Package size={26} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-text-neutral-primary truncate text-sm font-semibold">
|
||||
{displayName}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-text-neutral-secondary truncate text-xs">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{artifact.description && (
|
||||
<p className="text-text-neutral-secondary line-clamp-2 text-sm">
|
||||
{artifact.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto space-y-3">
|
||||
<RegistryOwnerRow
|
||||
isOfficial={artifact.isOfficial}
|
||||
isVerified={artifact.isVerified}
|
||||
owner={artifact.owners[0]}
|
||||
/>
|
||||
<RegistryArtifactMetadata
|
||||
complianceCount={artifact.complianceCount}
|
||||
checkCount={artifact.checkCount}
|
||||
version={
|
||||
artifact.isAdded
|
||||
? artifact.resolvedVersion || "Unknown"
|
||||
: artifact.latestVersion
|
||||
}
|
||||
isAdded={artifact.isAdded}
|
||||
availableVersion={
|
||||
artifact.isAdded &&
|
||||
artifact.latestVersion !== artifact.resolvedVersion
|
||||
? artifact.latestVersion
|
||||
: undefined
|
||||
}
|
||||
downloads={artifact.isBuiltin ? undefined : artifact.totalDownloads}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<RegistryProviderCluster providers={artifact.providers} />
|
||||
<span className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
{artifact.isBuiltin && (
|
||||
<Badge aria-label="Built in" role="status" variant="tag">
|
||||
Built in
|
||||
</Badge>
|
||||
)}
|
||||
{artifact.isAdded ? (
|
||||
<>
|
||||
{artifact.updateAvailable ? (
|
||||
<Button
|
||||
aria-label={`Update ${displayName} to ${artifact.latestVersion}`}
|
||||
disabled={Boolean(pendingAddName)}
|
||||
onClick={onAdd}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{pendingAddName === artifact.normalizedName
|
||||
? "Updating…"
|
||||
: "Update"}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Check aria-hidden />
|
||||
Added
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
aria-label={`Remove ${displayName}`}
|
||||
disabled={pendingAddName === artifact.normalizedName}
|
||||
onClick={(event) => onRemove(event.currentTarget)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : isRegistryArtifactInstallable(artifact) ? (
|
||||
<Button
|
||||
aria-label={`Add ${displayName}`}
|
||||
disabled={Boolean(pendingAddName)}
|
||||
onClick={onAdd}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{pendingAddName === artifact.normalizedName ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface RegistryTenantArtifactCardProps {
|
||||
normalizedName: string;
|
||||
onRemove: (trigger: HTMLButtonElement | null) => void;
|
||||
resolvedVersion?: string;
|
||||
}
|
||||
|
||||
export function RegistryTenantArtifactCard({
|
||||
normalizedName,
|
||||
onRemove,
|
||||
resolvedVersion,
|
||||
}: RegistryTenantArtifactCardProps) {
|
||||
return (
|
||||
<Card className="h-full gap-3" padding="md" variant="inner">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-bg-neutral-tertiary text-text-neutral-secondary flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
>
|
||||
{/* Tenant artifacts carry no provider metadata; the neutral package
|
||||
mark matches the marketplace card header. */}
|
||||
<Package size={26} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-text-neutral-primary truncate text-sm font-semibold">
|
||||
{normalizedName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Installed in this workspace. Catalog metadata is not available for this
|
||||
artifact.
|
||||
</p>
|
||||
<div className="mt-auto space-y-3">
|
||||
<RegistryArtifactMetadata
|
||||
isAdded
|
||||
version={resolvedVersion || "Unknown"}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
aria-label={`Remove ${normalizedName}`}
|
||||
onClick={(event) => onRemove(event.currentTarget)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PackageSearch } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
interface RegistryArtifactGridProps {
|
||||
children: ReactNode;
|
||||
emptyMessage: string;
|
||||
isEmpty: boolean;
|
||||
emptyDescription?: string;
|
||||
emptyActionLabel?: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function RegistryArtifactGrid({
|
||||
children,
|
||||
emptyMessage,
|
||||
isEmpty,
|
||||
onReset,
|
||||
emptyDescription = "Try another search or clear your filters to explore the catalog.",
|
||||
emptyActionLabel = "Clear filters",
|
||||
}: RegistryArtifactGridProps) {
|
||||
if (isEmpty)
|
||||
return (
|
||||
<Card variant="base">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<PackageSearch
|
||||
aria-hidden
|
||||
className="text-text-neutral-secondary size-10"
|
||||
/>
|
||||
<h2 className="text-text-neutral-primary text-lg font-semibold">
|
||||
{emptyMessage}
|
||||
</h2>
|
||||
<p className="text-text-neutral-secondary max-w-prose text-sm">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
{onReset && (
|
||||
<Button variant="outline" onClick={onReset}>
|
||||
{emptyActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Toast, ToastProvider, ToastViewport } from "@/components/shadcn/toast";
|
||||
import type { WatchedTask } from "@/store/task-watcher/store";
|
||||
|
||||
const { confirmRegistryArtifactAddition, toast } = vi.hoisted(() => ({
|
||||
confirmRegistryArtifactAddition: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
addRegistryArtifact: vi.fn(),
|
||||
confirmRegistryArtifactAddition,
|
||||
}));
|
||||
vi.mock("@/components/shadcn/toast", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/shadcn/toast")>()),
|
||||
toast,
|
||||
}));
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
trackAndPollTask: vi.fn(),
|
||||
TASK_WATCHER_STATUS: { READY: "ready" },
|
||||
}));
|
||||
|
||||
import { registryArtifactTaskHandler } from "./registry-artifact-task-handler";
|
||||
|
||||
const task: WatchedTask = {
|
||||
taskId: "installation-task",
|
||||
kind: "registry-artifact-add",
|
||||
status: "ready",
|
||||
startedAt: Date.now(),
|
||||
meta: { normalizedName: "acme-provider" },
|
||||
result: { installed: true, error: null },
|
||||
};
|
||||
|
||||
describe("resumed Registry installations", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("resumes an update with its expected version and announces one update", async () => {
|
||||
// Given
|
||||
confirmRegistryArtifactAddition.mockResolvedValue({
|
||||
status: "confirmed",
|
||||
tenantArtifacts: [],
|
||||
});
|
||||
// When
|
||||
await registryArtifactTaskHandler.onReady({
|
||||
...task,
|
||||
meta: { ...task.meta, operation: "update", expectedVersion: "2.0.0" },
|
||||
});
|
||||
// Then
|
||||
expect(confirmRegistryArtifactAddition).toHaveBeenCalledWith(
|
||||
"acme-provider",
|
||||
"2.0.0",
|
||||
);
|
||||
expect(toast).toHaveBeenCalledOnce();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Artifact updated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["refresh_failed", "error"])(
|
||||
"announces an update failure for %s after reload",
|
||||
async (status) => {
|
||||
// Given
|
||||
confirmRegistryArtifactAddition.mockResolvedValue({ status });
|
||||
// When
|
||||
await registryArtifactTaskHandler.onReady({
|
||||
...task,
|
||||
meta: {
|
||||
...task.meta,
|
||||
operation: "update",
|
||||
expectedVersion: "2.0.0",
|
||||
},
|
||||
});
|
||||
// Then
|
||||
expect(toast).toHaveBeenCalledOnce();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Artifact could not be updated",
|
||||
variant: "destructive",
|
||||
...(status === "refresh_failed"
|
||||
? {
|
||||
description:
|
||||
"Update could not be confirmed. Refresh Registry before retrying.",
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("never confirms an update with missing persisted target metadata", async () => {
|
||||
// Given / When
|
||||
await registryArtifactTaskHandler.onReady({
|
||||
...task,
|
||||
meta: { ...task.meta, operation: "update" },
|
||||
});
|
||||
// Then
|
||||
expect(confirmRegistryArtifactAddition).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Artifact could not be updated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for membership confirmation before one success notification and selector refresh", async () => {
|
||||
let confirm!: (value: unknown) => void;
|
||||
confirmRegistryArtifactAddition.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
confirm = resolve;
|
||||
}),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("registry-artifacts-changed", listener);
|
||||
const completion = registryArtifactTaskHandler.onReady(task);
|
||||
expect(confirmRegistryArtifactAddition).toHaveBeenCalledWith(
|
||||
"acme-provider",
|
||||
);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
const tenantArtifacts = {
|
||||
artifacts: [{ normalizedName: "acme-provider" }],
|
||||
};
|
||||
confirm({ status: "confirmed", tenantArtifacts });
|
||||
await completion;
|
||||
expect(toast).toHaveBeenCalledOnce();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Artifact added" }),
|
||||
);
|
||||
expect(toast.mock.calls[0][0]).not.toHaveProperty("description");
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Toast open>{toast.mock.calls[0][0].action}</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Go to Providers" }),
|
||||
).toHaveAttribute("href", "/providers");
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener.mock.calls[0][0].detail).toEqual(tenantArtifacts);
|
||||
window.removeEventListener("registry-artifacts-changed", listener);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ installed: false, error: "Installation rejected" },
|
||||
{ installed: true, error: "Partial failure" },
|
||||
undefined,
|
||||
])(
|
||||
"never announces success for unsuccessful task results",
|
||||
async (result) => {
|
||||
await registryArtifactTaskHandler.onReady({ ...task, result });
|
||||
expect(confirmRegistryArtifactAddition).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledOnce();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "destructive" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps backend diagnostics out of notifications after reload", async () => {
|
||||
// Given
|
||||
const result = {
|
||||
installed: false,
|
||||
error: "Private diagnostic: /srv/registry/customer",
|
||||
};
|
||||
|
||||
// When
|
||||
await registryArtifactTaskHandler.onReady({ ...task, result });
|
||||
|
||||
// Then
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
description: "The artifact could not be installed.",
|
||||
}),
|
||||
);
|
||||
expect(confirmRegistryArtifactAddition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed confirmation read without announcing availability", async () => {
|
||||
confirmRegistryArtifactAddition.mockRejectedValue(new Error("Unavailable"));
|
||||
await registryArtifactTaskHandler.onReady(task);
|
||||
expect(toast).toHaveBeenCalledOnce();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "destructive" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { confirmRegistryArtifactTask } from "@/lib/registry/artifact-execution";
|
||||
import { notifyRegistryArtifactOutcome } from "@/lib/registry/artifact-notifications";
|
||||
import type { TaskKindHandler } from "@/store/task-watcher/store";
|
||||
import { REGISTRY_INSTALL_OPERATION } from "@/types/registry";
|
||||
|
||||
export const registryArtifactTaskHandler: TaskKindHandler = {
|
||||
onReady: async (task) => {
|
||||
const normalizedName = task.meta.normalizedName;
|
||||
const operation =
|
||||
task.meta.operation === REGISTRY_INSTALL_OPERATION.UPDATE
|
||||
? REGISTRY_INSTALL_OPERATION.UPDATE
|
||||
: REGISTRY_INSTALL_OPERATION.ADD;
|
||||
const expectedVersion =
|
||||
operation === REGISTRY_INSTALL_OPERATION.UPDATE
|
||||
? task.meta.expectedVersion?.trim()
|
||||
: undefined;
|
||||
const result =
|
||||
normalizedName &&
|
||||
(operation !== REGISTRY_INSTALL_OPERATION.UPDATE || expectedVersion)
|
||||
? await confirmRegistryArtifactTask(
|
||||
normalizedName,
|
||||
task.result,
|
||||
expectedVersion,
|
||||
)
|
||||
: { status: "error" as const };
|
||||
notifyRegistryArtifactOutcome(result, operation);
|
||||
},
|
||||
onError: (task) =>
|
||||
notifyRegistryArtifactOutcome(
|
||||
{ status: "error" },
|
||||
task.meta.operation === REGISTRY_INSTALL_OPERATION.UPDATE
|
||||
? REGISTRY_INSTALL_OPERATION.UPDATE
|
||||
: REGISTRY_INSTALL_OPERATION.ADD,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { type Ref } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
|
||||
interface RegistryCredentialBannerProps {
|
||||
connectButtonRef?: Ref<HTMLButtonElement>;
|
||||
onConnect: () => void;
|
||||
tenantArtifactCount: number;
|
||||
validationPending: boolean;
|
||||
}
|
||||
|
||||
export function RegistryCredentialBanner({
|
||||
connectButtonRef,
|
||||
onConnect,
|
||||
tenantArtifactCount,
|
||||
validationPending,
|
||||
}: RegistryCredentialBannerProps) {
|
||||
const title = validationPending
|
||||
? "Registry validation in progress"
|
||||
: "Connect your Registry API key";
|
||||
const copy = validationPending
|
||||
? "Your Registry key is being validated. Catalog exploration will be available after validation succeeds."
|
||||
: "A Registry API key is required to install artifacts into this workspace.";
|
||||
|
||||
return (
|
||||
<Card aria-live="polite" variant="base">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<span className="bg-bg-neutral-tertiary text-text-neutral-secondary flex size-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<KeyRound aria-hidden className="size-5" />
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
<p className="text-text-neutral-secondary text-sm">{copy}</p>
|
||||
{tenantArtifactCount > 0 && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Your {tenantArtifactCount} preserved tenant artifact
|
||||
{tenantArtifactCount === 1 ? "" : "s"} will remain available in My
|
||||
artifacts.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{/* Stays enabled while validation is pending: submitting a
|
||||
replacement key supersedes a validation that never settles. */}
|
||||
<Button onClick={onConnect} ref={connectButtonRef} type="button">
|
||||
Connect API key
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
aria-label="Explore Prowler Registry (opens in a new tab)"
|
||||
href="https://registry.prowler.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Explore Prowler Registry
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WatchedTask } from "@/store/task-watcher/store";
|
||||
|
||||
import { registryCredentialTaskHandler } from "./registry-credential-task-handler";
|
||||
|
||||
const {
|
||||
refreshRegistryCollectionsMock,
|
||||
refreshRegistryCredentialMock,
|
||||
toastMock,
|
||||
} = vi.hoisted(() => ({
|
||||
refreshRegistryCollectionsMock: vi.fn(),
|
||||
refreshRegistryCredentialMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
refreshRegistryCollections: refreshRegistryCollectionsMock,
|
||||
refreshRegistryCredential: refreshRegistryCredentialMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast", () => ({ toast: toastMock }));
|
||||
|
||||
const buildTask = (overrides: Partial<WatchedTask> = {}): WatchedTask => ({
|
||||
taskId: "task-1",
|
||||
kind: "registry-credential-validation",
|
||||
status: "ready",
|
||||
startedAt: Date.now(),
|
||||
meta: {},
|
||||
result: { stored: true, error: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("registryCredentialTaskHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
refreshRegistryCollectionsMock.mockResolvedValue({
|
||||
status: "complete",
|
||||
catalog: { status: "complete", artifacts: [] },
|
||||
tenantArtifacts: [],
|
||||
});
|
||||
refreshRegistryCredentialMock.mockResolvedValue({ status: "error" });
|
||||
});
|
||||
|
||||
it("announces the connected Registry after a resumed task completes validly", async () => {
|
||||
// Given
|
||||
refreshRegistryCredentialMock.mockResolvedValue({
|
||||
status: "status",
|
||||
credential: {
|
||||
configured: true,
|
||||
isValid: true,
|
||||
scopes: ["catalog:read"],
|
||||
validationPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
registryCredentialTaskHandler.onReady(buildTask());
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({ title: "Registry connected" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a safe failure when a resumed task settles in error", async () => {
|
||||
// When
|
||||
await registryCredentialTaskHandler.onError(
|
||||
buildTask({ status: "error", error: 'Task ended in state "failed".' }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Registry key validation failed",
|
||||
description: "Registry key validation could not be completed. Try again.",
|
||||
});
|
||||
expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("does not announce a rejected replacement as connected", async () => {
|
||||
const refresh = vi.fn();
|
||||
window.addEventListener("registry-credential-changed", refresh);
|
||||
refreshRegistryCredentialMock.mockResolvedValue({
|
||||
status: "status",
|
||||
credential: {
|
||||
configured: true,
|
||||
isValid: true,
|
||||
scopes: [],
|
||||
validationPending: false,
|
||||
},
|
||||
});
|
||||
await registryCredentialTaskHandler.onReady(
|
||||
buildTask({
|
||||
meta: { priorConfigured: "true" },
|
||||
result: { stored: false, error: "Invalid key" },
|
||||
}),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
description:
|
||||
"Registry key validation failed. Existing access is unchanged.",
|
||||
}),
|
||||
);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
window.removeEventListener("registry-credential-changed", refresh);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { completeRegistryCredentialValidation } from "@/lib/registry/credential-result";
|
||||
import type { TaskKindHandler, WatchedTask } from "@/store/task-watcher/store";
|
||||
|
||||
const complete = async (task: WatchedTask) => {
|
||||
await completeRegistryCredentialValidation(
|
||||
task,
|
||||
task.meta.priorConfigured === "true",
|
||||
);
|
||||
};
|
||||
|
||||
export const registryCredentialTaskHandler: TaskKindHandler = {
|
||||
onReady: complete,
|
||||
onError: complete,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { RegistryCatalogArtifact } from "@/types/registry";
|
||||
|
||||
import { buildRegistryMarketplaceModel } from "./registry-explorer.model";
|
||||
|
||||
const artifact = (
|
||||
normalizedName: string,
|
||||
overrides: Partial<RegistryCatalogArtifact> = {},
|
||||
): RegistryCatalogArtifact => ({
|
||||
normalizedName,
|
||||
name: normalizedName,
|
||||
providers: [],
|
||||
isVerified: false,
|
||||
isOfficial: false,
|
||||
isBuiltin: false,
|
||||
isMeta: false,
|
||||
hasProvider: false,
|
||||
hasChecks: false,
|
||||
hasCompliance: false,
|
||||
versionCount: 0,
|
||||
totalDownloads: 0,
|
||||
owners: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Registry marketplace model", () => {
|
||||
it("offers the catalog version for an installed artifact with a different resolved version", () => {
|
||||
// Given
|
||||
const catalog = {
|
||||
status: "complete" as const,
|
||||
artifacts: [
|
||||
artifact("template", { latestVersion: " 1.1.0 ", hasProvider: true }),
|
||||
],
|
||||
};
|
||||
// When
|
||||
const model = buildRegistryMarketplaceModel(
|
||||
catalog,
|
||||
[
|
||||
{
|
||||
normalizedName: "template",
|
||||
versionSpec: "latest",
|
||||
resolvedVersion: " 1.0.0 ",
|
||||
},
|
||||
],
|
||||
{},
|
||||
"name",
|
||||
);
|
||||
// Then
|
||||
expect(model).toMatchObject({
|
||||
artifacts: [
|
||||
{
|
||||
isAdded: true,
|
||||
resolvedVersion: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
},
|
||||
],
|
||||
myArtifacts: [{ catalogArtifact: { updateAvailable: true } }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ resolvedVersion: "1.0.0", latestVersion: "1.0.0", expected: false },
|
||||
{ resolvedVersion: "2.0.0", latestVersion: "1.0.0", expected: true },
|
||||
{ resolvedVersion: undefined, latestVersion: "1.0.0", expected: false },
|
||||
{ resolvedVersion: " ", latestVersion: "1.0.0", expected: false },
|
||||
{ resolvedVersion: "1.0.0", latestVersion: undefined, expected: false },
|
||||
{
|
||||
resolvedVersion: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
isBuiltin: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
resolvedVersion: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
hasProvider: false,
|
||||
expected: false,
|
||||
},
|
||||
])(
|
||||
"compares resolved $resolvedVersion against catalog $latestVersion ($expected)",
|
||||
(example) => {
|
||||
// Given / When
|
||||
const model = buildRegistryMarketplaceModel(
|
||||
{
|
||||
status: "complete",
|
||||
artifacts: [artifact("template", { hasProvider: true, ...example })],
|
||||
},
|
||||
[
|
||||
{
|
||||
normalizedName: "template",
|
||||
versionSpec: "latest",
|
||||
resolvedVersion: example.resolvedVersion,
|
||||
},
|
||||
],
|
||||
{},
|
||||
"name",
|
||||
);
|
||||
// Then
|
||||
expect(model).toMatchObject({
|
||||
artifacts: [{ updateAvailable: example.expected }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the full catalog visible with tenant membership merged in", () => {
|
||||
// Given
|
||||
|
||||
const catalog = {
|
||||
status: "complete" as const,
|
||||
artifacts: [
|
||||
artifact("zeta", { providers: ["azure"], hasProvider: true }),
|
||||
artifact("core", { providers: ["aws"], isOfficial: true }),
|
||||
artifact("global", {
|
||||
name: "Global insight",
|
||||
description: "Security checks",
|
||||
providers: ["aws", "gcp"],
|
||||
hasChecks: true,
|
||||
isOfficial: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const mine = [
|
||||
{ normalizedName: "core", versionSpec: "latest" },
|
||||
{ normalizedName: "manual", versionSpec: "1.2.3" },
|
||||
];
|
||||
|
||||
// When
|
||||
const model = buildRegistryMarketplaceModel(catalog, mine, {}, "name");
|
||||
|
||||
// Then
|
||||
|
||||
expect(model).toMatchObject({
|
||||
isComplete: true,
|
||||
providers: ["aws", "azure", "gcp"],
|
||||
});
|
||||
if (!model.isComplete) throw new Error("expected complete model");
|
||||
|
||||
expect(
|
||||
model.artifacts.map(({ normalizedName, isAdded }) => ({
|
||||
normalizedName,
|
||||
isAdded,
|
||||
})),
|
||||
).toEqual([
|
||||
{ normalizedName: "core", isAdded: true },
|
||||
{ normalizedName: "global", isAdded: false },
|
||||
{ normalizedName: "zeta", isAdded: false },
|
||||
]);
|
||||
|
||||
expect(model.myArtifacts).toEqual([
|
||||
{
|
||||
normalizedName: "core",
|
||||
versionSpec: "latest",
|
||||
catalogArtifact: expect.objectContaining({
|
||||
normalizedName: "core",
|
||||
isAdded: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
normalizedName: "manual",
|
||||
versionSpec: "1.2.3",
|
||||
catalogArtifact: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies search, provider, and capability filters together", () => {
|
||||
// Given
|
||||
|
||||
const catalog = {
|
||||
status: "complete" as const,
|
||||
artifacts: [
|
||||
artifact("core", { providers: ["aws"] }),
|
||||
artifact("global", {
|
||||
name: "Global insight",
|
||||
description: "Security checks",
|
||||
providers: ["aws", "gcp"],
|
||||
hasChecks: true,
|
||||
}),
|
||||
artifact("zeta", { providers: ["azure"], hasProvider: true }),
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
|
||||
const model = buildRegistryMarketplaceModel(
|
||||
catalog,
|
||||
[],
|
||||
{ search: "security", providers: ["aws"], capabilities: ["checks"] },
|
||||
"name",
|
||||
);
|
||||
|
||||
// Then
|
||||
if (!model.isComplete) throw new Error("expected complete model");
|
||||
expect(model.artifacts.map(({ normalizedName }) => normalizedName)).toEqual(
|
||||
["global"],
|
||||
);
|
||||
});
|
||||
|
||||
it("unions providers and capabilities within each filter", () => {
|
||||
// Given
|
||||
const catalog = {
|
||||
status: "complete" as const,
|
||||
artifacts: [
|
||||
artifact("aws-checks", { providers: ["aws"], hasChecks: true }),
|
||||
artifact("gcp-provider", { providers: ["gcp"], hasProvider: true }),
|
||||
artifact("azure-checks", { providers: ["azure"], hasChecks: true }),
|
||||
],
|
||||
};
|
||||
// When
|
||||
const model = buildRegistryMarketplaceModel(
|
||||
catalog,
|
||||
[],
|
||||
{ providers: ["aws", "gcp"], capabilities: ["checks", "provider"] },
|
||||
"name",
|
||||
);
|
||||
// Then
|
||||
expect(model).toMatchObject({
|
||||
artifacts: [
|
||||
expect.objectContaining({ normalizedName: "aws-checks" }),
|
||||
expect.objectContaining({ normalizedName: "gcp-provider" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts by downloads descending with name as the tiebreak", () => {
|
||||
// Given
|
||||
|
||||
const catalog = {
|
||||
status: "complete" as const,
|
||||
artifacts: [
|
||||
artifact("alpha", { totalDownloads: 5 }),
|
||||
artifact("delta", { totalDownloads: 9 }),
|
||||
artifact("beta", { totalDownloads: 5 }),
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const model = buildRegistryMarketplaceModel(catalog, [], {}, "downloads");
|
||||
|
||||
// Then
|
||||
if (!model.isComplete) throw new Error("expected complete model");
|
||||
expect(model.artifacts.map(({ normalizedName }) => normalizedName)).toEqual(
|
||||
["delta", "alpha", "beta"],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps incomplete catalogs out of complete-only controls and selectors", () => {
|
||||
// Given
|
||||
|
||||
const catalog = {
|
||||
status: "incomplete" as const,
|
||||
reason: "page_failed" as const,
|
||||
collectedCount: 3,
|
||||
};
|
||||
|
||||
// When
|
||||
const model = buildRegistryMarketplaceModel(
|
||||
catalog,
|
||||
[],
|
||||
{ search: "core" },
|
||||
"name",
|
||||
);
|
||||
|
||||
// Then
|
||||
|
||||
expect(model).toEqual({
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
|
||||
import {
|
||||
REGISTRY_CATALOG,
|
||||
type RegistryCatalogArtifact,
|
||||
type RegistryCatalogResult,
|
||||
type RegistryTenantArtifact,
|
||||
} from "@/types/registry";
|
||||
|
||||
export const REGISTRY_CATALOG_CAPABILITY = {
|
||||
CHECKS: "checks",
|
||||
COMPLIANCE: "compliance",
|
||||
PROVIDER: "provider",
|
||||
} as const;
|
||||
|
||||
export type RegistryCatalogCapability =
|
||||
(typeof REGISTRY_CATALOG_CAPABILITY)[keyof typeof REGISTRY_CATALOG_CAPABILITY];
|
||||
|
||||
export const REGISTRY_CAPABILITY_LABELS = {
|
||||
checks: "Checks",
|
||||
compliance: "Compliance",
|
||||
provider: "Provider",
|
||||
} as const satisfies Record<RegistryCatalogCapability, string>;
|
||||
|
||||
export interface RegistryExplorerFilters {
|
||||
search?: string;
|
||||
providers?: string[];
|
||||
capabilities?: RegistryCatalogCapability[];
|
||||
}
|
||||
|
||||
export const REGISTRY_MARKETPLACE_SORT = {
|
||||
NAME: "name",
|
||||
DOWNLOADS: "downloads",
|
||||
} as const;
|
||||
|
||||
export type RegistryMarketplaceSort =
|
||||
(typeof REGISTRY_MARKETPLACE_SORT)[keyof typeof REGISTRY_MARKETPLACE_SORT];
|
||||
|
||||
export interface RegistryMarketplaceArtifact extends RegistryCatalogArtifact {
|
||||
isAdded: boolean;
|
||||
resolvedVersion?: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface RegistryMarketplaceMyArtifact extends RegistryTenantArtifact {
|
||||
catalogArtifact?: RegistryMarketplaceArtifact;
|
||||
}
|
||||
|
||||
export interface RegistryMarketplaceIncompleteModel {
|
||||
isComplete: false;
|
||||
}
|
||||
|
||||
export interface RegistryMarketplaceCompleteModel {
|
||||
isComplete: true;
|
||||
artifacts: RegistryMarketplaceArtifact[];
|
||||
providers: string[];
|
||||
myArtifacts: RegistryMarketplaceMyArtifact[];
|
||||
}
|
||||
|
||||
export type RegistryMarketplaceModel =
|
||||
| RegistryMarketplaceIncompleteModel
|
||||
| RegistryMarketplaceCompleteModel;
|
||||
|
||||
export function buildRegistryMarketplaceModel(
|
||||
catalog: RegistryCatalogResult,
|
||||
myArtifacts: RegistryTenantArtifact[],
|
||||
filters: RegistryExplorerFilters,
|
||||
sort: RegistryMarketplaceSort,
|
||||
): RegistryMarketplaceModel {
|
||||
if (catalog.status !== REGISTRY_CATALOG.COMPLETE)
|
||||
return {
|
||||
isComplete: false,
|
||||
};
|
||||
const installedArtifacts = new Map(
|
||||
myArtifacts.map((artifact) => [artifact.normalizedName, artifact]),
|
||||
);
|
||||
const merged = new Map(
|
||||
catalog.artifacts.map((artifact) => {
|
||||
const installed = installedArtifacts.get(artifact.normalizedName);
|
||||
const resolvedVersion = installed?.resolvedVersion?.trim() || undefined;
|
||||
const latestVersion = artifact.latestVersion?.trim() || undefined;
|
||||
return [
|
||||
artifact.normalizedName,
|
||||
{
|
||||
...artifact,
|
||||
latestVersion,
|
||||
resolvedVersion,
|
||||
isAdded: Boolean(installed),
|
||||
updateAvailable: Boolean(
|
||||
resolvedVersion &&
|
||||
latestVersion &&
|
||||
resolvedVersion !== latestVersion &&
|
||||
isRegistryArtifactInstallable(artifact),
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const artifacts = Array.from(merged.values())
|
||||
.filter((artifact) => matches(artifact, filters))
|
||||
.sort((left, right) =>
|
||||
sort === REGISTRY_MARKETPLACE_SORT.DOWNLOADS
|
||||
? right.totalDownloads - left.totalDownloads ||
|
||||
compare(left.normalizedName, right.normalizedName)
|
||||
: compare(left.normalizedName, right.normalizedName),
|
||||
);
|
||||
return {
|
||||
isComplete: true,
|
||||
artifacts,
|
||||
providers: Array.from(
|
||||
new Set(catalog.artifacts.flatMap((artifact) => artifact.providers)),
|
||||
).sort(compare),
|
||||
myArtifacts: myArtifacts
|
||||
.map(({ normalizedName, versionSpec, resolvedVersion }) => ({
|
||||
normalizedName,
|
||||
versionSpec,
|
||||
resolvedVersion: resolvedVersion?.trim() || undefined,
|
||||
catalogArtifact: merged.get(normalizedName),
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
compare(left.normalizedName, right.normalizedName),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function matches(
|
||||
artifact: RegistryCatalogArtifact,
|
||||
filters: RegistryExplorerFilters,
|
||||
) {
|
||||
const search = filters.search?.trim().toLowerCase();
|
||||
const providers = (filters.providers ?? []).map((provider) =>
|
||||
provider.trim().toLowerCase(),
|
||||
);
|
||||
const text =
|
||||
`${artifact.normalizedName} ${artifact.name ?? ""} ${artifact.description ?? ""}`.toLowerCase();
|
||||
return (
|
||||
(!search || text.includes(search)) &&
|
||||
(providers.length === 0 ||
|
||||
providers.some((provider) => artifact.providers.includes(provider))) &&
|
||||
(filters.capabilities?.length ? filters.capabilities : [undefined]).some(
|
||||
(capability) =>
|
||||
!capability ||
|
||||
(capability === REGISTRY_CATALOG_CAPABILITY.CHECKS &&
|
||||
artifact.hasChecks) ||
|
||||
(capability === REGISTRY_CATALOG_CAPABILITY.COMPLIANCE &&
|
||||
artifact.hasCompliance) ||
|
||||
(capability === REGISTRY_CATALOG_CAPABILITY.PROVIDER &&
|
||||
artifact.hasProvider),
|
||||
)
|
||||
);
|
||||
}
|
||||
function compare(left: string, right: string) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
disconnectRegistryCredential,
|
||||
removeRegistryArtifact,
|
||||
} from "@/actions/registry/registry";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn/tabs/tabs";
|
||||
import { toast } from "@/components/shadcn/toast/use-toast";
|
||||
import { executeRegistryArtifactAddition } from "@/lib/registry/artifact-execution";
|
||||
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
|
||||
import { executeRegistryCredentialValidation } from "@/lib/registry/credential-execution";
|
||||
import {
|
||||
REGISTRY_CREDENTIAL_CHANGED,
|
||||
credentialOutcomeMessage,
|
||||
type RegistryCredentialValidationOutcome,
|
||||
} from "@/lib/registry/credential-result";
|
||||
import { useTaskWatcherStore } from "@/store/task-watcher/store";
|
||||
import {
|
||||
REGISTRY_ARTIFACT_REMOVAL,
|
||||
REGISTRY_BOOTSTRAP_STATE,
|
||||
REGISTRY_CREDENTIAL_ACTION,
|
||||
REGISTRY_FAILURE,
|
||||
REGISTRY_INSTALL_OPERATION,
|
||||
REGISTRY_MUTATION,
|
||||
type RegistryArtifactRemovalResult,
|
||||
type RegistryBootstrapState,
|
||||
type RegistryMutationResult,
|
||||
type RegistryRemoveDialogError,
|
||||
type RegistryTenantArtifact,
|
||||
} from "@/types/registry";
|
||||
|
||||
import { RegistryAccessDialog } from "./registry-access-dialog";
|
||||
import {
|
||||
RegistryArtifactCard,
|
||||
RegistryTenantArtifactCard,
|
||||
} from "./registry-artifact-card";
|
||||
import { RegistryArtifactGrid } from "./registry-artifact-grid";
|
||||
import { RegistryCredentialBanner } from "./registry-credential-banner";
|
||||
import {
|
||||
buildRegistryMarketplaceModel,
|
||||
REGISTRY_MARKETPLACE_SORT,
|
||||
REGISTRY_CATALOG_CAPABILITY,
|
||||
type RegistryCatalogCapability,
|
||||
type RegistryExplorerFilters,
|
||||
type RegistryMarketplaceArtifact,
|
||||
type RegistryMarketplaceSort,
|
||||
} from "./registry-explorer.model";
|
||||
import { RegistryRemoveDialog } from "./registry-remove-dialog";
|
||||
import { RegistryToolbar } from "./registry-toolbar";
|
||||
import { useRegistryRefresh } from "./use-registry-refresh";
|
||||
|
||||
const PAGE_SUBTITLE =
|
||||
"Explore checks, compliance frameworks, and providers. Add external provider artifacts to connect new providers to your workspace.";
|
||||
|
||||
const REGISTRY_TAB = { EXPLORE: "explore", MINE: "mine" } as const;
|
||||
type RegistryTab = (typeof REGISTRY_TAB)[keyof typeof REGISTRY_TAB];
|
||||
|
||||
const REGISTRY_PENDING_OPERATION = {
|
||||
CREDENTIAL: "credential",
|
||||
REMOVE: "remove",
|
||||
} as const;
|
||||
type RegistryPendingOperation =
|
||||
(typeof REGISTRY_PENDING_OPERATION)[keyof typeof REGISTRY_PENDING_OPERATION];
|
||||
|
||||
const REGISTRY_ACCESS_DIALOG_MODE = {
|
||||
CONNECT: "connect",
|
||||
MANAGE: "manage",
|
||||
} as const;
|
||||
type RegistryAccessDialogMode =
|
||||
(typeof REGISTRY_ACCESS_DIALOG_MODE)[keyof typeof REGISTRY_ACCESS_DIALOG_MODE];
|
||||
|
||||
interface RetryStateProps {
|
||||
title: string;
|
||||
children: string;
|
||||
}
|
||||
|
||||
function RetryState({ title, children }: RetryStateProps) {
|
||||
return (
|
||||
<section aria-live="polite" className="mx-auto max-w-2xl py-12 text-center">
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<p className="text-text-neutral-secondary mt-3 text-sm">{children}</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function mutationFailureMessage(result: RegistryMutationResult) {
|
||||
if (result.status === REGISTRY_MUTATION.REFUSED) return result.message;
|
||||
if (result.status === REGISTRY_MUTATION.REFRESH_FAILED) {
|
||||
return "Registry membership could not be confirmed. Try again.";
|
||||
}
|
||||
return "The Registry operation could not be completed. Try again.";
|
||||
}
|
||||
|
||||
interface RegistryExplorerProps {
|
||||
initialState: RegistryBootstrapState;
|
||||
registryKeyUrl?: string;
|
||||
}
|
||||
|
||||
export function RegistryExplorer({
|
||||
initialState,
|
||||
registryKeyUrl,
|
||||
}: RegistryExplorerProps) {
|
||||
// The API is the sole access authority: a denied action result routes to
|
||||
// Profile once, and the navigation unmounts this component with its state.
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState(initialState);
|
||||
const searchParams = useSearchParams();
|
||||
const filters: RegistryExplorerFilters = {
|
||||
search: searchParams.get("filter[search]") ?? undefined,
|
||||
providers:
|
||||
searchParams.get("filter[provider]")?.split(",").filter(Boolean) ?? [],
|
||||
capabilities: (
|
||||
searchParams.get("filter[capability]")?.split(",") ?? []
|
||||
).filter((value): value is RegistryCatalogCapability =>
|
||||
Object.values(REGISTRY_CATALOG_CAPABILITY).includes(
|
||||
value as RegistryCatalogCapability,
|
||||
),
|
||||
),
|
||||
};
|
||||
const sort =
|
||||
searchParams.get("sort") === REGISTRY_MARKETPLACE_SORT.DOWNLOADS
|
||||
? REGISTRY_MARKETPLACE_SORT.DOWNLOADS
|
||||
: REGISTRY_MARKETPLACE_SORT.NAME;
|
||||
const activeTab =
|
||||
searchParams.get("tab") === REGISTRY_TAB.MINE
|
||||
? REGISTRY_TAB.MINE
|
||||
: REGISTRY_TAB.EXPLORE;
|
||||
function updateView(values: Record<string, string | undefined>) {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(values).forEach(([key, value]) =>
|
||||
value ? next.set(key, value) : next.delete(key),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/registry${next.size ? `?${next}` : ""}`,
|
||||
);
|
||||
}
|
||||
const setFilters = (next: RegistryExplorerFilters) =>
|
||||
updateView({
|
||||
"filter[search]": next.search,
|
||||
"filter[provider]": next.providers?.join(","),
|
||||
"filter[capability]": next.capabilities?.join(","),
|
||||
});
|
||||
const setSort = (next: RegistryMarketplaceSort) =>
|
||||
updateView({
|
||||
sort: next === REGISTRY_MARKETPLACE_SORT.NAME ? undefined : next,
|
||||
});
|
||||
const setActiveTab = (next: RegistryTab) => {
|
||||
updateView({ tab: next === REGISTRY_TAB.EXPLORE ? undefined : next });
|
||||
if (next !== activeTab) requestRefresh();
|
||||
};
|
||||
const [pendingOperation, setPendingOperation] =
|
||||
useState<RegistryPendingOperation | null>(null);
|
||||
const [localPendingAddName, setPendingAddName] = useState<string>();
|
||||
const watchedTasks = useTaskWatcherStore((store) => store.tasks);
|
||||
const pendingAddName =
|
||||
localPendingAddName ||
|
||||
Object.values(watchedTasks).find(
|
||||
(task) =>
|
||||
task.kind === "registry-artifact-add" && task.status === "pending",
|
||||
)?.meta.normalizedName;
|
||||
const [refreshMessage, setRefreshMessage] = useState<string>();
|
||||
const { isRefreshing, requestRefresh, invalidateRefresh } =
|
||||
useRegistryRefresh({
|
||||
enabled: state.status === REGISTRY_BOOTSTRAP_STATE.READY,
|
||||
mutationPending: Boolean(pendingOperation || pendingAddName),
|
||||
onResult: (result) => {
|
||||
if (result.status === "access_denied") {
|
||||
router.replace("/profile");
|
||||
} else if (result.status === "complete") {
|
||||
setRefreshMessage(undefined);
|
||||
setState((current) =>
|
||||
current.status === "ready"
|
||||
? {
|
||||
...current,
|
||||
catalog: result.catalog,
|
||||
tenantArtifacts: result.tenantArtifacts,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} else if (result.status === "reconnect") {
|
||||
setState({ status: REGISTRY_BOOTSTRAP_STATE.RECONNECT });
|
||||
} else if (result.status === "onboarding") {
|
||||
setState((current) =>
|
||||
current.status === "ready"
|
||||
? {
|
||||
status: REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
|
||||
credential: {
|
||||
configured: false,
|
||||
isValid: false,
|
||||
scopes: [],
|
||||
validationPending: false,
|
||||
},
|
||||
tenantArtifacts: current.tenantArtifacts,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} else {
|
||||
setRefreshMessage(
|
||||
"Registry could not be refreshed. Showing the last available data. Try again.",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const consumeArtifactsChanged = useEffectEvent(
|
||||
(artifacts: RegistryTenantArtifact[]) => {
|
||||
invalidateRefresh();
|
||||
setState((current) =>
|
||||
current.status === "ready"
|
||||
? { ...current, tenantArtifacts: artifacts }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
const refresh = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || !Array.isArray(event.detail))
|
||||
return;
|
||||
consumeArtifactsChanged(event.detail);
|
||||
};
|
||||
window.addEventListener("registry-artifacts-changed", refresh);
|
||||
return () =>
|
||||
window.removeEventListener("registry-artifacts-changed", refresh);
|
||||
}, []);
|
||||
const [accessDialogMode, setAccessDialogMode] =
|
||||
useState<RegistryAccessDialogMode>();
|
||||
const [removeTarget, setRemoveTarget] = useState<string>();
|
||||
const [removeError, setRemoveError] = useState<RegistryRemoveDialogError>();
|
||||
const [operationMessage, setOperationMessage] = useState<string>();
|
||||
const connectButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const manageButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const removeTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const operationGeneration = useRef(0);
|
||||
const artifactSubmission = useRef(false);
|
||||
const awaitingCredential = useRef(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
operationGeneration.current += 1;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const consumeCredentialOutcome = useEffectEvent(
|
||||
(result: RegistryCredentialValidationOutcome) => {
|
||||
if (!awaitingCredential.current) applyCredentialOutcome(result);
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
const consume = (event: Event) => {
|
||||
if (event instanceof CustomEvent) consumeCredentialOutcome(event.detail);
|
||||
};
|
||||
window.addEventListener(REGISTRY_CREDENTIAL_CHANGED, consume);
|
||||
return () =>
|
||||
window.removeEventListener(REGISTRY_CREDENTIAL_CHANGED, consume);
|
||||
}, []);
|
||||
|
||||
function applyCredentialOutcome(result: RegistryCredentialValidationOutcome) {
|
||||
// A connected outcome already includes fresh collections. A failed
|
||||
// replacement must still resume any read deferred during that mutation.
|
||||
invalidateRefresh(
|
||||
result.status === REGISTRY_CREDENTIAL_ACTION.CONNECTED
|
||||
? false
|
||||
: undefined,
|
||||
);
|
||||
if (result.status === REGISTRY_FAILURE.ACCESS_DENIED) {
|
||||
router.replace("/profile");
|
||||
return;
|
||||
}
|
||||
setPendingOperation(null);
|
||||
if (result.status === REGISTRY_CREDENTIAL_ACTION.CONNECTED) {
|
||||
setRefreshMessage(undefined);
|
||||
setAccessDialogMode(undefined);
|
||||
setOperationMessage(undefined);
|
||||
setState({
|
||||
status: REGISTRY_BOOTSTRAP_STATE.READY,
|
||||
credential: result.credential,
|
||||
catalog: result.collections.catalog,
|
||||
tenantArtifacts: result.collections.tenantArtifacts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.status === REGISTRY_CREDENTIAL_ACTION.PENDING ||
|
||||
result.status === REGISTRY_CREDENTIAL_ACTION.INVALID
|
||||
) {
|
||||
setState((current) =>
|
||||
current.status === REGISTRY_BOOTSTRAP_STATE.ONBOARDING ||
|
||||
current.status === REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
|
||||
? {
|
||||
status:
|
||||
result.status === REGISTRY_CREDENTIAL_ACTION.PENDING
|
||||
? REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
|
||||
: REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
|
||||
credential: result.credential ?? current.credential,
|
||||
tenantArtifacts: current.tenantArtifacts,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
setOperationMessage(credentialOutcomeMessage(result));
|
||||
}
|
||||
|
||||
async function handleAdd(artifact: RegistryMarketplaceArtifact) {
|
||||
if (
|
||||
!isRegistryArtifactInstallable(artifact) ||
|
||||
(artifact.isAdded && !artifact.updateAvailable) ||
|
||||
pendingAddName ||
|
||||
pendingOperation ||
|
||||
artifactSubmission.current
|
||||
)
|
||||
return;
|
||||
const { normalizedName } = artifact;
|
||||
invalidateRefresh();
|
||||
artifactSubmission.current = true;
|
||||
const generation = operationGeneration.current;
|
||||
setOperationMessage(undefined);
|
||||
setPendingAddName(normalizedName);
|
||||
const result = await executeRegistryArtifactAddition(
|
||||
artifact.updateAvailable && artifact.latestVersion
|
||||
? {
|
||||
normalizedName,
|
||||
versionSpec: artifact.latestVersion,
|
||||
operation: REGISTRY_INSTALL_OPERATION.UPDATE,
|
||||
}
|
||||
: { normalizedName },
|
||||
);
|
||||
artifactSubmission.current = false;
|
||||
if (generation !== operationGeneration.current) return;
|
||||
if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
|
||||
return router.replace("/profile");
|
||||
|
||||
setPendingAddName(undefined);
|
||||
if (result.status !== REGISTRY_MUTATION.CONFIRMED) {
|
||||
setOperationMessage(
|
||||
artifact.updateAvailable &&
|
||||
result.status === REGISTRY_MUTATION.REFRESH_FAILED
|
||||
? "Update could not be confirmed. Refresh Registry before retrying."
|
||||
: mutationFailureMessage(result),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) =>
|
||||
current.status === REGISTRY_BOOTSTRAP_STATE.READY
|
||||
? { ...current, tenantArtifacts: result.tenantArtifacts }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCredentialSubmit(key: string) {
|
||||
invalidateRefresh();
|
||||
const generation = operationGeneration.current;
|
||||
setOperationMessage(undefined);
|
||||
setPendingOperation(REGISTRY_PENDING_OPERATION.CREDENTIAL);
|
||||
awaitingCredential.current = true;
|
||||
const result = await executeRegistryCredentialValidation(key);
|
||||
awaitingCredential.current = false;
|
||||
if (generation !== operationGeneration.current) return;
|
||||
applyCredentialOutcome(result);
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
invalidateRefresh();
|
||||
const generation = operationGeneration.current;
|
||||
setOperationMessage(undefined);
|
||||
setPendingOperation(REGISTRY_PENDING_OPERATION.CREDENTIAL);
|
||||
const result = await disconnectRegistryCredential().catch(() => ({
|
||||
status: REGISTRY_FAILURE.ERROR,
|
||||
}));
|
||||
if (generation !== operationGeneration.current) return;
|
||||
if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
|
||||
return router.replace("/profile");
|
||||
|
||||
setPendingOperation(null);
|
||||
if (result.status !== REGISTRY_CREDENTIAL_ACTION.DISCONNECTED) {
|
||||
setOperationMessage(
|
||||
"Registry access could not be disconnected. Try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setAccessDialogMode(undefined);
|
||||
setState({
|
||||
status: REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
|
||||
credential: result.credential,
|
||||
tenantArtifacts: result.tenantArtifacts,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemove(normalizedName: string) {
|
||||
if (
|
||||
pendingOperation === REGISTRY_PENDING_OPERATION.REMOVE ||
|
||||
pendingAddName === normalizedName
|
||||
)
|
||||
return;
|
||||
invalidateRefresh();
|
||||
const generation = operationGeneration.current;
|
||||
setOperationMessage(undefined);
|
||||
setRemoveError(undefined);
|
||||
setPendingOperation(REGISTRY_PENDING_OPERATION.REMOVE);
|
||||
let result: RegistryArtifactRemovalResult;
|
||||
try {
|
||||
result = await removeRegistryArtifact(normalizedName);
|
||||
} catch {
|
||||
result = { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
if (generation !== operationGeneration.current) return;
|
||||
setPendingOperation(null);
|
||||
if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
|
||||
return router.replace("/profile");
|
||||
|
||||
if (result.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE) {
|
||||
setRemoveError(result);
|
||||
return;
|
||||
}
|
||||
if (result.status !== REGISTRY_MUTATION.CONFIRMED) {
|
||||
setRemoveError({
|
||||
status: REGISTRY_FAILURE.ERROR,
|
||||
message: mutationFailureMessage(result),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoveTarget(undefined);
|
||||
setState((current) =>
|
||||
current.status === REGISTRY_BOOTSTRAP_STATE.READY
|
||||
? { ...current, tenantArtifacts: result.tenantArtifacts }
|
||||
: current,
|
||||
);
|
||||
toast({ title: "Artifact removed" });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("registry-artifacts-changed", {
|
||||
detail: result.tenantArtifacts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function openRemoveDialog(
|
||||
normalizedName: string,
|
||||
trigger: HTMLButtonElement | null,
|
||||
) {
|
||||
removeTriggerRef.current = trigger;
|
||||
setRemoveError(undefined);
|
||||
setRemoveTarget(normalizedName);
|
||||
}
|
||||
|
||||
const accessDialogProps = {
|
||||
registryKeyUrl,
|
||||
errorMessage: operationMessage,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) {
|
||||
setAccessDialogMode(undefined);
|
||||
}
|
||||
},
|
||||
onSubmit: handleCredentialSubmit,
|
||||
open: true,
|
||||
pending: pendingOperation === REGISTRY_PENDING_OPERATION.CREDENTIAL,
|
||||
returnFocusRef:
|
||||
accessDialogMode === REGISTRY_ACCESS_DIALOG_MODE.CONNECT
|
||||
? connectButtonRef
|
||||
: manageButtonRef,
|
||||
};
|
||||
const accessDialog =
|
||||
accessDialogMode === REGISTRY_ACCESS_DIALOG_MODE.CONNECT ? (
|
||||
<RegistryAccessDialog mode="connect" {...accessDialogProps} />
|
||||
) : accessDialogMode === REGISTRY_ACCESS_DIALOG_MODE.MANAGE ? (
|
||||
<RegistryAccessDialog
|
||||
mode="manage"
|
||||
onDisconnect={handleDisconnect}
|
||||
{...accessDialogProps}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (
|
||||
state.status === REGISTRY_BOOTSTRAP_STATE.ONBOARDING ||
|
||||
state.status === REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-text-neutral-secondary text-sm">{PAGE_SUBTITLE}</p>
|
||||
{!accessDialogMode && operationMessage && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription>{operationMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<RegistryCredentialBanner
|
||||
connectButtonRef={connectButtonRef}
|
||||
onConnect={() =>
|
||||
setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT)
|
||||
}
|
||||
tenantArtifactCount={state.tenantArtifacts.length}
|
||||
validationPending={
|
||||
state.status === REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
|
||||
}
|
||||
/>
|
||||
{accessDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status !== REGISTRY_BOOTSTRAP_STATE.READY) {
|
||||
const messages = {
|
||||
[REGISTRY_BOOTSTRAP_STATE.INCOMPLETE]: [
|
||||
"Registry catalog is incomplete",
|
||||
"Complete catalog controls and metrics are unavailable until every catalog page loads. Retry to load the catalog again.",
|
||||
],
|
||||
[REGISTRY_BOOTSTRAP_STATE.UNAVAILABLE]: [
|
||||
"Registry is unavailable",
|
||||
"Registry data may be stale or unavailable. Retry when the service is available.",
|
||||
],
|
||||
[REGISTRY_BOOTSTRAP_STATE.RECONNECT]: [
|
||||
"Reconnect Registry",
|
||||
"Reconnect Registry before exploring artifacts.",
|
||||
],
|
||||
[REGISTRY_BOOTSTRAP_STATE.ERROR]: [
|
||||
"Registry could not be loaded",
|
||||
"An unexpected Registry error occurred. Retry to load the explorer again.",
|
||||
],
|
||||
} as const;
|
||||
const [title, message] = messages[state.status];
|
||||
return (
|
||||
<>
|
||||
<RetryState title={title}>{message}</RetryState>
|
||||
{state.status === REGISTRY_BOOTSTRAP_STATE.RECONNECT && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.MANAGE)
|
||||
}
|
||||
ref={manageButtonRef}
|
||||
type="button"
|
||||
>
|
||||
Replace key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{accessDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const model = buildRegistryMarketplaceModel(
|
||||
state.catalog,
|
||||
state.tenantArtifacts,
|
||||
filters,
|
||||
sort,
|
||||
);
|
||||
if (!model.isComplete) {
|
||||
return (
|
||||
<RetryState title="Registry catalog is incomplete">
|
||||
Complete catalog controls and metrics are unavailable.
|
||||
</RetryState>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="sr-only">Registry marketplace</h1>
|
||||
<p className="text-text-neutral-secondary text-sm">{PAGE_SUBTITLE}</p>
|
||||
{refreshMessage && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>{refreshMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!accessDialogMode && operationMessage && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription>{operationMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Tabs
|
||||
onValueChange={(value) => setActiveTab(value as RegistryTab)}
|
||||
value={activeTab}
|
||||
>
|
||||
<div className="border-border-neutral-secondary flex flex-wrap items-center justify-between gap-4 border-b">
|
||||
<div className="min-w-52 flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
adornment={
|
||||
<Badge size="sm" variant="tag">
|
||||
{state.catalog.artifacts.length}
|
||||
</Badge>
|
||||
}
|
||||
value={REGISTRY_TAB.EXPLORE}
|
||||
>
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
adornment={
|
||||
<Badge size="sm" variant="tag">
|
||||
{state.tenantArtifacts.length}
|
||||
</Badge>
|
||||
}
|
||||
value={REGISTRY_TAB.MINE}
|
||||
>
|
||||
My artifacts
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
aria-label="Refresh Registry"
|
||||
aria-busy={isRefreshing}
|
||||
disabled={
|
||||
isRefreshing || Boolean(pendingOperation || pendingAddName)
|
||||
}
|
||||
onClick={requestRefresh}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw aria-hidden />
|
||||
{isRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Manage access"
|
||||
title="Manage access"
|
||||
onClick={() =>
|
||||
setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.MANAGE)
|
||||
}
|
||||
ref={manageButtonRef}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent className="space-y-4 pt-4" value={REGISTRY_TAB.EXPLORE}>
|
||||
<RegistryToolbar
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onSortChange={setSort}
|
||||
providers={model.providers}
|
||||
resultsCount={model.artifacts.length}
|
||||
sort={sort}
|
||||
/>
|
||||
<RegistryArtifactGrid
|
||||
emptyMessage={
|
||||
state.catalog.artifacts.length === 0
|
||||
? "No Registry artifacts are available."
|
||||
: "No artifacts match the current filters."
|
||||
}
|
||||
emptyDescription={
|
||||
state.catalog.artifacts.length === 0
|
||||
? "Published artifacts will appear here when the Registry catalog is available."
|
||||
: undefined
|
||||
}
|
||||
emptyActionLabel={
|
||||
state.catalog.artifacts.length === 0
|
||||
? "Refresh catalog"
|
||||
: undefined
|
||||
}
|
||||
onReset={
|
||||
state.catalog.artifacts.length > 0
|
||||
? () => setFilters({})
|
||||
: requestRefresh
|
||||
}
|
||||
isEmpty={model.artifacts.length === 0}
|
||||
>
|
||||
{model.artifacts.map((artifact) => (
|
||||
<li key={artifact.normalizedName}>
|
||||
<RegistryArtifactCard
|
||||
artifact={artifact}
|
||||
pendingAddName={pendingAddName}
|
||||
onAdd={() => handleAdd(artifact)}
|
||||
onRemove={(trigger) =>
|
||||
openRemoveDialog(artifact.normalizedName, trigger)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</RegistryArtifactGrid>
|
||||
</TabsContent>
|
||||
<TabsContent className="space-y-4 pt-4" value={REGISTRY_TAB.MINE}>
|
||||
<RegistryArtifactGrid
|
||||
emptyMessage="No artifacts in this workspace yet."
|
||||
emptyDescription="Explore the catalog to add an external provider to this workspace."
|
||||
emptyActionLabel="Explore artifacts"
|
||||
isEmpty={model.myArtifacts.length === 0}
|
||||
onReset={() => setActiveTab(REGISTRY_TAB.EXPLORE)}
|
||||
>
|
||||
{model.myArtifacts.map((myArtifact) => (
|
||||
<li key={myArtifact.normalizedName}>
|
||||
{myArtifact.catalogArtifact ? (
|
||||
<RegistryArtifactCard
|
||||
artifact={myArtifact.catalogArtifact}
|
||||
pendingAddName={pendingAddName}
|
||||
onAdd={() => handleAdd(myArtifact.catalogArtifact!)}
|
||||
onRemove={(trigger) =>
|
||||
openRemoveDialog(myArtifact.normalizedName, trigger)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RegistryTenantArtifactCard
|
||||
normalizedName={myArtifact.normalizedName}
|
||||
onRemove={(trigger) =>
|
||||
openRemoveDialog(myArtifact.normalizedName, trigger)
|
||||
}
|
||||
resolvedVersion={myArtifact.resolvedVersion}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</RegistryArtifactGrid>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{accessDialog}
|
||||
<RegistryRemoveDialog
|
||||
artifactName={removeTarget}
|
||||
error={removeError}
|
||||
isPending={pendingOperation === REGISTRY_PENDING_OPERATION.REMOVE}
|
||||
onConfirm={() => removeTarget && handleRemove(removeTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.REMOVE) {
|
||||
setRemoveError(undefined);
|
||||
setRemoveTarget(undefined);
|
||||
}
|
||||
}}
|
||||
onViewProviders={() => router.push("/providers")}
|
||||
open={removeTarget !== undefined}
|
||||
returnFocusRef={removeTriggerRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Modal } from "@/components/shadcn/modal/modal";
|
||||
import {
|
||||
REGISTRY_ARTIFACT_REMOVAL,
|
||||
type RegistryRemoveDialogError,
|
||||
} from "@/types/registry";
|
||||
|
||||
interface RegistryRemoveDialogProps {
|
||||
artifactName?: string;
|
||||
error?: RegistryRemoveDialogError;
|
||||
isPending: boolean;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onViewProviders: () => void;
|
||||
open: boolean;
|
||||
returnFocusRef: RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export function RegistryRemoveDialog({
|
||||
artifactName,
|
||||
error,
|
||||
isPending,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onViewProviders,
|
||||
open,
|
||||
returnFocusRef,
|
||||
}: RegistryRemoveDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const isInUse = error?.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE;
|
||||
|
||||
// Disabling the submit button can lose focus; restore it inside the dialog
|
||||
// when a failure re-enables the actions or replaces them with recovery actions.
|
||||
useEffect(() => {
|
||||
if (open && error) cancelButtonRef.current?.focus();
|
||||
}, [error, open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
description={`Remove ${artifactName ?? "this artifact"} from My artifacts. Artifacts used by providers cannot be removed.`}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
cancelButtonRef.current?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
returnFocusRef.current?.focus();
|
||||
}}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
size="sm"
|
||||
title="Remove artifact"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
{isInUse && <AlertTitle>Artifact in use</AlertTitle>}
|
||||
<AlertDescription>
|
||||
{error.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE
|
||||
? "This artifact cannot be removed because one or more providers use it. Review the associated providers before trying again."
|
||||
: error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
ref={cancelButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isInUse ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{isInUse ? (
|
||||
<Button onClick={onViewProviders} type="button">
|
||||
View providers
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? "Removing artifact" : "Confirm Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { SearchInput } from "@/components/shadcn/search-input/search-input";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectSelectAll,
|
||||
MultiSelectSeparator,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
import {
|
||||
REGISTRY_CAPABILITY_LABELS,
|
||||
REGISTRY_CATALOG_CAPABILITY,
|
||||
REGISTRY_MARKETPLACE_SORT,
|
||||
type RegistryCatalogCapability,
|
||||
type RegistryExplorerFilters,
|
||||
type RegistryMarketplaceSort,
|
||||
} from "./registry-explorer.model";
|
||||
|
||||
interface RegistryToolbarProps {
|
||||
filters: RegistryExplorerFilters;
|
||||
onFiltersChange: (filters: RegistryExplorerFilters) => void;
|
||||
onSortChange: (sort: RegistryMarketplaceSort) => void;
|
||||
providers: string[];
|
||||
resultsCount: number;
|
||||
sort: RegistryMarketplaceSort;
|
||||
}
|
||||
|
||||
export function RegistryToolbar({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onSortChange,
|
||||
providers,
|
||||
resultsCount,
|
||||
sort,
|
||||
}: RegistryToolbarProps) {
|
||||
const activeCount =
|
||||
Number(Boolean(filters.providers?.length)) +
|
||||
Number(Boolean(filters.capabilities?.length)) +
|
||||
Number(Boolean(filters.search));
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="w-full sm:w-64">
|
||||
<SearchInput
|
||||
aria-label="Search artifacts"
|
||||
placeholder="Search artifacts..."
|
||||
value={filters.search ?? ""}
|
||||
onChange={(event) =>
|
||||
onFiltersChange({ ...filters, search: event.target.value })
|
||||
}
|
||||
onClear={() => onFiltersChange({ ...filters, search: undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-56">
|
||||
<MultiSelect
|
||||
values={filters.providers ?? []}
|
||||
onValuesChange={(values) =>
|
||||
onFiltersChange({ ...filters, providers: values })
|
||||
}
|
||||
>
|
||||
<MultiSelectTrigger aria-label="Filter by provider">
|
||||
<MultiSelectValue placeholder="All providers" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={{
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
}}
|
||||
>
|
||||
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
|
||||
<MultiSelectSeparator />
|
||||
{providers.map((provider) => (
|
||||
<MultiSelectItem
|
||||
key={provider}
|
||||
value={provider}
|
||||
badgeLabel={getProviderDisplayName(provider)}
|
||||
>
|
||||
<ProviderTypeIcon size={20} type={provider} />
|
||||
{getProviderDisplayName(provider)}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
<div className="w-full sm:w-52">
|
||||
<MultiSelect
|
||||
values={filters.capabilities ?? []}
|
||||
onValuesChange={(values) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
capabilities: values as RegistryCatalogCapability[],
|
||||
})
|
||||
}
|
||||
>
|
||||
<MultiSelectTrigger aria-label="Filter by capability">
|
||||
<MultiSelectValue placeholder="All capabilities" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent>
|
||||
{Object.values(REGISTRY_CATALOG_CAPABILITY).map((capability) => (
|
||||
<MultiSelectItem key={capability} value={capability}>
|
||||
{REGISTRY_CAPABILITY_LABELS[capability]}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
<div className="w-full sm:w-48">
|
||||
<Select
|
||||
value={sort}
|
||||
onValueChange={(value) =>
|
||||
onSortChange(value as RegistryMarketplaceSort)
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Sort artifacts">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={REGISTRY_MARKETPLACE_SORT.NAME}>
|
||||
Name (A–Z)
|
||||
</SelectItem>
|
||||
<SelectItem value={REGISTRY_MARKETPLACE_SORT.DOWNLOADS}>
|
||||
Most downloaded
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ClearFiltersButton
|
||||
ariaLabel="Clear filters"
|
||||
showCount
|
||||
pendingCount={activeCount}
|
||||
onClear={() => {
|
||||
onFiltersChange({});
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="text-text-neutral-secondary ml-auto text-sm"
|
||||
>
|
||||
{resultsCount} artifact{resultsCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
|
||||
import { refreshRegistryCollections } from "@/actions/registry/registry";
|
||||
import type { RegistryCollectionsResult } from "@/types/registry";
|
||||
|
||||
interface RegistryRefreshOptions {
|
||||
enabled: boolean;
|
||||
mutationPending: boolean;
|
||||
onResult: (result: RegistryCollectionsResult) => void;
|
||||
}
|
||||
|
||||
export function useRegistryRefresh({
|
||||
enabled,
|
||||
mutationPending,
|
||||
onResult,
|
||||
}: RegistryRefreshOptions) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [requestId, setRequestId] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const inFlight = useRef<number | null>(null);
|
||||
const requested = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
generation.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function requestRefresh() {
|
||||
// Tab/focus/button events share the current read unless a mutation invalidated it.
|
||||
if (inFlight.current === generation.current) return;
|
||||
requested.current = true;
|
||||
setRequestId((value) => value + 1);
|
||||
}
|
||||
|
||||
function invalidateRefresh(refreshAfter?: boolean) {
|
||||
generation.current += 1;
|
||||
requested.current =
|
||||
refreshAfter ?? (requested.current || inFlight.current !== null);
|
||||
setRequestId((value) => value + 1);
|
||||
}
|
||||
|
||||
const readCollections = useEffectEvent(async () => {
|
||||
if (!enabled) {
|
||||
generation.current += 1;
|
||||
requested.current = false;
|
||||
return;
|
||||
}
|
||||
if (mutationPending) {
|
||||
generation.current += 1;
|
||||
requested.current = requested.current || inFlight.current !== null;
|
||||
return;
|
||||
}
|
||||
if (!requested.current || inFlight.current !== null) return;
|
||||
const currentGeneration = generation.current;
|
||||
inFlight.current = currentGeneration;
|
||||
requested.current = false;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const result = await refreshRegistryCollections().catch(() => ({
|
||||
status: "error" as const,
|
||||
}));
|
||||
if (mounted.current && currentGeneration === generation.current)
|
||||
onResult(result);
|
||||
} finally {
|
||||
inFlight.current = null;
|
||||
if (mounted.current) {
|
||||
setIsRefreshing(false);
|
||||
if (requested.current) setRequestId((value) => value + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void readCollections();
|
||||
}, [enabled, mutationPending, requestId]);
|
||||
|
||||
const refreshOnFocus = useEffectEvent(() => {
|
||||
if (enabled && document.visibilityState === "visible") requestRefresh();
|
||||
});
|
||||
useEffect(() => {
|
||||
const refresh = () => refreshOnFocus();
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
return () => {
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isRefreshing, requestRefresh, invalidateRefresh };
|
||||
}
|
||||
@@ -68,6 +68,11 @@ vi.mock("@/lib", () => ({
|
||||
description:
|
||||
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
|
||||
},
|
||||
{
|
||||
field: "manage_registry",
|
||||
label: "Manage Registry",
|
||||
description: "Allows managing tenant Registry credentials and artifacts",
|
||||
},
|
||||
{
|
||||
field: "manage_billing",
|
||||
label: "Manage Billing",
|
||||
@@ -147,9 +152,25 @@ describe("AddRoleForm", () => {
|
||||
// Then
|
||||
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Manage Lighthouse AI")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Manage Registry")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits manage_registry when granted in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<AddRoleForm groups={[]} />);
|
||||
|
||||
// When
|
||||
await user.type(screen.getByPlaceholderText("Enter role name"), "New role");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Manage Registry" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Role" }));
|
||||
|
||||
// Then
|
||||
expect(submittedFormData().get("manage_registry")).toBe("true");
|
||||
});
|
||||
|
||||
it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
@@ -193,6 +214,7 @@ describe("AddRoleForm", () => {
|
||||
expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(submittedFormData().has("manage_registry")).toBe(false);
|
||||
});
|
||||
|
||||
it("navigates back to roles when cancel is clicked", async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
|
||||
manage_billing: false,
|
||||
manage_alerts: false,
|
||||
manage_lighthouse_ai_configuration: false,
|
||||
manage_registry: false,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -56,6 +57,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
|
||||
"manage_lighthouse_ai_configuration",
|
||||
String(values.manage_lighthouse_ai_configuration),
|
||||
);
|
||||
formData.append("manage_registry", String(values.manage_registry));
|
||||
}
|
||||
|
||||
if (values.groups && values.groups.length > 0) {
|
||||
|
||||
@@ -68,6 +68,11 @@ vi.mock("@/lib", () => ({
|
||||
description:
|
||||
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
|
||||
},
|
||||
{
|
||||
field: "manage_registry",
|
||||
label: "Manage Registry",
|
||||
description: "Allows managing tenant Registry credentials and artifacts",
|
||||
},
|
||||
{
|
||||
field: "manage_billing",
|
||||
label: "Manage Billing",
|
||||
@@ -97,9 +102,11 @@ beforeAll(() => {
|
||||
|
||||
const roleData = ({
|
||||
manageProviders = false,
|
||||
manageRegistry = false,
|
||||
unlimitedVisibility = false,
|
||||
}: {
|
||||
manageProviders?: boolean;
|
||||
manageRegistry?: boolean;
|
||||
unlimitedVisibility?: boolean;
|
||||
} = {}) => ({
|
||||
data: {
|
||||
@@ -109,6 +116,7 @@ const roleData = ({
|
||||
manage_account: false,
|
||||
manage_providers: manageProviders,
|
||||
manage_integrations: false,
|
||||
manage_registry: manageRegistry,
|
||||
manage_scans: false,
|
||||
unlimited_visibility: unlimitedVisibility,
|
||||
groups: [],
|
||||
@@ -139,6 +147,19 @@ describe("EditRoleForm", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("retains manage_registry when updating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
const user = userEvent.setup();
|
||||
renderEditRoleForm({ manageRegistry: true });
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Update Role" }));
|
||||
|
||||
// Then
|
||||
expect(submittedFormData().get("manage_registry")).toBe("true");
|
||||
});
|
||||
|
||||
it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
@@ -186,6 +207,7 @@ describe("EditRoleForm", () => {
|
||||
expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(submittedFormData().has("manage_registry")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the subtle Unlimited Visibility description inside Visibility", () => {
|
||||
|
||||
@@ -35,6 +35,9 @@ export const EditRoleForm = ({
|
||||
|
||||
const defaultValues: DefaultValues<RoleFormValues> = {
|
||||
...roleData.data.attributes,
|
||||
...(isCloudEnvironment && {
|
||||
manage_registry: roleData.data.attributes.manage_registry ?? false,
|
||||
}),
|
||||
groups:
|
||||
roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || [],
|
||||
};
|
||||
@@ -62,6 +65,7 @@ export const EditRoleForm = ({
|
||||
updatedFields.manage_alerts = values.manage_alerts;
|
||||
updatedFields.manage_lighthouse_ai_configuration =
|
||||
values.manage_lighthouse_ai_configuration;
|
||||
updatedFields.manage_registry = values.manage_registry;
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -108,7 +108,7 @@ const ToastDescription = React.forwardRef<
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"max-h-48 overflow-x-hidden overflow-y-auto text-sm break-all whitespace-pre-wrap opacity-90",
|
||||
"max-h-48 overflow-x-hidden overflow-y-auto text-sm break-normal wrap-anywhere whitespace-pre-wrap opacity-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
import { render } from "vitest-browser-react";
|
||||
|
||||
import { Toaster } from "./Toaster";
|
||||
import { toast } from "./use-toast";
|
||||
|
||||
const MESSAGE =
|
||||
"Provider connection failed. Check your credentials and try connecting again.";
|
||||
const LONG_URL = `https://example.com/${"provider-identifier".repeat(16)}`;
|
||||
const DESCRIPTION = `${MESSAGE}\n${LONG_URL}\nExplicit final line`;
|
||||
|
||||
describe("toast text wrapping", () => {
|
||||
afterEach(async () => {
|
||||
await page.viewport(1280, 800);
|
||||
});
|
||||
|
||||
it.each([1280, 393])(
|
||||
"preserves words and contains long URLs at %ipx",
|
||||
async (width) => {
|
||||
// Given
|
||||
await page.viewport(width, 800);
|
||||
toast({
|
||||
title: "Connection test failed",
|
||||
description: DESCRIPTION,
|
||||
duration: Infinity,
|
||||
});
|
||||
|
||||
// When
|
||||
const screen = await render(<Toaster />);
|
||||
const description = screen.getByText(DESCRIPTION, { exact: true });
|
||||
await expect.element(description).toBeVisible();
|
||||
const element = description.element();
|
||||
const text = element.firstChild!;
|
||||
const range = document.createRange();
|
||||
|
||||
// Then: regular words fit on one line rather than breaking mid-word.
|
||||
for (const match of Array.from(MESSAGE.matchAll(/\S+/g))) {
|
||||
range.setStart(text, match.index);
|
||||
range.setEnd(text, match.index + match[0].length);
|
||||
expect(Array.from(range.getClientRects()), match[0]).toHaveLength(1);
|
||||
}
|
||||
|
||||
// Long unbroken strings wrap without clipping or horizontal scrolling.
|
||||
range.setStart(text, MESSAGE.length + 1);
|
||||
range.setEnd(text, MESSAGE.length + 1 + LONG_URL.length);
|
||||
const urlLines = Array.from(range.getClientRects());
|
||||
expect(urlLines.length).toBeGreaterThan(1);
|
||||
const bounds = element.getBoundingClientRect();
|
||||
expect(urlLines.every((line) => line.right <= bounds.right + 1)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(element.scrollWidth).toBeLessThanOrEqual(element.clientWidth);
|
||||
|
||||
// Explicit line breaks are preserved and tall descriptions remain scrollable.
|
||||
range.setStart(text, DESCRIPTION.indexOf("Explicit"));
|
||||
range.setEnd(text, DESCRIPTION.length);
|
||||
expect(range.getBoundingClientRect().top).toBeGreaterThan(
|
||||
urlLines.at(-1)!.top,
|
||||
);
|
||||
expect(getComputedStyle(element).overflowY).toBe("auto");
|
||||
expect(element.clientHeight).toBeLessThanOrEqual(192);
|
||||
await page.screenshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -30,7 +30,8 @@ describe("Toaster", () => {
|
||||
"max-h-48",
|
||||
"overflow-x-hidden",
|
||||
"overflow-y-auto",
|
||||
"break-all",
|
||||
"wrap-anywhere",
|
||||
"break-normal",
|
||||
"whitespace-pre-wrap",
|
||||
);
|
||||
expect(description.parentElement).toHaveClass(
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
|
||||
import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler";
|
||||
import { integrationConnectionTaskHandler } from "@/components/integrations/integration-connection-task-handler";
|
||||
import { registryArtifactTaskHandler } from "@/components/registry/registry-artifact-task-handler";
|
||||
import { registryCredentialTaskHandler } from "@/components/registry/registry-credential-task-handler";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { REGISTRY_ARTIFACT_TASK_KIND } from "@/lib/registry/artifact-execution";
|
||||
import { REGISTRY_CREDENTIAL_TASK_KIND } from "@/lib/registry/credential-task";
|
||||
import {
|
||||
registerTaskKindHandler,
|
||||
resumePendingTasks,
|
||||
@@ -26,6 +30,14 @@ import {
|
||||
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
|
||||
registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
|
||||
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
|
||||
registerTaskKindHandler(
|
||||
REGISTRY_ARTIFACT_TASK_KIND,
|
||||
registryArtifactTaskHandler,
|
||||
);
|
||||
registerTaskKindHandler(
|
||||
REGISTRY_CREDENTIAL_TASK_KIND,
|
||||
registryCredentialTaskHandler,
|
||||
);
|
||||
registerTaskKindHandler(
|
||||
INTEGRATION_CONNECTION_TASK_KIND,
|
||||
integrationConnectionTaskHandler,
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useAuth() {
|
||||
manage_billing: false,
|
||||
manage_alerts: false,
|
||||
manage_lighthouse_ai_configuration: false,
|
||||
manage_registry: false,
|
||||
unlimited_visibility: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
||||
vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1" }));
|
||||
|
||||
import { fetchCurrentUser } from "./current-user";
|
||||
|
||||
const role = (manage_registry: unknown) => ({
|
||||
type: "roles",
|
||||
id: "role-1",
|
||||
attributes: { manage_registry },
|
||||
});
|
||||
const document = (roles: unknown) => ({
|
||||
data: {
|
||||
type: "users",
|
||||
id: "user-1",
|
||||
attributes: { name: "Jane", email: "jane@example.com" },
|
||||
},
|
||||
included: roles,
|
||||
});
|
||||
const reply = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status });
|
||||
|
||||
describe("fetchCurrentUser", () => {
|
||||
beforeEach(() => vi.stubGlobal("fetch", fetchMock));
|
||||
|
||||
it("accepts one current exact-true role without caching", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(reply(document([role(true)])));
|
||||
const controller = new AbortController();
|
||||
// When
|
||||
const result = await fetchCurrentUser("access-token", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Then
|
||||
expect(result.manageRegistry).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/users/me?include=roles",
|
||||
expect.objectContaining({ cache: "no-store", signal: controller.signal }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[undefined, undefined],
|
||||
["true", undefined],
|
||||
])(
|
||||
"keeps only exact boolean authority for %j",
|
||||
async (permission, expected) => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(reply(document([role(permission)])));
|
||||
// When / Then
|
||||
await expect(fetchCurrentUser("access-token")).resolves.toMatchObject({
|
||||
manageRegistry: expected,
|
||||
permissions: { manage_registry: permission === true },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("combines exact-true permissions from every assigned role", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(
|
||||
reply(
|
||||
document([
|
||||
{
|
||||
...role(false),
|
||||
attributes: {
|
||||
manage_providers: true,
|
||||
manage_scans: false,
|
||||
manage_registry: false,
|
||||
manage_users: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
...role(true),
|
||||
id: "role-2",
|
||||
attributes: {
|
||||
manage_providers: false,
|
||||
manage_scans: true,
|
||||
manage_registry: true,
|
||||
manage_users: 1,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await fetchCurrentUser("access-token");
|
||||
|
||||
// Then
|
||||
expect(result.permissions).toMatchObject({
|
||||
manage_providers: true,
|
||||
manage_scans: true,
|
||||
manage_registry: true,
|
||||
manage_users: false,
|
||||
manage_account: false,
|
||||
});
|
||||
expect(result.manageRegistry).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ assignments: [true, false], expected: true },
|
||||
{ assignments: [true, undefined], expected: true },
|
||||
{ assignments: [false, false], expected: false },
|
||||
{ assignments: [false, undefined], expected: undefined },
|
||||
{ assignments: [false, "true"], expected: undefined },
|
||||
])(
|
||||
"resolves Registry authority across $assignments",
|
||||
async ({ assignments, expected }) => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(
|
||||
reply(
|
||||
document(
|
||||
assignments.map((permission, index) => ({
|
||||
...role(permission),
|
||||
id: `role-${index}`,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When / Then
|
||||
await expect(fetchCurrentUser("access-token")).resolves.toMatchObject({
|
||||
manageRegistry: expected,
|
||||
permissions: { manage_registry: expected === true },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[document([]), 200],
|
||||
[{ data: { type: "users" } }, 200],
|
||||
[document([role(true)]), 401],
|
||||
[document([role(true)]), 403],
|
||||
[document([role(true)]), 500],
|
||||
])(
|
||||
"rejects absent, malformed, or unsuccessful evidence",
|
||||
async (body, status) => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(reply(body, status));
|
||||
// When / Then
|
||||
await expect(fetchCurrentUser("access-token")).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { UserMeError } from "@/lib/auth-errors";
|
||||
import { PERMISSION_KEY, type RolePermissionAttributes } from "@/types/users";
|
||||
|
||||
const currentUserDocumentSchema = z.object({
|
||||
data: z.object({
|
||||
type: z.literal("users"),
|
||||
id: z.string().min(1),
|
||||
attributes: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
company_name: z.string().optional(),
|
||||
date_joined: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
included: z.array(
|
||||
z.object({
|
||||
type: z.literal("roles"),
|
||||
id: z.string().min(1),
|
||||
attributes: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export interface CurrentUser {
|
||||
name: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
dateJoined?: string;
|
||||
permissions: RolePermissionAttributes;
|
||||
manageRegistry: true | false | undefined;
|
||||
}
|
||||
|
||||
const toPermissions = (
|
||||
roles: readonly Record<string, unknown>[],
|
||||
): RolePermissionAttributes =>
|
||||
Object.fromEntries(
|
||||
Object.values(PERMISSION_KEY).map((key) => [
|
||||
key,
|
||||
roles.some((attributes) => attributes[key] === true),
|
||||
]),
|
||||
) as RolePermissionAttributes;
|
||||
|
||||
export async function fetchCurrentUser(
|
||||
accessToken: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<CurrentUser> {
|
||||
if (!accessToken.trim()) throw new Error("Current user token is required");
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}/users/me?include=roles`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: options.signal,
|
||||
headers: {
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new UserMeError("Unable to load user");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
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(message, response.status);
|
||||
}
|
||||
|
||||
const parsed = currentUserDocumentSchema.safeParse(
|
||||
await response.json().catch(() => undefined),
|
||||
);
|
||||
if (!parsed.success) throw new Error("Malformed current user response");
|
||||
|
||||
const roles = parsed.data.included.map((role) => role.attributes);
|
||||
if (roles.length === 0) {
|
||||
throw new Error("Missing current user role");
|
||||
}
|
||||
|
||||
const permissions = toPermissions(roles);
|
||||
return {
|
||||
name: parsed.data.data.attributes.name,
|
||||
email: parsed.data.data.attributes.email,
|
||||
company: parsed.data.data.attributes.company_name,
|
||||
dateJoined: parsed.data.data.attributes.date_joined,
|
||||
permissions,
|
||||
manageRegistry: permissions.manage_registry
|
||||
? true
|
||||
: roles.every((attributes) => attributes.manage_registry === false)
|
||||
? false
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -4,6 +4,7 @@ const POSTHOG_CSP_SOURCE = "https://*.posthog.com";
|
||||
|
||||
interface CspOptions {
|
||||
cloudEnabled: boolean;
|
||||
registryImageOrigins?: string[];
|
||||
posthogEnabled: boolean;
|
||||
posthogKey: string | null;
|
||||
posthogIngestionHost: string | null;
|
||||
@@ -33,6 +34,7 @@ const getPosthogToolbarUiSource = (
|
||||
|
||||
export function getCspHeader({
|
||||
cloudEnabled,
|
||||
registryImageOrigins = [],
|
||||
posthogEnabled,
|
||||
posthogKey,
|
||||
posthogIngestionHost,
|
||||
@@ -66,7 +68,7 @@ export function getCspHeader({
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}${toolbarUiSource};
|
||||
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource};
|
||||
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource}${toolbarUiSource};
|
||||
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${registryImageOrigins.map((origin) => ` ${origin}`).join("")}${posthogSource}${toolbarUiSource};
|
||||
font-src 'self'${toolbarPosthogSource};
|
||||
style-src 'self' 'unsafe-inline'${toolbarPosthogSource};
|
||||
${toolbarMediaSource}
|
||||
|
||||
@@ -475,6 +475,11 @@ export const permissionFormFields: PermissionInfo[] = [
|
||||
description:
|
||||
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
|
||||
},
|
||||
{
|
||||
field: "manage_registry",
|
||||
label: "Manage Registry",
|
||||
description: "Allows managing tenant Registry credentials and artifacts",
|
||||
},
|
||||
|
||||
{
|
||||
field: "manage_billing",
|
||||
|
||||
@@ -13,6 +13,7 @@ const attributes = {
|
||||
manage_billing: false,
|
||||
manage_alerts: true,
|
||||
manage_lighthouse_ai_configuration: true,
|
||||
manage_registry: true,
|
||||
unlimited_visibility: false,
|
||||
} satisfies RolePermissionAttributes;
|
||||
|
||||
@@ -21,6 +22,34 @@ describe("getRolePermissions", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("includes Manage Registry in Prowler Cloud when role attributes provide it", () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
// When
|
||||
const permissions = getRolePermissions(attributes);
|
||||
|
||||
// Then
|
||||
expect(permissions).toContainEqual({
|
||||
key: "manage_registry",
|
||||
label: "Manage Registry",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Manage Registry outside Prowler Cloud", () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
|
||||
// When
|
||||
const permissions = getRolePermissions(attributes);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
permissions.some((permission) => permission.key === "manage_registry"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("includes Manage Alerts in Prowler Cloud when role attributes provide it", () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
@@ -72,6 +72,11 @@ export const getRolePermissions = (attributes: RolePermissionAttributes) => {
|
||||
label: "Manage Lighthouse AI",
|
||||
enabled: attributes.manage_lighthouse_ai_configuration ?? false,
|
||||
},
|
||||
{
|
||||
key: "manage_registry",
|
||||
label: "Manage Registry",
|
||||
enabled: attributes.manage_registry === true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"description": "Static OpenAI Administration API credentials.\n\nOne pydantic model per secret type the provider accepts. The docstring\ndocuments the secret type; ``Field(description=...)`` documents each field;\nrequired fields have no default, optional ones use a default. Prowler reads\nthis (via ``OpenAIProvider.get_credentials_schema``) to validate the stored\nsecret and to document its shape.\n\nThe two secret fields are ``organization_id`` and ``platform_api_key``.\n``base_url`` is an optional non-secret override kept here so the credential\nshape stays the single source of truth across the light (API/web) and the\nruntime (worker) sides.",
|
||||
"properties": {
|
||||
"organization_id": {
|
||||
"description": "OpenAI organization ID, e.g. org-xxxxxxxxxxxxxxxx",
|
||||
"title": "Organization Id",
|
||||
"type": "string"
|
||||
},
|
||||
"platform_api_key": {
|
||||
"description": "OpenAI platform (Administration) API key used to read organization users and admin API keys",
|
||||
"title": "Platform Api Key",
|
||||
"type": "string"
|
||||
},
|
||||
"base_url": {
|
||||
"default": "https://api.openai.com/v1",
|
||||
"description": "OpenAI API base URL (defaults to https://api.openai.com/v1)",
|
||||
"title": "Base Url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["organization_id", "platform_api_key"],
|
||||
"title": "OpenAIStaticCredentials",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"description": "API key.\n\nLong-lived key issued from the provider's console.",
|
||||
"properties": {
|
||||
"api_url": {
|
||||
"description": "Base URL of the provider API.",
|
||||
"examples": ["https://api.acme.com"],
|
||||
"title": "API URL",
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"description": "Key used to authenticate against the provider API.",
|
||||
"format": "password",
|
||||
"title": "API Key",
|
||||
"type": "string",
|
||||
"writeOnly": true
|
||||
},
|
||||
"ca_bundle": {
|
||||
"default": "",
|
||||
"description": "Certificates of a private authority, in PEM format, for an API whose certificate no public authority signed. Leave empty to use public trust.",
|
||||
"title": "CA Bundle",
|
||||
"type": "string",
|
||||
"x-prowler-widget": "textarea"
|
||||
},
|
||||
"verify_tls": {
|
||||
"default": true,
|
||||
"description": "Whether to verify the API's TLS certificate.",
|
||||
"title": "Verify TLS",
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"default": 30,
|
||||
"description": "Seconds to wait for the API before giving up.",
|
||||
"maximum": 300,
|
||||
"minimum": 1,
|
||||
"title": "Timeout",
|
||||
"type": "integer"
|
||||
},
|
||||
"auth_scheme": {
|
||||
"description": "How the key is sent on each request.",
|
||||
"enum": ["bearer", "basic"],
|
||||
"title": "Authentication Scheme",
|
||||
"type": "string",
|
||||
"default": "bearer"
|
||||
}
|
||||
},
|
||||
"required": ["api_url", "api_key"],
|
||||
"title": "TemplateStaticCredentials",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import openaiSchema from "./fixtures/openai-credential-schema.json";
|
||||
import templateSchema from "./fixtures/template-credential-schema.json";
|
||||
import {
|
||||
parseRegistryCredentialSchema,
|
||||
REGISTRY_CREDENTIAL_SCHEMA_LIMITS,
|
||||
} from "./provider-credential-schema";
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
api_key: {
|
||||
title: "API Key",
|
||||
description: "The key.",
|
||||
type: "string",
|
||||
format: "password",
|
||||
writeOnly: true,
|
||||
},
|
||||
scheme: {
|
||||
title: "Scheme",
|
||||
type: "string",
|
||||
enum: ["bearer", "basic"],
|
||||
default: "bearer",
|
||||
},
|
||||
notes: {
|
||||
title: "Notes",
|
||||
type: "string",
|
||||
"x-prowler-widget": "textarea",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
required: ["api_key"],
|
||||
};
|
||||
|
||||
describe("parseRegistryCredentialSchema", () => {
|
||||
it("accepts the installed Template 0.2.5 schema with typed fields and examples", () => {
|
||||
// Given / When
|
||||
const result = parseRegistryCredentialSchema(templateSchema);
|
||||
|
||||
// Then
|
||||
expect(result?.fields.map(({ name, kind }) => [name, kind])).toEqual([
|
||||
["api_url", "text"],
|
||||
["api_key", "password"],
|
||||
["ca_bundle", "textarea"],
|
||||
["verify_tls", "checkbox"],
|
||||
["timeout_seconds", "integer"],
|
||||
["auth_scheme", "select"],
|
||||
]);
|
||||
expect(result?.fields[0]).toMatchObject({
|
||||
placeholder: "https://api.acme.com",
|
||||
required: true,
|
||||
});
|
||||
expect(result?.fields[3].defaultValue).toBe(true);
|
||||
expect(result?.fields[4]).toMatchObject({
|
||||
defaultValue: 30,
|
||||
minimum: 1,
|
||||
maximum: 300,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
"api_key",
|
||||
"platform_api_key",
|
||||
"platform-api-key",
|
||||
"apiKey",
|
||||
"platformApiKey",
|
||||
"platformAPIKey",
|
||||
"API_KEY",
|
||||
"apikey",
|
||||
])("masks the plain API key field %s without schema annotations", (name) => {
|
||||
// Given / When
|
||||
const result = parseRegistryCredentialSchema({
|
||||
type: "object",
|
||||
properties: { [name]: { type: "string" } },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result?.fields[0].kind).toBe("password");
|
||||
});
|
||||
|
||||
it("keeps identifiers and explicitly configured widgets unchanged", () => {
|
||||
// Given / When
|
||||
const result = parseRegistryCredentialSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
api_key_id: { type: "string" },
|
||||
api_key_url: { type: "string" },
|
||||
selected_api_key: { type: "string", enum: ["primary", "secondary"] },
|
||||
multiline_api_key: { type: "string", "x-prowler-widget": "textarea" },
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result?.fields.map(({ kind }) => kind)).toEqual([
|
||||
"text",
|
||||
"text",
|
||||
"select",
|
||||
"textarea",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts the installed OpenAI schema with its full description", () => {
|
||||
// Given / When: the materialized OpenAI 0.1.5 schema contains a long docstring.
|
||||
const result = parseRegistryCredentialSchema(openaiSchema);
|
||||
|
||||
// Then
|
||||
expect(result?.fields.map(({ name }) => name)).toEqual([
|
||||
"organization_id",
|
||||
"platform_api_key",
|
||||
"base_url",
|
||||
]);
|
||||
expect(result?.fields[2].defaultValue).toBe("https://api.openai.com/v1");
|
||||
expect(result?.fields.map(({ kind }) => kind)).toEqual([
|
||||
"text",
|
||||
"password",
|
||||
"text",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves long field descriptions without treating them as input limits", () => {
|
||||
// Given
|
||||
const description = openaiSchema.description;
|
||||
|
||||
// When
|
||||
const result = parseRegistryCredentialSchema({
|
||||
...schema,
|
||||
properties: { token: { type: "string", description } },
|
||||
required: ["token"],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result?.fields[0].description).toBe(description);
|
||||
});
|
||||
|
||||
it("accepts the observed flat credential schema and preserves property order", () => {
|
||||
// Given
|
||||
const result = parseRegistryCredentialSchema(schema);
|
||||
|
||||
// When / Then
|
||||
|
||||
expect(result?.fields.map(({ name, kind }) => [name, kind])).toEqual([
|
||||
["api_key", "password"],
|
||||
["scheme", "select"],
|
||||
["notes", "textarea"],
|
||||
]);
|
||||
|
||||
expect(result?.fields[0]).toMatchObject({
|
||||
description: "The key.",
|
||||
label: "API Key",
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["$ref", { $ref: "#/$defs/credential" }],
|
||||
["$defs", { $defs: {} }],
|
||||
["definitions", { definitions: {} }],
|
||||
["combinators", { anyOf: [] }],
|
||||
["additional properties", { additionalProperties: true }],
|
||||
])("rejects risky root keywords: %s", (_name, keyword) => {
|
||||
expect(parseRegistryCredentialSchema({ ...schema, ...keyword })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["nested objects", { type: "object", properties: {} }],
|
||||
["arrays", { type: "array" }],
|
||||
["nullable unions", { type: ["string", "null"] }],
|
||||
["unsupported formats", { type: "string", format: "email" }],
|
||||
["passwords without writeOnly", { type: "string", format: "password" }],
|
||||
["maps", { type: "string", additionalProperties: true }],
|
||||
])("rejects unsupported fields: %s", (_name, apiKey) => {
|
||||
expect(
|
||||
parseRegistryCredentialSchema({
|
||||
...schema,
|
||||
properties: { ...schema.properties, api_key: apiKey },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "boolean", default: "true" },
|
||||
{ type: "boolean", enum: [true] },
|
||||
{ type: "integer", default: "30" },
|
||||
{ type: "integer", default: 1.5 },
|
||||
{ type: "integer", minimum: 1, default: 0 },
|
||||
{ type: "integer", maximum: 300, default: 301 },
|
||||
{ type: "integer", minimum: 10, maximum: 1 },
|
||||
{ type: "integer", minimum: "1" },
|
||||
{ type: "integer", maximum: Infinity },
|
||||
{ type: "integer", multipleOf: 5 },
|
||||
{ type: "string", examples: "not-an-array" },
|
||||
{ type: "string", examples: [{ value: "unexpected" }] },
|
||||
])(
|
||||
"rejects malformed annotations or unsupported constraints: %j",
|
||||
(property) => {
|
||||
expect(
|
||||
parseRegistryCredentialSchema({
|
||||
type: "object",
|
||||
properties: { field: property },
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"invalid defaults",
|
||||
{ type: "string", enum: ["bearer", "basic"], default: "token" },
|
||||
],
|
||||
["duplicate values", { type: "string", enum: ["bearer", "bearer"] }],
|
||||
])("rejects enum definitions with %s", (_name, scheme) => {
|
||||
expect(
|
||||
parseRegistryCredentialSchema({
|
||||
...schema,
|
||||
properties: { ...schema.properties, scheme },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsafe names, invalid required fields, and over-limit metadata", () => {
|
||||
// Given
|
||||
|
||||
const fields = Object.fromEntries(
|
||||
Array.from(
|
||||
{ length: REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_FIELDS + 1 },
|
||||
(_, index) => [`field${index}`, { type: "string" }],
|
||||
),
|
||||
);
|
||||
|
||||
const cases = [
|
||||
{ ...schema, required: ["missing"] },
|
||||
{ ...schema, description: { invalid: "not text" } },
|
||||
{
|
||||
...schema,
|
||||
properties: { api_key: { type: "string", description: 123 } },
|
||||
},
|
||||
JSON.parse(
|
||||
'{"type":"object","properties":{"__proto__":{"type":"string"}}}',
|
||||
),
|
||||
{ type: "object", properties: fields },
|
||||
{
|
||||
...schema,
|
||||
properties: {
|
||||
...schema.properties,
|
||||
notes: {
|
||||
...schema.properties.notes,
|
||||
title: "a".repeat(
|
||||
REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_TEXT_LENGTH + 1,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// When / Then
|
||||
|
||||
expect(cases.map(parseRegistryCredentialSchema)).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
const FIELD_KIND = {
|
||||
TEXT: "text",
|
||||
PASSWORD: "password",
|
||||
SELECT: "select",
|
||||
TEXTAREA: "textarea",
|
||||
CHECKBOX: "checkbox",
|
||||
INTEGER: "integer",
|
||||
} as const;
|
||||
|
||||
export const REGISTRY_CREDENTIAL_SCHEMA_LIMITS = {
|
||||
MAX_FIELDS: 12,
|
||||
MAX_NAME_LENGTH: 50,
|
||||
MAX_TEXT_LENGTH: 200,
|
||||
MAX_ENUM_OPTIONS: 20,
|
||||
} as const;
|
||||
|
||||
type FieldKind = (typeof FIELD_KIND)[keyof typeof FIELD_KIND];
|
||||
export type RegistryCredentialValue = string | boolean | number;
|
||||
|
||||
export interface RegistryCredentialField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
readonly kind: FieldKind;
|
||||
readonly options?: readonly string[];
|
||||
readonly required: boolean;
|
||||
readonly defaultValue?: RegistryCredentialValue;
|
||||
readonly placeholder?: string;
|
||||
readonly minimum?: number;
|
||||
readonly maximum?: number;
|
||||
}
|
||||
|
||||
export interface RegistryCredentialSchema {
|
||||
readonly fields: readonly RegistryCredentialField[];
|
||||
}
|
||||
|
||||
const ROOT = new Set("type title description properties required".split(" "));
|
||||
const FIELD = new Set(
|
||||
"title description type format writeOnly enum default examples x-prowler-widget".split(
|
||||
" ",
|
||||
),
|
||||
);
|
||||
const BOOLEAN_FIELD = new Set("title description type default".split(" "));
|
||||
const INTEGER_FIELD = new Set(
|
||||
"title description type default minimum maximum".split(" "),
|
||||
);
|
||||
const FORBIDDEN_NAMES = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
||||
|
||||
// Some installed artifacts expose API keys as plain strings without secret metadata.
|
||||
function isApiKeyField(name: string): boolean {
|
||||
const normalizedName = name
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.replace(/-/g, "_")
|
||||
.toLowerCase();
|
||||
return /(?:^|_)api_?key$/.test(normalizedName);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
);
|
||||
}
|
||||
|
||||
function isText(value: unknown, allowEmpty = false): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length <= REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_TEXT_LENGTH &&
|
||||
(allowEmpty || value.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function hasOnly(
|
||||
record: Record<string, unknown>,
|
||||
allowed: Set<string>,
|
||||
): boolean {
|
||||
for (const key in record) {
|
||||
if (Object.hasOwn(record, key) && !allowed.has(key)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseRegistryCredentialSchema(
|
||||
value: unknown,
|
||||
): RegistryCredentialSchema | null {
|
||||
if (!isRecord(value) || !hasOnly(value, ROOT) || value.type !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
(value.title !== undefined && !isText(value.title)) ||
|
||||
(value.description !== undefined && typeof value.description !== "string")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties = value.properties;
|
||||
if (!isRecord(properties)) return null;
|
||||
const entries: [string, unknown][] = [];
|
||||
for (const name in properties) {
|
||||
if (!Object.hasOwn(properties, name)) continue;
|
||||
if (entries.length === REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_FIELDS)
|
||||
return null;
|
||||
entries.push([name, properties[name]]);
|
||||
}
|
||||
|
||||
const required = value.required ?? [];
|
||||
if (
|
||||
!Array.isArray(required) ||
|
||||
required.length > entries.length ||
|
||||
!required.every((name) => typeof name === "string")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const requiredNames = new Set(required);
|
||||
if (
|
||||
requiredNames.size !== required.length ||
|
||||
required.some((name) => !Object.hasOwn(properties, name))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields: RegistryCredentialField[] = [];
|
||||
for (const [name, property] of entries) {
|
||||
if (
|
||||
FORBIDDEN_NAMES.has(name) ||
|
||||
!FIELD_NAME.test(name) ||
|
||||
name.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_NAME_LENGTH ||
|
||||
!isRecord(property)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = property.title ?? name;
|
||||
const description = property.description;
|
||||
if (
|
||||
!isText(label) ||
|
||||
(description !== undefined && typeof description !== "string")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const baseField = {
|
||||
name,
|
||||
label,
|
||||
...(description ? { description } : {}),
|
||||
required: requiredNames.has(name),
|
||||
};
|
||||
const defaultValue = property.default;
|
||||
if (property.type === "boolean") {
|
||||
if (
|
||||
!hasOnly(property, BOOLEAN_FIELD) ||
|
||||
(defaultValue !== undefined && typeof defaultValue !== "boolean")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
fields.push({
|
||||
...baseField,
|
||||
kind: FIELD_KIND.CHECKBOX,
|
||||
...(typeof defaultValue === "boolean" ? { defaultValue } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (property.type === "integer") {
|
||||
const { minimum, maximum } = property;
|
||||
if (
|
||||
!hasOnly(property, INTEGER_FIELD) ||
|
||||
(minimum !== undefined && !Number.isSafeInteger(minimum)) ||
|
||||
(maximum !== undefined && !Number.isSafeInteger(maximum)) ||
|
||||
(typeof minimum === "number" &&
|
||||
typeof maximum === "number" &&
|
||||
minimum > maximum) ||
|
||||
(defaultValue !== undefined &&
|
||||
(typeof defaultValue !== "number" ||
|
||||
!Number.isSafeInteger(defaultValue) ||
|
||||
(typeof minimum === "number" && defaultValue < minimum) ||
|
||||
(typeof maximum === "number" && defaultValue > maximum)))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
fields.push({
|
||||
...baseField,
|
||||
kind: FIELD_KIND.INTEGER,
|
||||
...(typeof minimum === "number" ? { minimum } : {}),
|
||||
...(typeof maximum === "number" ? { maximum } : {}),
|
||||
...(typeof defaultValue === "number" ? { defaultValue } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (property.type !== "string" || !hasOnly(property, FIELD)) return null;
|
||||
|
||||
const format = property.format;
|
||||
const widget = property["x-prowler-widget"];
|
||||
const options = property.enum;
|
||||
const examples = property.examples;
|
||||
const password = format === "password" && property.writeOnly === true;
|
||||
if (
|
||||
((format !== undefined || property.writeOnly !== undefined) &&
|
||||
!password) ||
|
||||
(widget !== undefined && widget !== "textarea") ||
|
||||
(defaultValue !== undefined && !isText(defaultValue, true)) ||
|
||||
(examples !== undefined &&
|
||||
(!Array.isArray(examples) ||
|
||||
examples.length >
|
||||
REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_ENUM_OPTIONS ||
|
||||
!examples.every((example) => isText(example, true))))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options !== undefined) {
|
||||
if (
|
||||
format !== undefined ||
|
||||
widget !== undefined ||
|
||||
property.writeOnly !== undefined ||
|
||||
!Array.isArray(options) ||
|
||||
options.length === 0 ||
|
||||
options.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_ENUM_OPTIONS ||
|
||||
!options.every((option) => isText(option)) ||
|
||||
new Set(options).size !== options.length ||
|
||||
(defaultValue !== undefined && !options.includes(defaultValue))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
fields.push({
|
||||
...baseField,
|
||||
kind: FIELD_KIND.SELECT,
|
||||
options,
|
||||
...(typeof defaultValue === "string" ? { defaultValue } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
widget !== undefined &&
|
||||
(format !== undefined || property.writeOnly !== undefined)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
fields.push({
|
||||
...baseField,
|
||||
kind: password
|
||||
? FIELD_KIND.PASSWORD
|
||||
: widget === "textarea"
|
||||
? FIELD_KIND.TEXTAREA
|
||||
: isApiKeyField(name)
|
||||
? FIELD_KIND.PASSWORD
|
||||
: FIELD_KIND.TEXT,
|
||||
...(Array.isArray(examples) && typeof examples[0] === "string"
|
||||
? { placeholder: examples[0] }
|
||||
: {}),
|
||||
...(typeof defaultValue === "string" ? { defaultValue } : {}),
|
||||
});
|
||||
}
|
||||
return { fields };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import templateSchema from "./fixtures/template-credential-schema.json";
|
||||
import { parseRegistryCredentialSchema } from "./provider-credential-schema";
|
||||
import {
|
||||
getCredentialDefaults,
|
||||
validateCredentialValues,
|
||||
} from "./provider-credential-values";
|
||||
|
||||
const schema = parseRegistryCredentialSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string", format: "password", writeOnly: true },
|
||||
region: { type: "string", enum: ["eu", "us"], default: "eu" },
|
||||
notes: { type: "string", "x-prowler-widget": "textarea" },
|
||||
},
|
||||
required: ["token"],
|
||||
})!;
|
||||
|
||||
describe("dynamic credential validation", () => {
|
||||
const template = () => parseRegistryCredentialSchema(templateSchema)!;
|
||||
const templateValues = {
|
||||
api_url: "https://api.example.test",
|
||||
api_key: "fixture-key-not-a-secret",
|
||||
verify_tls: false,
|
||||
timeout_seconds: "60",
|
||||
};
|
||||
|
||||
it("preserves typed Template defaults and submits booleans and integers", () => {
|
||||
// Given / When / Then
|
||||
expect(getCredentialDefaults(template())).toEqual({
|
||||
ca_bundle: "",
|
||||
verify_tls: true,
|
||||
timeout_seconds: 30,
|
||||
auth_scheme: "bearer",
|
||||
});
|
||||
expect(validateCredentialValues(template(), templateValues)).toEqual({
|
||||
valid: true,
|
||||
secret: { ...templateValues, timeout_seconds: 60 },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([1, 300, "1", "300"])("accepts timeout boundary %j", (timeout) => {
|
||||
expect(
|
||||
validateCredentialValues(template(), {
|
||||
...templateValues,
|
||||
timeout_seconds: timeout,
|
||||
}),
|
||||
).toMatchObject({
|
||||
valid: true,
|
||||
secret: { timeout_seconds: Number(timeout) },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
301,
|
||||
1.5,
|
||||
"1.5",
|
||||
" ",
|
||||
"1second",
|
||||
"0x10",
|
||||
true,
|
||||
null,
|
||||
Infinity,
|
||||
NaN,
|
||||
])("rejects invalid Template timeouts: %j", (timeout) => {
|
||||
expect(
|
||||
validateCredentialValues(template(), {
|
||||
...templateValues,
|
||||
timeout_seconds: timeout,
|
||||
}),
|
||||
).toMatchObject({
|
||||
valid: false,
|
||||
errors: { timeout_seconds: expect.any(String) },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["true", "false", 0, 1, null])(
|
||||
"rejects non-boolean TLS values: %j",
|
||||
(verifyTls) => {
|
||||
expect(
|
||||
validateCredentialValues(template(), {
|
||||
...templateValues,
|
||||
verify_tls: verifyTls,
|
||||
}),
|
||||
).toMatchObject({
|
||||
valid: false,
|
||||
errors: { verify_tls: expect.any(String) },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts false and zero for required fields without treating them as missing", () => {
|
||||
const requiredSchema = parseRegistryCredentialSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
retries: { type: "integer" },
|
||||
},
|
||||
required: ["enabled", "retries"],
|
||||
})!;
|
||||
expect(getCredentialDefaults(requiredSchema)).toEqual({ enabled: false });
|
||||
expect(
|
||||
validateCredentialValues(requiredSchema, { enabled: false, retries: 0 }),
|
||||
).toEqual({
|
||||
valid: true,
|
||||
secret: { enabled: false, retries: 0 },
|
||||
errors: {},
|
||||
});
|
||||
expect(validateCredentialValues(requiredSchema, {})).toMatchObject({
|
||||
valid: false,
|
||||
errors: { enabled: expect.any(String), retries: expect.any(String) },
|
||||
});
|
||||
});
|
||||
it("uses declared defaults and preserves credential bytes", () => {
|
||||
expect(getCredentialDefaults(schema)).toEqual({ region: "eu" });
|
||||
expect(
|
||||
validateCredentialValues(schema, { token: " secret ", region: "eu" }),
|
||||
).toEqual({
|
||||
valid: true,
|
||||
secret: { token: " secret ", region: "eu" },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{},
|
||||
{ token: "" },
|
||||
{ token: "secret", region: "invalid" },
|
||||
{ token: 12 },
|
||||
{ token: "secret", extra: "hidden" },
|
||||
])("rejects invalid or undeclared values: %j", (values) => {
|
||||
expect(validateCredentialValues(schema, values).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
RegistryCredentialSchema,
|
||||
RegistryCredentialValue,
|
||||
} from "./provider-credential-schema";
|
||||
|
||||
export function getCredentialDefaults(
|
||||
schema: RegistryCredentialSchema,
|
||||
): Record<string, RegistryCredentialValue> {
|
||||
return Object.fromEntries(
|
||||
schema.fields.flatMap((field) =>
|
||||
field.defaultValue !== undefined
|
||||
? [[field.name, field.defaultValue]]
|
||||
: field.kind === "checkbox" && field.required
|
||||
? [[field.name, false]]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function validateCredentialValues(
|
||||
schema: RegistryCredentialSchema,
|
||||
values: unknown,
|
||||
):
|
||||
| {
|
||||
valid: true;
|
||||
secret: Record<string, RegistryCredentialValue>;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
| { valid: false; errors: Record<string, string> } {
|
||||
if (!values || typeof values !== "object" || Array.isArray(values))
|
||||
return {
|
||||
valid: false,
|
||||
errors: { _form: "Enter the required credentials." },
|
||||
};
|
||||
const entries = Object.entries(values);
|
||||
if (
|
||||
entries.some(
|
||||
([name]) => !schema.fields.some((field) => field.name === name),
|
||||
)
|
||||
)
|
||||
return {
|
||||
valid: false,
|
||||
errors: { _form: "The credential fields have changed. Reload the form." },
|
||||
};
|
||||
const fields = new Map(entries);
|
||||
const errors: Record<string, string> = {};
|
||||
const secret: Record<string, RegistryCredentialValue> = {};
|
||||
for (const field of schema.fields) {
|
||||
const value = fields.get(field.name);
|
||||
if (value === undefined || (value === "" && field.kind !== "checkbox")) {
|
||||
if (field.required) errors[field.name] = `${field.label} is required`;
|
||||
} else if (field.kind === "checkbox") {
|
||||
if (typeof value !== "boolean") {
|
||||
errors[field.name] = `Enter a valid ${field.label}`;
|
||||
} else {
|
||||
secret[field.name] = value;
|
||||
}
|
||||
} else if (field.kind === "integer") {
|
||||
const number =
|
||||
typeof value === "string" && /^[+-]?\d+$/.test(value)
|
||||
? Number(value)
|
||||
: value;
|
||||
if (
|
||||
typeof number !== "number" ||
|
||||
!Number.isSafeInteger(number) ||
|
||||
(field.minimum !== undefined && number < field.minimum) ||
|
||||
(field.maximum !== undefined && number > field.maximum)
|
||||
) {
|
||||
errors[field.name] = `Enter a valid ${field.label}`;
|
||||
} else {
|
||||
secret[field.name] = number;
|
||||
}
|
||||
} else if (
|
||||
typeof value !== "string" ||
|
||||
(field.options && !field.options.includes(value))
|
||||
) {
|
||||
errors[field.name] = `Enter a valid ${field.label}`;
|
||||
} else {
|
||||
secret[field.name] = value;
|
||||
}
|
||||
}
|
||||
return Object.keys(errors).length > 0
|
||||
? { valid: false, errors }
|
||||
: { valid: true, secret, errors };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { checkConnectionProvider, checkTaskStatus } = vi.hoisted(() => ({
|
||||
checkConnectionProvider: vi.fn(),
|
||||
checkTaskStatus: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/actions/providers/providers", () => ({ checkConnectionProvider }));
|
||||
vi.mock("./helper", () => ({ checkTaskStatus }));
|
||||
|
||||
import { testProviderConnection } from "./provider-helpers";
|
||||
|
||||
describe("provider connection confirmation", () => {
|
||||
beforeEach(() => {
|
||||
checkConnectionProvider.mockResolvedValue({ data: { id: "task" } });
|
||||
});
|
||||
it.each([undefined, {}, { connected: "true" }, { connected: false }])(
|
||||
"does not advance without explicit connected=true: %j",
|
||||
async (result) => {
|
||||
checkTaskStatus.mockResolvedValue({
|
||||
completed: true,
|
||||
task: { data: { attributes: { result } } },
|
||||
});
|
||||
expect((await testProviderConnection("account")).connected).toBe(false);
|
||||
},
|
||||
);
|
||||
it("advances on an explicitly successful connection", async () => {
|
||||
checkTaskStatus.mockResolvedValue({
|
||||
completed: true,
|
||||
task: { data: { attributes: { result: { connected: true } } } },
|
||||
});
|
||||
expect(await testProviderConnection("account")).toEqual({
|
||||
connected: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -212,14 +212,15 @@ export async function testProviderConnection(
|
||||
};
|
||||
}
|
||||
|
||||
// Read from the task the poller already fetched. A completed task with no
|
||||
// readable `connected` counts as connected, as in the batched poller.
|
||||
// Task completion alone does not confirm that the credentials connected.
|
||||
const result = taskResult.task?.data?.attributes?.result;
|
||||
const connected =
|
||||
typeof result?.connected === "boolean" ? result.connected : true;
|
||||
const connected = result?.connected === true;
|
||||
|
||||
return {
|
||||
connected,
|
||||
error: connected ? null : result?.error || "Unknown error",
|
||||
error: connected
|
||||
? null
|
||||
: result?.error ||
|
||||
"Connection was not confirmed. Test the connection again.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchCurrentUserMock } = vi.hoisted(() => ({
|
||||
fetchCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/auth/current-user", () => ({
|
||||
fetchCurrentUser: fetchCurrentUserMock,
|
||||
}));
|
||||
|
||||
import { REGISTRY_ACCESS } from "./access";
|
||||
import {
|
||||
evaluateRegistryAccess,
|
||||
evaluateRegistryProviderAccess,
|
||||
} from "./access.server";
|
||||
|
||||
describe("Registry provider onboarding access", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
});
|
||||
|
||||
it.each([false, undefined])(
|
||||
"allows provider managers without Registry permission (%s)",
|
||||
async (manageRegistry) => {
|
||||
// Given
|
||||
fetchCurrentUserMock.mockResolvedValue({
|
||||
manageRegistry,
|
||||
permissions: { manage_providers: true },
|
||||
});
|
||||
// When / Then
|
||||
await expect(
|
||||
evaluateRegistryProviderAccess("access-token"),
|
||||
).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.ELIGIBLE,
|
||||
});
|
||||
await expect(evaluateRegistryAccess("access-token")).resolves.not.toEqual(
|
||||
{
|
||||
status: REGISTRY_ACCESS.ELIGIBLE,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("denies onboarding to a Registry manager without manage_providers", async () => {
|
||||
// Given
|
||||
fetchCurrentUserMock.mockResolvedValue({
|
||||
manageRegistry: true,
|
||||
permissions: { manage_providers: false },
|
||||
});
|
||||
// When / Then
|
||||
await expect(
|
||||
evaluateRegistryProviderAccess("access-token"),
|
||||
).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.INELIGIBLE,
|
||||
});
|
||||
await expect(evaluateRegistryAccess("access-token")).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.ELIGIBLE,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["false", "true", "access-token"],
|
||||
["true", "false", "access-token"],
|
||||
["true", "true", ""],
|
||||
])(
|
||||
"requires enabled flags and a token: %j / %j / %j",
|
||||
async (cloud, flag, token) => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", cloud);
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", flag);
|
||||
// When / Then
|
||||
await expect(evaluateRegistryProviderAccess(token)).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.INELIGIBLE,
|
||||
});
|
||||
expect(fetchCurrentUserMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("checks current provider permission again after revocation", async () => {
|
||||
// Given
|
||||
fetchCurrentUserMock
|
||||
.mockResolvedValueOnce({ permissions: { manage_providers: true } })
|
||||
.mockResolvedValueOnce({ permissions: { manage_providers: false } });
|
||||
// When / Then
|
||||
await expect(
|
||||
evaluateRegistryProviderAccess("access-token"),
|
||||
).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.ELIGIBLE,
|
||||
});
|
||||
await expect(
|
||||
evaluateRegistryProviderAccess("access-token"),
|
||||
).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.INELIGIBLE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRegistryAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
fetchCurrentUserMock.mockResolvedValue({ manageRegistry: true });
|
||||
});
|
||||
|
||||
it("allows a fresh exact-true current permission without lease metadata", async () => {
|
||||
// Given / When
|
||||
const result = await evaluateRegistryAccess("access-token");
|
||||
// Then
|
||||
expect(result).toStrictEqual({ status: REGISTRY_ACCESS.ELIGIBLE });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "true", "access-token", true, REGISTRY_ACCESS.INELIGIBLE, 0],
|
||||
["true", "false", "access-token", true, REGISTRY_ACCESS.INELIGIBLE, 0],
|
||||
["true", "true", "access-token", false, REGISTRY_ACCESS.INELIGIBLE, 1],
|
||||
["true", "true", "access-token", undefined, REGISTRY_ACCESS.UNKNOWN, 1],
|
||||
["true", "true", "", true, REGISTRY_ACCESS.INELIGIBLE, 0],
|
||||
])(
|
||||
"fails closed without trusting stale JWT authority",
|
||||
async (cloud, flag, token, permission, expected, calls) => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", cloud);
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", flag);
|
||||
fetchCurrentUserMock.mockResolvedValue({ manageRegistry: permission });
|
||||
// When / Then
|
||||
await expect(evaluateRegistryAccess(token)).resolves.toMatchObject({
|
||||
status: expected,
|
||||
});
|
||||
expect(fetchCurrentUserMock).toHaveBeenCalledTimes(calls);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[" true ", "true"],
|
||||
["true", " true "],
|
||||
["\ttrue\n", "\ttrue\n"],
|
||||
])(
|
||||
"accepts whitespace around enabled flags: %j / %j",
|
||||
async (cloud, flag) => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", cloud);
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", flag);
|
||||
// When / Then
|
||||
await expect(evaluateRegistryAccess("access-token")).resolves.toEqual({
|
||||
status: REGISTRY_ACCESS.ELIGIBLE,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("returns unknown for malformed, network, abort, and timeout evidence", async () => {
|
||||
// Given
|
||||
fetchCurrentUserMock
|
||||
.mockResolvedValueOnce({ manageRegistry: undefined })
|
||||
.mockRejectedValueOnce(new Error("network"))
|
||||
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
|
||||
.mockImplementationOnce(
|
||||
(_token, { signal }) =>
|
||||
new Promise((_, reject) => signal.addEventListener("abort", reject)),
|
||||
);
|
||||
// When / Then
|
||||
const expectUnknown = () =>
|
||||
expect(evaluateRegistryAccess("access-token")).resolves.toMatchObject({
|
||||
status: REGISTRY_ACCESS.UNKNOWN,
|
||||
});
|
||||
await expectUnknown();
|
||||
await expectUnknown();
|
||||
await expectUnknown();
|
||||
vi.useFakeTimers();
|
||||
const result = evaluateRegistryAccess("access-token");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await expect(result).resolves.toMatchObject({
|
||||
status: REGISTRY_ACCESS.UNKNOWN,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import "server-only";
|
||||
|
||||
import { fetchCurrentUser, type CurrentUser } from "@/lib/auth/current-user";
|
||||
import { readBoolEnv } from "@/lib/runtime-env";
|
||||
|
||||
import {
|
||||
isRegistryEligible,
|
||||
REGISTRY_ACCESS,
|
||||
type RegistryAccessResult,
|
||||
} from "./access";
|
||||
|
||||
const CURRENT_USER_TIMEOUT_MS = 5_000;
|
||||
|
||||
const hasEnabledProcessFlags = () =>
|
||||
readBoolEnv("UI_CLOUD_ENABLED") && readBoolEnv("UI_REGISTRY_ENABLED");
|
||||
|
||||
export async function evaluateRegistryAccess(
|
||||
accessToken?: string | null,
|
||||
): Promise<RegistryAccessResult> {
|
||||
return evaluatePermission(accessToken, (user) => user.manageRegistry);
|
||||
}
|
||||
|
||||
export async function evaluateRegistryProviderAccess(
|
||||
accessToken?: string | null,
|
||||
): Promise<RegistryAccessResult> {
|
||||
return evaluatePermission(
|
||||
accessToken,
|
||||
(user) => user.permissions.manage_providers,
|
||||
);
|
||||
}
|
||||
|
||||
async function evaluatePermission(
|
||||
accessToken: string | null | undefined,
|
||||
readPermission: (user: CurrentUser) => boolean | undefined,
|
||||
): Promise<RegistryAccessResult> {
|
||||
if (!hasEnabledProcessFlags() || !accessToken?.trim()) {
|
||||
return { status: REGISTRY_ACCESS.INELIGIBLE };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CURRENT_USER_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const currentUser = await fetchCurrentUser(accessToken, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const permission = readPermission(currentUser);
|
||||
if (permission === undefined) {
|
||||
return { status: REGISTRY_ACCESS.UNKNOWN };
|
||||
}
|
||||
return {
|
||||
status: isRegistryEligible(true, true, permission)
|
||||
? REGISTRY_ACCESS.ELIGIBLE
|
||||
: REGISTRY_ACCESS.INELIGIBLE,
|
||||
};
|
||||
} catch {
|
||||
return { status: REGISTRY_ACCESS.UNKNOWN };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isRegistryEligible } from "./access";
|
||||
describe("Registry access", () => {
|
||||
it.each([
|
||||
[true, true, true, true],
|
||||
[false, true, true, false],
|
||||
[true, false, true, false],
|
||||
[true, true, false, false],
|
||||
[true, true, undefined, false],
|
||||
[true, true, "true", false],
|
||||
])(
|
||||
"allows only exact current authority",
|
||||
(cloud, flag, permission, expected) => {
|
||||
expect(isRegistryEligible(cloud, flag, permission)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export const REGISTRY_ACCESS = {
|
||||
ELIGIBLE: "eligible",
|
||||
INELIGIBLE: "ineligible",
|
||||
UNKNOWN: "unknown",
|
||||
} as const;
|
||||
|
||||
export type RegistryAccessStatus =
|
||||
(typeof REGISTRY_ACCESS)[keyof typeof REGISTRY_ACCESS];
|
||||
|
||||
export interface RegistryAccessResult {
|
||||
status: RegistryAccessStatus;
|
||||
}
|
||||
|
||||
export const isRegistryEligible = (
|
||||
cloudEnabled: unknown,
|
||||
registryEnabled: unknown,
|
||||
manageRegistry: unknown,
|
||||
) =>
|
||||
cloudEnabled === true && registryEnabled === true && manageRegistry === true;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { executeRegistryArtifactAddition } from "./artifact-execution";
|
||||
|
||||
const {
|
||||
addRegistryArtifactMock,
|
||||
confirmRegistryArtifactAdditionMock,
|
||||
trackAndPollTaskMock,
|
||||
} = vi.hoisted(() => ({
|
||||
addRegistryArtifactMock: vi.fn(),
|
||||
confirmRegistryArtifactAdditionMock: vi.fn(),
|
||||
trackAndPollTaskMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
addRegistryArtifact: addRegistryArtifactMock,
|
||||
confirmRegistryArtifactAddition: confirmRegistryArtifactAdditionMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
TASK_WATCHER_STATUS: { READY: "ready", ERROR: "error" },
|
||||
trackAndPollTask: trackAndPollTaskMock,
|
||||
}));
|
||||
|
||||
const artifactInput = { normalizedName: "prowler-aws", versionSpec: "2.0.0" };
|
||||
|
||||
describe("executeRegistryArtifactAddition", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
addRegistryArtifactMock.mockResolvedValue({
|
||||
status: "submitted",
|
||||
taskId: "artifact-task",
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { installed: true, error: null },
|
||||
});
|
||||
confirmRegistryArtifactAdditionMock.mockResolvedValue({
|
||||
status: "confirmed",
|
||||
tenantArtifacts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists an update target and confirms that version after the task completes", async () => {
|
||||
// Given / When
|
||||
await executeRegistryArtifactAddition({
|
||||
...artifactInput,
|
||||
operation: "update",
|
||||
});
|
||||
// Then
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: {
|
||||
normalizedName: "prowler-aws",
|
||||
operation: "update",
|
||||
expectedVersion: "2.0.0",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledWith(
|
||||
"prowler-aws",
|
||||
"2.0.0",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"This version cannot be installed.",
|
||||
"The artifact could not be installed.",
|
||||
],
|
||||
[
|
||||
"Private diagnostic: /srv/registry/customer",
|
||||
"The artifact could not be installed.",
|
||||
],
|
||||
[null, "The artifact could not be installed."],
|
||||
["", "The artifact could not be installed."],
|
||||
[" ", "The artifact could not be installed."],
|
||||
])(
|
||||
"returns a safe task refusal for error %j without confirmation",
|
||||
async (error, message) => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { installed: false, error },
|
||||
});
|
||||
// When
|
||||
const outcome = await executeRegistryArtifactAddition(artifactInput);
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "refused", message });
|
||||
expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an otherwise valid result with an extra field",
|
||||
{ installed: true, error: null, reason: "private deployment detail" },
|
||||
],
|
||||
[
|
||||
"an installed result with an error",
|
||||
{ installed: true, error: "unexpected failure" },
|
||||
],
|
||||
])("returns error for %s without confirmation", async (_case, result) => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockResolvedValue({ status: "ready", result });
|
||||
// When
|
||||
const outcome = await executeRegistryArtifactAddition(artifactInput);
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "error" });
|
||||
expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps failed tasks to unavailable without confirmation", async () => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "error",
|
||||
error: "failed",
|
||||
});
|
||||
// When
|
||||
const outcome = await executeRegistryArtifactAddition(artifactInput);
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "unavailable" });
|
||||
expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the error catch when polling throws", async () => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockRejectedValue(new Error("watcher crashed"));
|
||||
// When
|
||||
const outcome = await executeRegistryArtifactAddition(artifactInput);
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "error" });
|
||||
expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not poll a synchronous 409 onboarding outcome", async () => {
|
||||
// Given
|
||||
addRegistryArtifactMock.mockResolvedValue({ status: "onboarding" });
|
||||
// When
|
||||
const outcome = await executeRegistryArtifactAddition(artifactInput);
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "onboarding" });
|
||||
expect(confirmRegistryArtifactAdditionMock).not.toHaveBeenCalled();
|
||||
expect(trackAndPollTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms presence after a completed installed task", async () => {
|
||||
// Given
|
||||
// When
|
||||
const outcome = await executeRegistryArtifactAddition(artifactInput);
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "confirmed", tenantArtifacts: [] });
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith({
|
||||
taskId: "artifact-task",
|
||||
kind: "registry-artifact-add",
|
||||
meta: { normalizedName: "prowler-aws" },
|
||||
notifyHandler: false,
|
||||
});
|
||||
expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledOnce();
|
||||
expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledWith(
|
||||
"prowler-aws",
|
||||
);
|
||||
});
|
||||
it("deduplicates concurrent submissions for the same artifact", async () => {
|
||||
await Promise.all([
|
||||
executeRegistryArtifactAddition(artifactInput),
|
||||
executeRegistryArtifactAddition(artifactInput),
|
||||
]);
|
||||
expect(addRegistryArtifactMock).toHaveBeenCalledOnce();
|
||||
expect(confirmRegistryArtifactAdditionMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports an unconfirmed update if the confirmation request throws", async () => {
|
||||
// Given
|
||||
confirmRegistryArtifactAdditionMock.mockRejectedValue(
|
||||
new Error("Network failure"),
|
||||
);
|
||||
// When
|
||||
const result = await executeRegistryArtifactAddition({
|
||||
...artifactInput,
|
||||
operation: "update",
|
||||
});
|
||||
// Then
|
||||
expect(result).toEqual({ status: "refresh_failed" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
addRegistryArtifact,
|
||||
confirmRegistryArtifactAddition,
|
||||
} from "@/actions/registry/registry";
|
||||
import { notifyRegistryArtifactOutcome } from "@/lib/registry/artifact-notifications";
|
||||
import {
|
||||
TASK_WATCHER_STATUS,
|
||||
trackAndPollTask,
|
||||
} from "@/store/task-watcher/store";
|
||||
import {
|
||||
REGISTRY_ARTIFACT_ACTION,
|
||||
REGISTRY_FAILURE,
|
||||
REGISTRY_INSTALL_OPERATION,
|
||||
REGISTRY_MUTATION,
|
||||
type RegistryArtifactExecutionInput,
|
||||
type RegistryArtifactTaskResult,
|
||||
type RegistryMutationResult,
|
||||
} from "@/types/registry";
|
||||
|
||||
export const REGISTRY_ARTIFACT_TASK_KIND = "registry-artifact-add";
|
||||
|
||||
const artifactTaskResultSchema = z
|
||||
.object({ installed: z.boolean(), error: z.string().nullable() })
|
||||
.strict();
|
||||
|
||||
async function runRegistryArtifactAddition(
|
||||
input: RegistryArtifactExecutionInput,
|
||||
): Promise<RegistryMutationResult> {
|
||||
const expectedVersion =
|
||||
input.operation === REGISTRY_INSTALL_OPERATION.UPDATE
|
||||
? input.versionSpec.trim()
|
||||
: undefined;
|
||||
if (expectedVersion === "") return { status: REGISTRY_FAILURE.ERROR };
|
||||
let submitted;
|
||||
try {
|
||||
submitted = await addRegistryArtifact(input);
|
||||
} catch {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
if (submitted.status !== REGISTRY_ARTIFACT_ACTION.SUBMITTED) {
|
||||
return submitted;
|
||||
}
|
||||
|
||||
let tracked;
|
||||
try {
|
||||
tracked = await trackAndPollTask<RegistryArtifactTaskResult>({
|
||||
taskId: submitted.taskId,
|
||||
kind: REGISTRY_ARTIFACT_TASK_KIND,
|
||||
meta: {
|
||||
normalizedName: input.normalizedName,
|
||||
...(expectedVersion
|
||||
? { operation: REGISTRY_INSTALL_OPERATION.UPDATE, expectedVersion }
|
||||
: {}),
|
||||
},
|
||||
notifyHandler: false,
|
||||
});
|
||||
} catch {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
if (tracked.status !== TASK_WATCHER_STATUS.READY) {
|
||||
return { status: REGISTRY_FAILURE.UNAVAILABLE };
|
||||
}
|
||||
|
||||
return confirmRegistryArtifactTask(
|
||||
input.normalizedName,
|
||||
tracked.result,
|
||||
expectedVersion,
|
||||
);
|
||||
}
|
||||
|
||||
export async function confirmRegistryArtifactTask(
|
||||
normalizedName: string,
|
||||
taskResult: unknown,
|
||||
expectedVersion?: string,
|
||||
): Promise<RegistryMutationResult> {
|
||||
const result = artifactTaskResultSchema.safeParse(taskResult);
|
||||
if (
|
||||
!result.success ||
|
||||
(result.data.installed && result.data.error !== null)
|
||||
) {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
if (!result.data.installed) {
|
||||
return {
|
||||
status: REGISTRY_MUTATION.REFUSED,
|
||||
// Task errors are backend diagnostics, not user-facing refusal codes.
|
||||
message: "The artifact could not be installed.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await (expectedVersion === undefined
|
||||
? confirmRegistryArtifactAddition(normalizedName)
|
||||
: confirmRegistryArtifactAddition(normalizedName, expectedVersion));
|
||||
} catch {
|
||||
return {
|
||||
status:
|
||||
expectedVersion === undefined
|
||||
? REGISTRY_FAILURE.ERROR
|
||||
: REGISTRY_MUTATION.REFRESH_FAILED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const installations = new Map<string, Promise<RegistryMutationResult>>();
|
||||
|
||||
export function executeRegistryArtifactAddition(
|
||||
input: RegistryArtifactExecutionInput,
|
||||
): Promise<RegistryMutationResult> {
|
||||
const pending = installations.get(input.normalizedName);
|
||||
if (pending) return pending;
|
||||
const execution = runRegistryArtifactAddition(input)
|
||||
.then((result) => {
|
||||
notifyRegistryArtifactOutcome(result, input.operation);
|
||||
return result;
|
||||
})
|
||||
.finally(() => installations.delete(input.normalizedName));
|
||||
installations.set(input.normalizedName, execution);
|
||||
return execution;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import {
|
||||
REGISTRY_INSTALL_OPERATION,
|
||||
type RegistryInstallOperation,
|
||||
type RegistryMutationResult,
|
||||
} from "@/types/registry";
|
||||
|
||||
export function notifyRegistryArtifactOutcome(
|
||||
result: RegistryMutationResult,
|
||||
operation: RegistryInstallOperation = REGISTRY_INSTALL_OPERATION.ADD,
|
||||
): void {
|
||||
const isUpdate = operation === REGISTRY_INSTALL_OPERATION.UPDATE;
|
||||
if (result.status === "confirmed") {
|
||||
toast({
|
||||
title: isUpdate ? "Artifact updated" : "Artifact added",
|
||||
action: (
|
||||
<ToastAction altText="Go to Providers" asChild>
|
||||
<Link href="/providers">Go to Providers</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("registry-artifacts-changed", {
|
||||
detail: result.tenantArtifacts,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: isUpdate
|
||||
? "Artifact could not be updated"
|
||||
: "Artifact could not be added",
|
||||
description:
|
||||
result.status === "refused"
|
||||
? result.message
|
||||
: result.status === "refresh_failed"
|
||||
? isUpdate
|
||||
? "Update could not be confirmed. Refresh Registry before retrying."
|
||||
: "Installation could not be confirmed. Refresh Registry before retrying."
|
||||
: "Check the Registry connection and try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RegistryCatalogArtifact } from "@/types/registry";
|
||||
|
||||
/** Installation eligibility comes from catalog capabilities, never the package name. */
|
||||
export function isRegistryArtifactInstallable(
|
||||
artifact: Pick<RegistryCatalogArtifact, "hasProvider" | "isBuiltin">,
|
||||
): boolean {
|
||||
return artifact.hasProvider === true && artifact.isBuiltin === false;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RegistryCredentialStatus } from "@/types/registry";
|
||||
|
||||
import { executeRegistryCredentialValidation } from "./credential-execution";
|
||||
import { REGISTRY_CREDENTIAL_TASK_KIND } from "./credential-task";
|
||||
|
||||
const {
|
||||
refreshRegistryCollectionsMock,
|
||||
refreshRegistryCredentialMock,
|
||||
submitRegistryCredentialMock,
|
||||
trackAndPollTaskMock,
|
||||
} = vi.hoisted(() => ({
|
||||
refreshRegistryCollectionsMock: vi.fn(),
|
||||
refreshRegistryCredentialMock: vi.fn(),
|
||||
submitRegistryCredentialMock: vi.fn(),
|
||||
trackAndPollTaskMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
refreshRegistryCollections: refreshRegistryCollectionsMock,
|
||||
refreshRegistryCredential: refreshRegistryCredentialMock,
|
||||
submitRegistryCredential: submitRegistryCredentialMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
|
||||
trackAndPollTask: trackAndPollTaskMock,
|
||||
}));
|
||||
|
||||
const activeCredential: RegistryCredentialStatus = {
|
||||
configured: true,
|
||||
isValid: true,
|
||||
scopes: ["catalog:read"],
|
||||
validationPending: false,
|
||||
};
|
||||
const noCredential: RegistryCredentialStatus = {
|
||||
configured: false,
|
||||
isValid: false,
|
||||
scopes: [],
|
||||
validationPending: false,
|
||||
};
|
||||
const pendingCredential: RegistryCredentialStatus = {
|
||||
configured: true,
|
||||
isValid: false,
|
||||
scopes: [],
|
||||
validationPending: true,
|
||||
};
|
||||
|
||||
describe("executeRegistryCredentialValidation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
refreshRegistryCollectionsMock.mockResolvedValue({
|
||||
status: "complete",
|
||||
catalog: { status: "complete", artifacts: [] },
|
||||
tenantArtifacts: [],
|
||||
});
|
||||
submitRegistryCredentialMock.mockResolvedValue({
|
||||
status: "submitted",
|
||||
taskId: "task-1",
|
||||
priorConfigured: false,
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { stored: true, error: null },
|
||||
});
|
||||
refreshRegistryCredentialMock.mockResolvedValue({
|
||||
status: "status",
|
||||
credential: activeCredential,
|
||||
});
|
||||
});
|
||||
|
||||
it("connects after the watched task settles and the credential is active", async () => {
|
||||
// Given
|
||||
const key = "registry-test-key";
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation(key);
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({
|
||||
status: "connected",
|
||||
credential: activeCredential,
|
||||
collections: {
|
||||
status: "complete",
|
||||
catalog: { status: "complete", artifacts: [] },
|
||||
tenantArtifacts: [],
|
||||
},
|
||||
});
|
||||
expect(submitRegistryCredentialMock).toHaveBeenCalledWith(key);
|
||||
expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
|
||||
expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(trackAndPollTaskMock).toHaveBeenCalledWith({
|
||||
taskId: "task-1",
|
||||
kind: REGISTRY_CREDENTIAL_TASK_KIND,
|
||||
meta: { priorConfigured: "false" },
|
||||
notifyHandler: false,
|
||||
});
|
||||
// The key must never reach the persisted watcher record.
|
||||
expect(JSON.stringify(trackAndPollTaskMock.mock.calls)).not.toContain(key);
|
||||
});
|
||||
|
||||
it("reports an invalid key when the settled credential is not active", async () => {
|
||||
// Given
|
||||
refreshRegistryCredentialMock.mockResolvedValue({
|
||||
status: "status",
|
||||
credential: noCredential,
|
||||
});
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation("bad-key");
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "invalid", credential: noCredential });
|
||||
});
|
||||
|
||||
it("keeps a failed replacement distinct from a first invalid key", async () => {
|
||||
// Given
|
||||
submitRegistryCredentialMock.mockResolvedValue({
|
||||
status: "submitted",
|
||||
taskId: "task-2",
|
||||
priorConfigured: true,
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "error",
|
||||
error: 'Task ended in state "failed".',
|
||||
});
|
||||
refreshRegistryCredentialMock.mockResolvedValue({
|
||||
status: "status",
|
||||
credential: activeCredential,
|
||||
});
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation("replacement");
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "replacement_failed" });
|
||||
});
|
||||
|
||||
it("reports a validation still pending after the watch settles", async () => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "error",
|
||||
error: "The task expired before it could be tracked to completion.",
|
||||
});
|
||||
refreshRegistryCredentialMock.mockResolvedValue({
|
||||
status: "status",
|
||||
credential: pendingCredential,
|
||||
});
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation("slow-key");
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({
|
||||
status: "pending",
|
||||
credential: pendingCredential,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes submit failures through without watching any task", async () => {
|
||||
// Given
|
||||
submitRegistryCredentialMock
|
||||
.mockResolvedValueOnce({ status: "access_denied" })
|
||||
.mockResolvedValueOnce({
|
||||
status: "replacement_failed",
|
||||
credential: activeCredential,
|
||||
})
|
||||
.mockResolvedValueOnce({ status: "error" });
|
||||
|
||||
// When
|
||||
const denied = await executeRegistryCredentialValidation("key");
|
||||
const replacementFailed = await executeRegistryCredentialValidation("key");
|
||||
const failed = await executeRegistryCredentialValidation("key");
|
||||
|
||||
// Then
|
||||
expect(denied).toEqual({ status: "access_denied" });
|
||||
expect(replacementFailed).toEqual({ status: "replacement_failed" });
|
||||
expect(failed).toEqual({ status: "error" });
|
||||
expect(trackAndPollTaskMock).not.toHaveBeenCalled();
|
||||
expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms and publishes once when validation finishes after the dialog deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const changed = vi.fn();
|
||||
window.addEventListener("registry-credential-changed", changed);
|
||||
try {
|
||||
let finish: (result: unknown) => void = () => {};
|
||||
trackAndPollTaskMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const waiting = executeRegistryCredentialValidation("slow-key");
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await expect(waiting).resolves.toEqual({ status: "pending" });
|
||||
expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
|
||||
finish({ status: "ready", result: { stored: true, error: null } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
|
||||
expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
expect(changed.mock.calls[0][0].detail.status).toBe("connected");
|
||||
} finally {
|
||||
window.removeEventListener("registry-credential-changed", changed);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes the same failure returned to the dialog when collections fail", async () => {
|
||||
const changed = vi.fn();
|
||||
window.addEventListener("registry-credential-changed", changed);
|
||||
try {
|
||||
refreshRegistryCollectionsMock.mockResolvedValue({
|
||||
status: "unavailable",
|
||||
});
|
||||
const result = await executeRegistryCredentialValidation("key");
|
||||
expect(result).toEqual({
|
||||
status: "error",
|
||||
message: "Registry collections could not be loaded. Try again.",
|
||||
});
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
expect(changed.mock.calls[0][0].detail).toBe(result);
|
||||
} finally {
|
||||
window.removeEventListener("registry-credential-changed", changed);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"does not confirm an unsettled task (priorConfigured: %s)",
|
||||
async (priorConfigured) => {
|
||||
submitRegistryCredentialMock.mockResolvedValue({
|
||||
status: "submitted",
|
||||
taskId: "task-1",
|
||||
priorConfigured,
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({ status: "pending" });
|
||||
expect(await executeRegistryCredentialValidation("race-key")).toEqual({
|
||||
status: "pending",
|
||||
});
|
||||
expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
|
||||
expect(refreshRegistryCollectionsMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("fails safely when the submit RPC rejects", async () => {
|
||||
// Given
|
||||
submitRegistryCredentialMock.mockRejectedValue(new Error("rpc dropped"));
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation("key");
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "error" });
|
||||
expect(trackAndPollTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails safely when the authoritative re-read RPC rejects", async () => {
|
||||
// Given
|
||||
refreshRegistryCredentialMock.mockRejectedValue(new Error("rpc dropped"));
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation("key");
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "error" });
|
||||
});
|
||||
|
||||
it("fails safely when tracking throws before the authoritative re-read", async () => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockRejectedValue(new Error("watcher crashed"));
|
||||
|
||||
// When
|
||||
const outcome = await executeRegistryCredentialValidation("key");
|
||||
|
||||
// Then
|
||||
expect(outcome).toEqual({ status: "error" });
|
||||
expect(refreshRegistryCredentialMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates authoritative re-read failures after the watch", async () => {
|
||||
// Given
|
||||
refreshRegistryCredentialMock
|
||||
.mockResolvedValueOnce({ status: "access_denied" })
|
||||
.mockResolvedValueOnce({ status: "error" });
|
||||
|
||||
// When
|
||||
const denied = await executeRegistryCredentialValidation("key");
|
||||
const failed = await executeRegistryCredentialValidation("key");
|
||||
|
||||
// Then
|
||||
expect(denied).toEqual({ status: "access_denied" });
|
||||
expect(failed).toEqual({ status: "error" });
|
||||
});
|
||||
|
||||
it("does not report a rejected replacement as connected when the prior key remains active", async () => {
|
||||
submitRegistryCredentialMock.mockResolvedValue({
|
||||
status: "submitted",
|
||||
taskId: "task",
|
||||
priorConfigured: true,
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { stored: false, error: "Invalid key" },
|
||||
});
|
||||
expect(await executeRegistryCredentialValidation("replacement")).toEqual({
|
||||
status: "replacement_failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { submitRegistryCredential } from "@/actions/registry/registry";
|
||||
import {
|
||||
completeRegistryCredentialValidation,
|
||||
type RegistryCredentialValidationOutcome,
|
||||
} from "@/lib/registry/credential-result";
|
||||
import { REGISTRY_CREDENTIAL_TASK_KIND } from "@/lib/registry/credential-task";
|
||||
import {
|
||||
TASK_WATCHER_STATUS,
|
||||
trackAndPollTask,
|
||||
} from "@/store/task-watcher/store";
|
||||
import { REGISTRY_CREDENTIAL_ACTION, REGISTRY_FAILURE } from "@/types/registry";
|
||||
|
||||
export const REGISTRY_CREDENTIAL_WATCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** The live operation owns confirmation. Reloads resume through the kind handler. */
|
||||
export async function executeRegistryCredentialValidation(
|
||||
key: string,
|
||||
options: { notifyHandler?: boolean } = {},
|
||||
): Promise<RegistryCredentialValidationOutcome> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const submitted = await submitRegistryCredential(key);
|
||||
if (
|
||||
submitted.status === REGISTRY_FAILURE.ACCESS_DENIED ||
|
||||
submitted.status === REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED
|
||||
)
|
||||
return { status: submitted.status };
|
||||
if (submitted.status !== REGISTRY_CREDENTIAL_ACTION.SUBMITTED)
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
|
||||
// Only non-secret operation context survives reload. Suppression is in-memory
|
||||
// and disappears on reload, when the registered handler takes ownership.
|
||||
const completion = trackAndPollTask({
|
||||
taskId: submitted.taskId,
|
||||
kind: REGISTRY_CREDENTIAL_TASK_KIND,
|
||||
meta: { priorConfigured: String(submitted.priorConfigured) },
|
||||
notifyHandler: false,
|
||||
}).then((tracked) =>
|
||||
tracked.status === TASK_WATCHER_STATUS.PENDING
|
||||
? ({ status: REGISTRY_CREDENTIAL_ACTION.PENDING } as const)
|
||||
: completeRegistryCredentialValidation(
|
||||
tracked,
|
||||
submitted.priorConfigured,
|
||||
options.notifyHandler ?? true,
|
||||
),
|
||||
);
|
||||
// Let the dialog recover while completion continues across navigation.
|
||||
// A deadline is not a verdict: only task settlement can confirm the key.
|
||||
const deadline = new Promise<RegistryCredentialValidationOutcome>(
|
||||
(resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ status: REGISTRY_CREDENTIAL_ACTION.PENDING }),
|
||||
REGISTRY_CREDENTIAL_WATCH_TIMEOUT_MS,
|
||||
);
|
||||
},
|
||||
);
|
||||
return await Promise.race([completion, deadline]);
|
||||
} catch {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
refreshRegistryCredential,
|
||||
refreshRegistryCollections,
|
||||
} from "@/actions/registry/registry";
|
||||
import { toast } from "@/components/shadcn/toast";
|
||||
import {
|
||||
getRegistryCredentialFailureMessage,
|
||||
isActiveRegistryCredential,
|
||||
isRegistryCredentialTaskSuccessful,
|
||||
} from "@/lib/registry/credential-task";
|
||||
import {
|
||||
TASK_WATCHER_STATUS,
|
||||
type TaskTrackingResult,
|
||||
} from "@/store/task-watcher/store";
|
||||
import {
|
||||
REGISTRY_CATALOG,
|
||||
REGISTRY_CREDENTIAL_ACTION,
|
||||
REGISTRY_CREDENTIAL_READ,
|
||||
REGISTRY_FAILURE,
|
||||
type RegistryCredentialReadResult,
|
||||
type RegistryCredentialStatus,
|
||||
type RegistryCollectionsResult,
|
||||
} from "@/types/registry";
|
||||
|
||||
export type RegistryCredentialValidationOutcome =
|
||||
| {
|
||||
status: typeof REGISTRY_CREDENTIAL_ACTION.CONNECTED;
|
||||
collections: Extract<RegistryCollectionsResult, { status: "complete" }>;
|
||||
credential: RegistryCredentialStatus;
|
||||
}
|
||||
| {
|
||||
status: typeof REGISTRY_CREDENTIAL_ACTION.PENDING;
|
||||
credential?: RegistryCredentialStatus;
|
||||
}
|
||||
| {
|
||||
status: typeof REGISTRY_CREDENTIAL_ACTION.INVALID;
|
||||
credential: RegistryCredentialStatus;
|
||||
message?: string;
|
||||
}
|
||||
| { status: typeof REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED }
|
||||
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
|
||||
| { status: typeof REGISTRY_FAILURE.ERROR; message?: string };
|
||||
|
||||
export const REGISTRY_CREDENTIAL_CHANGED = "registry-credential-changed";
|
||||
|
||||
async function confirmCredential(
|
||||
tracked: TaskTrackingResult,
|
||||
priorConfigured: boolean,
|
||||
): Promise<RegistryCredentialValidationOutcome> {
|
||||
let read: RegistryCredentialReadResult;
|
||||
try {
|
||||
read = await refreshRegistryCredential();
|
||||
} catch {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
if (read.status === REGISTRY_FAILURE.ACCESS_DENIED) {
|
||||
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
}
|
||||
if (read.status !== REGISTRY_CREDENTIAL_READ.STATUS) {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
|
||||
const { credential } = read;
|
||||
// Both the submitted task and the authoritative credential must confirm success.
|
||||
if (
|
||||
isActiveRegistryCredential(credential) &&
|
||||
tracked.status === TASK_WATCHER_STATUS.READY &&
|
||||
isRegistryCredentialTaskSuccessful(tracked.result)
|
||||
) {
|
||||
const collections = await refreshRegistryCollections().catch(() => null);
|
||||
if (collections?.status === REGISTRY_FAILURE.ACCESS_DENIED)
|
||||
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
if (collections?.status !== REGISTRY_CATALOG.COMPLETE)
|
||||
return {
|
||||
status: REGISTRY_FAILURE.ERROR,
|
||||
message: "Registry collections could not be loaded. Try again.",
|
||||
};
|
||||
return {
|
||||
status: REGISTRY_CREDENTIAL_ACTION.CONNECTED,
|
||||
credential,
|
||||
collections,
|
||||
};
|
||||
}
|
||||
if (credential.validationPending) {
|
||||
return { status: REGISTRY_CREDENTIAL_ACTION.PENDING, credential };
|
||||
}
|
||||
// An unsettled watch has not judged the key; report it still pending
|
||||
// rather than invalid or a failed replacement.
|
||||
if (tracked.status === TASK_WATCHER_STATUS.PENDING) {
|
||||
return { status: REGISTRY_CREDENTIAL_ACTION.PENDING, credential };
|
||||
}
|
||||
if (priorConfigured) {
|
||||
return { status: REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED };
|
||||
}
|
||||
const message = getRegistryCredentialFailureMessage(tracked.result);
|
||||
return {
|
||||
status: REGISTRY_CREDENTIAL_ACTION.INVALID,
|
||||
credential,
|
||||
...(message ? { message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function credentialOutcomeMessage(
|
||||
result: RegistryCredentialValidationOutcome,
|
||||
): string {
|
||||
if (result.status === REGISTRY_CREDENTIAL_ACTION.PENDING)
|
||||
return "Registry key validation is taking longer than expected. Try again.";
|
||||
if (result.status === REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED)
|
||||
return "Registry key validation failed. Existing access is unchanged.";
|
||||
if (result.status === REGISTRY_CREDENTIAL_ACTION.INVALID)
|
||||
return (
|
||||
result.message ?? "This Registry key is invalid. Check it and try again."
|
||||
);
|
||||
return (
|
||||
(result.status === REGISTRY_FAILURE.ERROR && result.message) ||
|
||||
"Registry key validation could not be completed. Try again."
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared by the active operation and the watcher after a document reload. */
|
||||
export async function completeRegistryCredentialValidation(
|
||||
tracked: TaskTrackingResult,
|
||||
priorConfigured: boolean,
|
||||
notify = true,
|
||||
): Promise<RegistryCredentialValidationOutcome> {
|
||||
const outcome = await confirmCredential(tracked, priorConfigured);
|
||||
if (notify) {
|
||||
if (outcome.status === REGISTRY_CREDENTIAL_ACTION.CONNECTED) {
|
||||
toast({ title: "Registry connected" });
|
||||
} else if (outcome.status !== REGISTRY_FAILURE.ACCESS_DENIED) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Registry key validation failed",
|
||||
description: credentialOutcomeMessage(outcome),
|
||||
});
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(REGISTRY_CREDENTIAL_CHANGED, { detail: outcome }),
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRegistryCredentialFailureMessage } from "./credential-task";
|
||||
|
||||
describe("Registry credential rejection feedback", () => {
|
||||
it("explains a wrong-environment key without reflecting server data", () => {
|
||||
const message = getRegistryCredentialFailureMessage({
|
||||
stored: false,
|
||||
error: "Registry rejected the API key (HTTP 401). sensitive-input",
|
||||
});
|
||||
expect(message).toContain("Registry environment");
|
||||
expect(message).toContain("HTTP 401");
|
||||
expect(message).not.toContain("sensitive-input");
|
||||
});
|
||||
it("does not expose arbitrary backend errors", () => {
|
||||
expect(
|
||||
getRegistryCredentialFailureMessage({ error: "sensitive-input" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RegistryCredentialStatus } from "@/types/registry";
|
||||
|
||||
/**
|
||||
* Watched-task kind for Registry API key validation. Metadata retains only
|
||||
* whether a credential was already configured; the submitted key is write-only
|
||||
* and must never reach the persisted watcher record.
|
||||
*/
|
||||
export const REGISTRY_CREDENTIAL_TASK_KIND = "registry-credential-validation";
|
||||
|
||||
export const isActiveRegistryCredential = (
|
||||
credential: RegistryCredentialStatus | null,
|
||||
) =>
|
||||
Boolean(
|
||||
credential?.configured &&
|
||||
credential.isValid &&
|
||||
!credential.validationPending,
|
||||
);
|
||||
|
||||
export const isRegistryCredentialTaskSuccessful = (result: unknown): boolean =>
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"stored" in result &&
|
||||
result.stored === true &&
|
||||
"error" in result &&
|
||||
result.error === null;
|
||||
|
||||
/** Translate known rejection reasons without reflecting server payloads or secrets. */
|
||||
export function getRegistryCredentialFailureMessage(
|
||||
result: unknown,
|
||||
): string | undefined {
|
||||
if (
|
||||
typeof result !== "object" ||
|
||||
result === null ||
|
||||
!("error" in result) ||
|
||||
typeof result.error !== "string"
|
||||
)
|
||||
return;
|
||||
const error = result.error.toLowerCase();
|
||||
if (error.includes("http 401"))
|
||||
return "The configured Registry rejected this key (HTTP 401). Check that the key belongs to this Registry environment and is still active.";
|
||||
if (error.includes("organization keys are not supported"))
|
||||
return "Use a customer download key for the official Registry. Organization upload keys cannot connect this workspace.";
|
||||
if (error.includes("download scope"))
|
||||
return "This key needs a download scope. Create a download key in Registry and try again.";
|
||||
if (error.includes("customer accounts disabled"))
|
||||
return "Customer accounts are disabled on the configured Registry. Contact its administrator.";
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRegistryPresentation } from "./presentation";
|
||||
|
||||
describe("Registry presentation configuration", () => {
|
||||
const urlWithCredentials = new URL("https://registry.test");
|
||||
urlWithCredentials.username = "user";
|
||||
urlWithCredentials.password = "pass";
|
||||
|
||||
it("uses the configured Registry and media origins", () => {
|
||||
expect(
|
||||
getRegistryPresentation(
|
||||
"https://registry.private.test/keys",
|
||||
"https://assets.private.test/media/",
|
||||
),
|
||||
).toEqual({
|
||||
keyUrl: "https://registry.private.test/keys",
|
||||
imageOrigins: [
|
||||
"https://registry.private.test",
|
||||
"https://assets.private.test",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not guess a Registry environment when configuration is missing", () => {
|
||||
expect(getRegistryPresentation()).toEqual({
|
||||
keyUrl: undefined,
|
||||
imageOrigins: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"javascript:alert(1)",
|
||||
urlWithCredentials.href,
|
||||
"https://registry.test; img-src *",
|
||||
"invalid",
|
||||
])("rejects unsafe configuration: %s", (value) => {
|
||||
expect(getRegistryPresentation(value, value)).toEqual({
|
||||
keyUrl: undefined,
|
||||
imageOrigins: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Accept public HTTP URLs only; never expose URL credentials or CSP syntax. */
|
||||
function parsePublicUrl(value?: string | null): URL | undefined {
|
||||
if (!value || /[\s;]/.test(value)) return;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (
|
||||
(url.protocol === "https:" || url.protocol === "http:") &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
return url;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRegistryPresentation(
|
||||
registryUrl?: string | null,
|
||||
mediaUrl?: string | null,
|
||||
) {
|
||||
const registry = parsePublicUrl(registryUrl);
|
||||
const media = parsePublicUrl(mediaUrl);
|
||||
return {
|
||||
keyUrl: registry?.href,
|
||||
imageOrigins: Array.from(
|
||||
new Set(
|
||||
[registry?.origin, media?.origin].filter((origin): origin is string =>
|
||||
Boolean(origin),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user