Skip to content

Commit ff2da11

Browse files
fix(ui): stabilize Registry acceptance flows
- Align credential and artifact actions with backend contracts - Preserve valid eligibility leases during routine refresh failures - Improve validation progress, empty states, and control spacing - Add regression coverage for authorization and explorer behavior
1 parent 01dc698 commit ff2da11

12 files changed

Lines changed: 658 additions & 126 deletions

ui/actions/registry/registry.adapter.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,35 @@ describe("Registry adapter", () => {
5959
expect(adaptRegistryCredentialStatus(malformedPayload)).toBeNull();
6060
});
6161

62+
it("normalizes an absent credential status with nullable validation fields", () => {
63+
// Given
64+
const absentCredentialPayload = {
65+
data: {
66+
attributes: {
67+
configured: false,
68+
is_valid: false,
69+
scopes: [],
70+
last_validated_at: null,
71+
validation_status: null,
72+
validation_pending: false,
73+
},
74+
},
75+
};
76+
77+
// When
78+
const status = adaptRegistryCredentialStatus(absentCredentialPayload);
79+
80+
// Then
81+
expect(status).toEqual({
82+
configured: false,
83+
isValid: false,
84+
scopes: [],
85+
lastValidatedAt: undefined,
86+
validationStatus: undefined,
87+
validationPending: false,
88+
});
89+
});
90+
6291
it("accepts only a matching 202 task and fixed Content-Location path", async () => {
6392
// Given
6493
const response = new Response(

ui/actions/registry/registry.adapter.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ const credentialStatusSchema = z.object({
4141
configured: z.boolean(),
4242
is_valid: z.boolean(),
4343
scopes: z.array(z.string()),
44-
last_validated_at: z.string().optional(),
45-
validation_status: z.string().optional(),
44+
last_validated_at: z.string().nullish(),
45+
validation_status: z.string().nullish(),
4646
validation_pending: z.boolean(),
4747
}),
4848
}),
@@ -85,8 +85,8 @@ export function adaptRegistryCredentialStatus(
8585
configured: attributes.configured,
8686
isValid: attributes.is_valid,
8787
scopes: attributes.scopes,
88-
lastValidatedAt: attributes.last_validated_at,
89-
validationStatus: attributes.validation_status,
88+
lastValidatedAt: attributes.last_validated_at ?? undefined,
89+
validationStatus: attributes.validation_status ?? undefined,
9090
validationPending: attributes.validation_pending,
9191
};
9292
}

ui/actions/registry/registry.test.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const tenantArtifactsResponse = () =>
6767
jsonResponse({
6868
data: [
6969
{
70-
type: "registry-tenant-artifacts",
70+
type: "registry-artifacts",
7171
id: "prowler-aws",
7272
attributes: {
7373
version_spec: "latest",
@@ -199,7 +199,7 @@ describe("Registry guarded reads", () => {
199199
});
200200
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
201201
"https://api.test/api/v1/registry/credential",
202-
"https://api.test/api/v1/registry/my-artifacts",
202+
"https://api.test/api/v1/registry/artifacts",
203203
"https://api.test/api/v1/registry/providers",
204204
"https://api.test/api/v1/registry/available-artifacts?page%5Bnumber%5D=1&page%5Bsize%5D=100",
205205
]);
@@ -463,7 +463,7 @@ describe("Registry guarded reads", () => {
463463
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
464464
"https://api.test/api/v1/registry/credential",
465465
"https://api.test/api/v1/registry/credential",
466-
"https://api.test/api/v1/registry/my-artifacts",
466+
"https://api.test/api/v1/registry/artifacts",
467467
]);
468468
expect(fetchMock).toHaveBeenNthCalledWith(
469469
1,
@@ -674,7 +674,7 @@ describe("Registry guarded reads", () => {
674674
jsonResponse({
675675
data: [
676676
{
677-
type: "registry-tenant-artifacts",
677+
type: "registry-artifacts",
678678
id: "later-guard",
679679
attributes: { version_spec: "2.0.0" },
680680
},
@@ -697,13 +697,15 @@ describe("Registry guarded reads", () => {
697697
});
698698
expect(fetchMock).toHaveBeenNthCalledWith(
699699
1,
700-
"https://api.test/api/v1/registry/my-artifacts",
700+
"https://api.test/api/v1/registry/artifacts",
701701
expect.objectContaining({
702702
body: JSON.stringify({
703703
data: {
704-
type: "registry-tenant-artifacts",
705-
id: "later-guard",
706-
attributes: { version_spec: "2.0.0" },
704+
type: "registry-artifacts",
705+
attributes: {
706+
normalized_name: "later-guard",
707+
version_spec: "2.0.0",
708+
},
707709
},
708710
}),
709711
cache: "no-store",
@@ -720,7 +722,7 @@ describe("Registry guarded reads", () => {
720722
jsonResponse({
721723
data: [
722724
{
723-
type: "registry-tenant-artifacts",
725+
type: "registry-artifacts",
724726
id: "later-guard",
725727
attributes: { version_spec: "latest" },
726728
},
@@ -734,13 +736,15 @@ describe("Registry guarded reads", () => {
734736
// Then
735737
expect(fetchMock).toHaveBeenNthCalledWith(
736738
1,
737-
"https://api.test/api/v1/registry/my-artifacts",
739+
"https://api.test/api/v1/registry/artifacts",
738740
expect.objectContaining({
739741
body: JSON.stringify({
740742
data: {
741-
type: "registry-tenant-artifacts",
742-
id: "later-guard",
743-
attributes: { version_spec: "latest" },
743+
type: "registry-artifacts",
744+
attributes: {
745+
normalized_name: "later-guard",
746+
version_spec: "latest",
747+
},
744748
},
745749
}),
746750
}),
@@ -790,7 +794,7 @@ describe("Registry guarded reads", () => {
790794
expect(result).toEqual({ status: "confirmed", tenantArtifacts: [] });
791795
expect(fetchMock).toHaveBeenNthCalledWith(
792796
1,
793-
"https://api.test/api/v1/registry/my-artifacts/guard%2Fwith%20space",
797+
"https://api.test/api/v1/registry/artifacts/guard%2Fwith%20space",
794798
expect.objectContaining({ cache: "no-store", method: "DELETE" }),
795799
);
796800
});
@@ -807,7 +811,7 @@ describe("Registry guarded reads", () => {
807811
{
808812
data: [
809813
{
810-
type: "registry-tenant-artifacts",
814+
type: "registry-artifacts",
811815
id: "later-guard",
812816
attributes: { version_spec: "latest" },
813817
},

ui/actions/registry/registry.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ async function readRegistryCredential(accessToken: string) {
9797
async function readRegistryTenantArtifacts(accessToken: string) {
9898
const result = await readRegistryResponse(
9999
accessToken,
100-
"my-artifacts",
100+
"artifacts",
101101
REGISTRY_ENDPOINT.MUTATION,
102102
);
103103
if (!(result instanceof Response)) return result;
@@ -347,7 +347,7 @@ export async function addRegistryArtifact({
347347

348348
let response: Response;
349349
try {
350-
response = await fetch(`${apiBaseUrl}/registry/my-artifacts`, {
350+
response = await fetch(`${apiBaseUrl}/registry/artifacts`, {
351351
method: "POST",
352352
cache: "no-store",
353353
headers: {
@@ -357,9 +357,11 @@ export async function addRegistryArtifact({
357357
},
358358
body: JSON.stringify({
359359
data: {
360-
type: "registry-tenant-artifacts",
361-
id: normalizedName,
362-
attributes: { version_spec: selectedVersion },
360+
type: "registry-artifacts",
361+
attributes: {
362+
normalized_name: normalizedName,
363+
version_spec: selectedVersion,
364+
},
363365
},
364366
}),
365367
});
@@ -389,7 +391,7 @@ export async function removeRegistryArtifact(
389391
let response: Response;
390392
try {
391393
response = await fetch(
392-
`${apiBaseUrl}/registry/my-artifacts/${encodeURIComponent(normalizedName)}`,
394+
`${apiBaseUrl}/registry/artifacts/${encodeURIComponent(normalizedName)}`,
393395
{
394396
method: "DELETE",
395397
cache: "no-store",

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

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,42 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { act } from "react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23

34
import { render } from "@/__tests__/render-browser";
45

6+
const { refreshAccessMock } = vi.hoisted(() => ({
7+
refreshAccessMock: vi.fn(),
8+
}));
59
vi.mock("@/actions/registry/registry", () => ({
6-
refreshRegistryEligibility: () => Promise.resolve({ status: "ineligible" }),
10+
refreshRegistryEligibility: refreshAccessMock,
711
}));
812

913
import { RegistryAccessBoundary } from "./registry-access-boundary";
10-
import { RegistryEligibilityProvider } from "./registry-eligibility-provider";
14+
import {
15+
RegistryEligibilityProvider,
16+
useRegistryEligibility,
17+
} from "./registry-eligibility-provider";
18+
19+
function EligibilityProbe() {
20+
const { generation, isEligible, status } = useRegistryEligibility();
21+
return (
22+
<p>{`${isEligible ? "eligible" : status} generation ${generation}`}</p>
23+
);
24+
}
1125

1226
describe("RegistryAccessBoundary", () => {
27+
beforeEach(() => {
28+
refreshAccessMock.mockReset();
29+
});
30+
31+
afterEach(() => {
32+
vi.useRealTimers();
33+
});
34+
1335
it("unmounts protected Registry state after client access denial", async () => {
14-
// Given / When
36+
// Given
37+
refreshAccessMock.mockResolvedValue({ status: "ineligible" });
38+
39+
// When
1540
await render(
1641
<RegistryEligibilityProvider>
1742
<RegistryAccessBoundary initialLeaseDurationMs={30_000}>
@@ -25,4 +50,84 @@ describe("RegistryAccessBoundary", () => {
2550
.poll(() => document.body.textContent)
2651
.not.toContain("Protected Registry state");
2752
});
53+
54+
it("keeps protected Registry state mounted during an unexpired server lease", async () => {
55+
// Given
56+
refreshAccessMock.mockImplementation(() => new Promise(() => {}));
57+
const view = await render(
58+
<RegistryEligibilityProvider>
59+
<EligibilityProbe />
60+
<RegistryAccessBoundary initialLeaseDurationMs={30_000}>
61+
<p>Protected Registry state</p>
62+
</RegistryAccessBoundary>
63+
</RegistryEligibilityProvider>,
64+
);
65+
await expect
66+
.element(view.getByText("Protected Registry state"))
67+
.toBeVisible();
68+
69+
// When
70+
window.dispatchEvent(new Event("focus"));
71+
await expect.poll(() => refreshAccessMock).toHaveBeenCalledTimes(2);
72+
73+
// Then
74+
await expect.element(view.getByText("unknown generation 0")).toBeVisible();
75+
await expect
76+
.element(view.getByText("Protected Registry state"))
77+
.toBeVisible();
78+
});
79+
80+
it("keeps protected Registry state mounted when a routine recheck outlasts the server lease", async () => {
81+
// Given
82+
vi.useFakeTimers();
83+
refreshAccessMock
84+
.mockResolvedValueOnce({ status: "eligible", leaseDurationMs: 60_000 })
85+
.mockImplementation(() => new Promise(() => {}));
86+
const view = await render(
87+
<RegistryEligibilityProvider>
88+
<RegistryAccessBoundary initialLeaseDurationMs={30_000}>
89+
<p>Protected Registry state</p>
90+
</RegistryAccessBoundary>
91+
</RegistryEligibilityProvider>,
92+
);
93+
await expect
94+
.element(view.getByText("Protected Registry state"))
95+
.toBeVisible();
96+
97+
// When
98+
window.dispatchEvent(new Event("focus"));
99+
await expect.poll(() => refreshAccessMock).toHaveBeenCalledTimes(2);
100+
await act(async () => {
101+
await vi.advanceTimersByTimeAsync(30_000);
102+
});
103+
104+
// Then
105+
await expect
106+
.element(view.getByText("Protected Registry state"))
107+
.toBeVisible();
108+
});
109+
110+
it("fails closed after the initial unknown state reaches the bounded server lease", async () => {
111+
// Given
112+
vi.useFakeTimers();
113+
refreshAccessMock.mockImplementation(() => new Promise(() => {}));
114+
const view = await render(
115+
<RegistryEligibilityProvider>
116+
<RegistryAccessBoundary initialLeaseDurationMs={30_000}>
117+
<p>Protected Registry state</p>
118+
</RegistryAccessBoundary>
119+
</RegistryEligibilityProvider>,
120+
);
121+
await expect
122+
.element(view.getByText("Protected Registry state"))
123+
.toBeVisible();
124+
125+
// When
126+
await act(async () => {
127+
await vi.advanceTimersByTimeAsync(30_000);
128+
});
129+
130+
// Then
131+
expect(document.body.textContent).not.toContain("Protected Registry state");
132+
});
28133
});

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,12 @@ export function RegistryAccessBoundary({
1515
initialLeaseDurationMs: number;
1616
}) {
1717
const router = useRouter();
18-
const { generation, isEligible, status } = useRegistryEligibility();
18+
const { isEligible, status } = useRegistryEligibility();
1919
const expiresAt = useRef(Date.now() + initialLeaseDurationMs);
2020
const [now, setNow] = useState(Date.now());
2121
const allowed =
2222
isEligible ||
23-
(status === REGISTRY_ACCESS.UNKNOWN &&
24-
generation <= 1 &&
25-
now < expiresAt.current);
23+
(status === REGISTRY_ACCESS.UNKNOWN && now < expiresAt.current);
2624

2725
useEffect(() => {
2826
const timer = window.setTimeout(

0 commit comments

Comments
 (0)