Skip to content

Commit 01dc698

Browse files
feat(ui): harden Registry accessibility and acceptance
- Add reduced-motion and focus behavior to shared primitives - Add deterministic Registry browser acceptance and CI routing - Document default-off rollout and live-backend prerequisites - Fix Next.js server-action build compatibility
1 parent 99a8101 commit 01dc698

31 files changed

Lines changed: 1585 additions & 107 deletions

.github/test-impact.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,17 @@ modules:
451451
e2e:
452452
- ui/tests/home/**
453453

454+
- name: ui-registry
455+
match:
456+
- ui/actions/registry/**
457+
- ui/app/**/registry/**
458+
- ui/components/registry/**
459+
- ui/lib/registry/**
460+
- ui/tests/registry/**
461+
tests: []
462+
e2e:
463+
- ui/tests/registry/**
464+
454465
- name: ui-shadcn
455466
match:
456467
- ui/components/shadcn/**

.github/workflows/ui-e2e-tests-v2.yml

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ on:
1010
- master
1111
- "v5.*"
1212
paths:
13-
- '.github/workflows/ui-e2e-tests-v2.yml'
14-
- '.github/test-impact.yml'
15-
- 'ui/**'
16-
- 'api/**' # API changes can affect UI E2E
17-
- '!ui/CHANGELOG.md'
18-
- '!api/CHANGELOG.md'
13+
- ".github/workflows/ui-e2e-tests-v2.yml"
14+
- ".github/test-impact.yml"
15+
- "ui/**"
16+
- "api/**" # API changes can affect UI E2E
17+
- "!ui/CHANGELOG.md"
18+
- "!api/CHANGELOG.md"
1919

2020
concurrency:
2121
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -40,11 +40,11 @@ jobs:
4040
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
4141
runs-on: ubuntu-latest
4242
env:
43-
AUTH_SECRET: 'fallback-ci-secret-for-testing'
43+
AUTH_SECRET: "fallback-ci-secret-for-testing"
4444
AUTH_TRUST_HOST: true
45-
NEXTAUTH_URL: 'http://localhost:3000'
46-
AUTH_URL: 'http://localhost:3000'
47-
UI_API_BASE_URL: 'http://localhost:8080/api/v1'
45+
NEXTAUTH_URL: "http://localhost:3000"
46+
AUTH_URL: "http://localhost:3000"
47+
UI_API_BASE_URL: "http://localhost:8080/api/v1"
4848
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
4949
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
5050
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
@@ -60,7 +60,7 @@ jobs:
6060
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
6161
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
6262
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
63-
E2E_KUBERNETES_CONTEXT: 'kind-kind'
63+
E2E_KUBERNETES_CONTEXT: "kind-kind"
6464
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
6565
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
6666
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
@@ -292,7 +292,7 @@ jobs:
292292
- name: Setup Node.js
293293
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
294294
with:
295-
node-version-file: 'ui/.nvmrc'
295+
node-version-file: "ui/.nvmrc"
296296

297297
- name: Setup pnpm
298298
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
@@ -340,55 +340,56 @@ jobs:
340340
- name: Run E2E tests
341341
working-directory: ./ui
342342
run: |
343+
REGISTRY_FIXTURE_SELECTED=false
344+
if [[ "${RUN_ALL_TESTS}" == "true" ]] ||
345+
[[ " ${E2E_TEST_PATHS} " == *" ui/tests/registry/"* ]]; then
346+
REGISTRY_FIXTURE_SELECTED=true
347+
fi
348+
343349
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
344-
echo "Running ALL E2E tests..."
350+
echo "Running all standard E2E tests..."
345351
pnpm run test:e2e
346352
else
347-
echo "Running targeted E2E tests: ${E2E_TEST_PATHS}"
348-
# Convert glob patterns to playwright test paths
349-
# e.g., "ui/tests/providers/**" -> "tests/providers"
353+
echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}"
350354
TEST_PATHS="${E2E_TEST_PATHS}"
351-
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
352355
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
353-
# Drop auth setup helpers (not runnable test suites)
354-
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
355-
# Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**),
356-
# expand to specific subdirs to avoid Playwright discovering setup files
356+
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true)
357+
357358
if echo "$TEST_PATHS" | grep -qx 'tests/'; then
358-
echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..."
359359
SPECIFIC_DIRS=""
360360
for dir in tests/*/; do
361-
[[ "$dir" == "tests/setups/" ]] && continue
361+
[[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue
362362
SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n'
363363
done
364-
# Replace "tests/" with specific dirs, keep other paths
365-
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/')
364+
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true)
366365
TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}"
367366
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u)
368367
fi
369-
if [[ -z "$TEST_PATHS" ]]; then
370-
echo "No runnable E2E test paths after filtering setups"
371-
exit 0
372-
fi
373-
# Filter out directories that don't contain any test files
368+
374369
VALID_PATHS=""
375-
while IFS= read -r p; do
376-
[[ -z "$p" ]] && continue
377-
if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
378-
VALID_PATHS="${VALID_PATHS}${p}"$'\n'
370+
while IFS= read -r path; do
371+
[[ -z "$path" ]] && continue
372+
if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
373+
VALID_PATHS="${VALID_PATHS}${path}"$'\n'
379374
else
380-
echo "Skipping empty test directory: $p"
375+
echo "Skipping empty test directory: $path"
381376
fi
382377
done <<< "$TEST_PATHS"
383378
VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true)
384-
if [[ -z "$VALID_PATHS" ]]; then
385-
echo "No test files found in any resolved paths — skipping E2E"
386-
exit 0
379+
380+
if [[ -n "$VALID_PATHS" ]]; then
381+
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
382+
echo "Resolved standard test paths: $TEST_PATHS"
383+
read -ra test_paths <<< "$TEST_PATHS"
384+
pnpm exec playwright test "${test_paths[@]}"
385+
else
386+
echo "No standard E2E test paths selected."
387387
fi
388-
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
389-
echo "Resolved test paths: $TEST_PATHS"
390-
read -ra test_paths <<< "$TEST_PATHS"
391-
pnpm exec playwright test "${test_paths[@]}"
388+
fi
389+
390+
if [[ "$REGISTRY_FIXTURE_SELECTED" == "true" ]]; then
391+
echo "Running self-contained Registry fixture acceptance."
392+
pnpm run test:e2e:registry
392393
fi
393394
394395
- name: Upload test reports

docs/developer-guide/environment-variables.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ The former build-time variables map to the new runtime variables as follows:
4040

4141
`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.
4242

43+
## Registry UI Rollout and Rollback
44+
45+
`UI_REGISTRY_ENABLED` is an optional Prowler Cloud runtime flag. 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"`, malformed, or stale values fail closed. The flag defaults to off and is not a replacement for backend authorization.
46+
47+
Roll out Registry only after the Registry backend dependency is deployed, intended Cloud roles have `MANAGE_REGISTRY`, and controlled acceptance has exercised credential onboarding, complete catalog loading, and Add/Remove behavior. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared Cloud process environment, then restart or otherwise apply the environment update required by the platform.
48+
49+
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. A visible client rechecks within 15 seconds, while a hidden client lease expires within 30 seconds and rechecks before showing Registry on return. Rollback does not delete Registry credentials or tenant artifact records.
50+
4351
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.
4452

4553
## Enabling Third-Party Integrations

ui/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ ENV HOSTNAME="0.0.0.0"
8383
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
8484
# - optional: UI_API_DOCS_URL
8585
# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments)
86+
# - optional: UI_REGISTRY_ENABLED ("true" only after the Registry dependency,
87+
# Cloud role grant, and controlled acceptance are ready; unset/false hides Registry)
8688
# - gated integrations (load only when *_ENABLED="true"; the value is then
8789
# required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*,
8890
# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:

ui/actions/registry/registry.test.ts

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const {
4-
authMock,
5-
evaluateAccessMock,
6-
fetchMock,
7-
pollTaskUntilSettledMock,
8-
refreshEligibilityMock,
9-
} = vi.hoisted(() => ({
10-
authMock: vi.fn(),
11-
evaluateAccessMock: vi.fn(),
12-
fetchMock: vi.fn(),
13-
pollTaskUntilSettledMock: vi.fn(),
14-
refreshEligibilityMock: vi.fn(),
15-
}));
3+
const { authMock, evaluateAccessMock, fetchMock, pollTaskUntilSettledMock } =
4+
vi.hoisted(() => ({
5+
authMock: vi.fn(),
6+
evaluateAccessMock: vi.fn(),
7+
fetchMock: vi.fn(),
8+
pollTaskUntilSettledMock: vi.fn(),
9+
}));
1610

1711
vi.mock("@/auth.config", () => ({ auth: authMock }));
1812
vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" }));
@@ -21,7 +15,6 @@ vi.mock("@/actions/task/poll", () => ({
2115
}));
2216
vi.mock("@/lib/registry/access.server", () => ({
2317
evaluateRegistryAccess: evaluateAccessMock,
24-
refreshRegistryEligibility: refreshEligibilityMock,
2518
}));
2619

2720
import {
@@ -103,10 +96,6 @@ beforeEach(() => {
10396
status: "eligible",
10497
leaseDurationMs: 30_000,
10598
});
106-
refreshEligibilityMock.mockResolvedValue({
107-
status: "eligible",
108-
leaseDurationMs: 30_000,
109-
});
11099
fetchMock.mockReset();
111100
pollTaskUntilSettledMock.mockReset();
112101
});
@@ -164,13 +153,14 @@ describe("Registry guarded reads", () => {
164153
it("returns only the current eligibility result without Registry I/O", async () => {
165154
// Given
166155
const access = { status: "unknown" } as const;
167-
refreshEligibilityMock.mockResolvedValue(access);
156+
evaluateAccessMock.mockResolvedValue(access);
168157

169158
// When
170159
const result = await refreshRegistryEligibility();
171160

172161
// Then
173162
expect(result).toEqual(access);
163+
expect(evaluateAccessMock).toHaveBeenCalledWith("access-token");
174164
expect(fetchMock).not.toHaveBeenCalled();
175165
});
176166

ui/actions/registry/registry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ function bootstrapFailure(
253253
});
254254
}
255255

256-
export { refreshRegistryEligibility } from "@/lib/registry/access.server";
256+
export async function refreshRegistryEligibility() {
257+
return evaluateRegistryAccess((await auth())?.accessToken);
258+
}
257259

258260
export async function getRegistryBootstrap(): Promise<RegistryBootstrapResult> {
259261
const access = await getRegistryAccess();

ui/components/registry/registry-access-boundary.integration.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
22

33
import { render } from "@/__tests__/render-browser";
44

5-
vi.mock("@/lib/registry/access.server", () => ({
5+
vi.mock("@/actions/registry/registry", () => ({
66
refreshRegistryEligibility: () => Promise.resolve({ status: "ineligible" }),
77
}));
88

ui/components/registry/registry-access-dialog.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useRef } from "react";
3+
import { type FormEvent, type RefObject, useRef } from "react";
44

55
import { Button } from "@/components/shadcn/button/button";
66
import { Input } from "@/components/shadcn/input/input";
@@ -11,6 +11,7 @@ interface RegistryAccessDialogCommonProps {
1111
onSubmit: (key: string) => Promise<void>;
1212
open: boolean;
1313
pending: boolean;
14+
returnFocusRef: RefObject<HTMLButtonElement | null>;
1415
}
1516

1617
type ConnectRegistryAccessDialogProps = RegistryAccessDialogCommonProps & {
@@ -34,13 +35,15 @@ export function RegistryAccessDialog({
3435
onSubmit,
3536
open,
3637
pending,
38+
returnFocusRef,
3739
}: RegistryAccessDialogProps) {
3840
const formRef = useRef<HTMLFormElement>(null);
3941
const keyInputRef = useRef<HTMLInputElement>(null);
4042
const actionLabel = mode === "connect" ? "Connect" : "Replace Registry key";
4143

42-
async function handleSubmit(formData: FormData) {
43-
const key = formData.get("registry-key");
44+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
45+
event.preventDefault();
46+
const key = new FormData(event.currentTarget).get("registry-key");
4447
if (typeof key !== "string" || key.trim().length === 0) return;
4548

4649
formRef.current?.reset();
@@ -54,12 +57,16 @@ export function RegistryAccessDialog({
5457
event.preventDefault();
5558
keyInputRef.current?.focus();
5659
}}
60+
onCloseAutoFocus={(event) => {
61+
event.preventDefault();
62+
returnFocusRef.current?.focus();
63+
}}
5764
onOpenChange={onOpenChange}
5865
open={open}
5966
size="sm"
6067
title={mode === "connect" ? "Connect Registry" : "Manage Registry access"}
6168
>
62-
<form action={handleSubmit} ref={formRef} className="space-y-4">
69+
<form className="space-y-4" onSubmit={handleSubmit} ref={formRef}>
6370
<label className="space-y-2 text-sm" htmlFor="registry-key">
6471
<span>Registry key</span>
6572
<Input
@@ -72,6 +79,11 @@ export function RegistryAccessDialog({
7279
type="password"
7380
/>
7481
</label>
82+
{pending && (
83+
<p aria-live="polite" role="status">
84+
Validating Registry key
85+
</p>
86+
)}
7587
<div className="flex flex-wrap justify-end gap-2">
7688
{mode === "manage" && (
7789
<Button

ui/components/registry/registry-artifact-detail.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { type Ref, useState } from "react";
44

55
import { Badge } from "@/components/shadcn/badge/badge";
66
import { Button } from "@/components/shadcn/button/button";
@@ -14,6 +14,8 @@ import type {
1414

1515
interface RegistryArtifactDetailProps {
1616
catalogArtifact?: RegistryCatalogArtifact;
17+
headingRef?: Ref<HTMLHeadingElement>;
18+
removeButtonRef?: Ref<HTMLButtonElement>;
1719
tenantArtifact?: RegistryTenantArtifact;
1820
isMutationPending?: boolean;
1921
onAdd?: (versionSpec?: string) => void;
@@ -22,6 +24,8 @@ interface RegistryArtifactDetailProps {
2224

2325
export function RegistryArtifactDetail({
2426
catalogArtifact: artifact,
27+
headingRef,
28+
removeButtonRef,
2529
tenantArtifact,
2630
isMutationPending = false,
2731
onAdd,
@@ -55,7 +59,12 @@ export function RegistryArtifactDetail({
5559

5660
return (
5761
<section aria-labelledby="registry-artifact-title">
58-
<h1 id="registry-artifact-title" className="text-xl font-semibold">
62+
<h1
63+
className="text-xl font-semibold"
64+
id="registry-artifact-title"
65+
ref={headingRef}
66+
tabIndex={-1}
67+
>
5968
{name}
6069
</h1>
6170
{artifact?.description && (
@@ -84,6 +93,7 @@ export function RegistryArtifactDetail({
8493
<Button
8594
className="mt-6"
8695
onClick={onRemove}
96+
ref={removeButtonRef}
8797
type="button"
8898
variant="destructive"
8999
>

ui/components/registry/registry-eligibility-provider.integration.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { render } from "@/__tests__/render-browser";
55
const { refreshAccessMock } = vi.hoisted(() => ({
66
refreshAccessMock: vi.fn(),
77
}));
8-
vi.mock("@/lib/registry/access.server", () => ({
8+
vi.mock("@/actions/registry/registry", () => ({
99
refreshRegistryEligibility: refreshAccessMock,
1010
}));
1111

0 commit comments

Comments
 (0)