Skip to content

Commit 5fff469

Browse files
fix(ui): revoke Registry access after denied actions
- Invalidate shared eligibility for denied Registry actions - Preserve safe routing and suppress late action results - Cover denial paths through the real access boundary
1 parent ff2da11 commit 5fff469

4 files changed

Lines changed: 207 additions & 89 deletions

File tree

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,7 @@ export function RegistryAccessDialog({
6969
title={mode === "connect" ? "Connect Registry" : "Manage Registry access"}
7070
>
7171
{pending ? (
72-
<div
73-
aria-live="polite"
74-
className="flex items-center gap-3"
75-
role="status"
76-
>
72+
<div className="flex items-center gap-3" role="status">
7773
<Spinner className="motion-reduce:animate-none" />
7874
<p className="font-medium">Validating your Registry key…</p>
7975
</div>

ui/components/registry/registry-explorer.integration.test.tsx

Lines changed: 153 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
1-
import { useState } from "react";
1+
import { useRouter } from "next/navigation";
2+
import type { ReactElement } from "react";
23
import { beforeEach, describe, expect, it, vi } from "vitest";
34
import { userEvent } from "vitest/browser";
45

6+
import { render as renderBrowser } from "@/__tests__/render-browser";
7+
import type { RegistryBootstrapState } from "@/types/registry";
8+
9+
import { RegistryAccessBoundary } from "./registry-access-boundary";
10+
import { RegistryEligibilityProvider } from "./registry-eligibility-provider";
11+
import { RegistryExplorer } from "./registry-explorer";
12+
513
const {
614
addRegistryArtifactMock,
715
disconnectRegistryCredentialMock,
816
refreshRegistryCollectionsMock,
17+
refreshRegistryEligibilityMock,
918
removeRegistryArtifactMock,
1019
submitRegistryCredentialMock,
1120
} = vi.hoisted(() => ({
1221
addRegistryArtifactMock: vi.fn(),
1322
disconnectRegistryCredentialMock: vi.fn(),
1423
refreshRegistryCollectionsMock: vi.fn(),
24+
refreshRegistryEligibilityMock: vi.fn(),
1525
removeRegistryArtifactMock: vi.fn(),
1626
submitRegistryCredentialMock: vi.fn(),
1727
}));
@@ -20,14 +30,19 @@ vi.mock("@/actions/registry/registry", () => ({
2030
addRegistryArtifact: addRegistryArtifactMock,
2131
disconnectRegistryCredential: disconnectRegistryCredentialMock,
2232
refreshRegistryCollections: refreshRegistryCollectionsMock,
33+
refreshRegistryEligibility: refreshRegistryEligibilityMock,
2334
removeRegistryArtifact: removeRegistryArtifactMock,
2435
submitRegistryCredential: submitRegistryCredentialMock,
2536
}));
2637

27-
import { render } from "@/__tests__/render-browser";
28-
import type { RegistryBootstrapState } from "@/types/registry";
29-
30-
import { RegistryExplorer } from "./registry-explorer";
38+
const render = (
39+
ui: ReactElement,
40+
options?: Parameters<typeof renderBrowser>[1],
41+
) =>
42+
renderBrowser(
43+
<RegistryEligibilityProvider>{ui}</RegistryEligibilityProvider>,
44+
options,
45+
);
3146

3247
const onboardingState: RegistryBootstrapState = {
3348
status: "onboarding",
@@ -107,19 +122,31 @@ const readyState: RegistryBootstrapState = {
107122
],
108123
};
109124

110-
function AccessInvalidationHarness() {
111-
const [isAllowed, setIsAllowed] = useState(true);
125+
let registryRouter: ReturnType<typeof useRouter>;
126+
127+
function AuthorizedRegistryExplorer({
128+
initialState = readyState,
129+
}: {
130+
initialState?: RegistryBootstrapState;
131+
}) {
132+
registryRouter = useRouter();
112133

113134
return (
114-
<>
115-
<button onClick={() => setIsAllowed(false)} type="button">
116-
Invalidate access
117-
</button>
118-
{isAllowed && <RegistryExplorer initialState={readyState} />}
119-
</>
135+
<RegistryAccessBoundary initialLeaseDurationMs={30_000}>
136+
<RegistryExplorer initialState={initialState} />
137+
</RegistryAccessBoundary>
120138
);
121139
}
122140

141+
async function expectRegistryAccessRevoked(
142+
screen: Awaited<ReturnType<typeof render>>,
143+
) {
144+
await expect
145+
.element(screen.getByLabelText("Registry explorer"))
146+
.not.toBeInTheDocument();
147+
expect(registryRouter.replace).toHaveBeenCalledWith("/profile");
148+
}
149+
123150
const incompleteState: RegistryBootstrapState = {
124151
status: "incomplete",
125152
catalog: { status: "incomplete", reason: "page_failed", collectedCount: 100 },
@@ -130,8 +157,13 @@ describe("RegistryExplorer", () => {
130157
addRegistryArtifactMock.mockReset();
131158
disconnectRegistryCredentialMock.mockReset();
132159
refreshRegistryCollectionsMock.mockReset();
160+
refreshRegistryEligibilityMock.mockReset();
133161
removeRegistryArtifactMock.mockReset();
134162
submitRegistryCredentialMock.mockReset();
163+
refreshRegistryEligibilityMock.mockResolvedValue({
164+
status: "eligible",
165+
leaseDurationMs: 30_000,
166+
});
135167
});
136168

137169
describe("when Registry access is not connected", () => {
@@ -385,16 +417,115 @@ describe("RegistryExplorer", () => {
385417
.toContain("Available artifacts");
386418
});
387419

388-
it("ignores a late mutation result after Registry access invalidates", async () => {
420+
describe("when a Registry action loses authorization", () => {
421+
it("removes Registry and routes to Profile when Add is denied", async () => {
422+
// Given
423+
addRegistryArtifactMock.mockResolvedValue({ status: "access_denied" });
424+
const screen = await render(<AuthorizedRegistryExplorer />);
425+
await screen.getByText("Multi-provider").click();
426+
await screen
427+
.getByLabelText("Registry explorer")
428+
.getByText("Cloud guard")
429+
.click();
430+
await screen.getByRole("button", { name: "Add" }).click();
431+
432+
// When
433+
await screen.getByRole("button", { name: "Add artifact" }).click();
434+
435+
// Then
436+
await expectRegistryAccessRevoked(screen);
437+
});
438+
439+
it("removes Registry and routes to Profile when Remove is denied", async () => {
440+
// Given
441+
removeRegistryArtifactMock.mockResolvedValue({ status: "access_denied" });
442+
const screen = await render(<AuthorizedRegistryExplorer />);
443+
await screen
444+
.getByLabelText("Registry explorer")
445+
.getByText("aws-guard")
446+
.click();
447+
await screen.getByRole("button", { name: "Remove" }).click();
448+
449+
// When
450+
await screen.getByRole("button", { name: "Confirm Remove" }).click();
451+
452+
// Then
453+
await expectRegistryAccessRevoked(screen);
454+
});
455+
456+
it("removes Registry and routes to Profile when credential submission is denied", async () => {
457+
// Given
458+
submitRegistryCredentialMock.mockResolvedValue({
459+
status: "access_denied",
460+
});
461+
const screen = await render(
462+
<AuthorizedRegistryExplorer initialState={onboardingState} />,
463+
);
464+
await screen.getByRole("button", { name: "Connect Registry" }).click();
465+
await screen.getByLabelText("Registry key").fill("registry-test-key");
466+
467+
// When
468+
await screen
469+
.getByRole("button", { name: "Connect", exact: true })
470+
.click();
471+
472+
// Then
473+
await expectRegistryAccessRevoked(screen);
474+
});
475+
476+
it("removes Registry and routes to Profile when disconnect is denied", async () => {
477+
// Given
478+
disconnectRegistryCredentialMock.mockResolvedValue({
479+
status: "access_denied",
480+
});
481+
const screen = await render(<AuthorizedRegistryExplorer />);
482+
await screen.getByRole("button", { name: "Manage access" }).click();
483+
484+
// When
485+
await screen.getByRole("button", { name: "Disconnect Registry" }).click();
486+
487+
// Then
488+
await expectRegistryAccessRevoked(screen);
489+
});
490+
491+
it("removes Registry and routes to Profile when post-connect collection refresh is denied", async () => {
492+
// Given
493+
submitRegistryCredentialMock.mockResolvedValue({
494+
status: "connected",
495+
credential: readyState.credential,
496+
});
497+
refreshRegistryCollectionsMock.mockResolvedValue({
498+
status: "access_denied",
499+
});
500+
const screen = await render(
501+
<AuthorizedRegistryExplorer initialState={onboardingState} />,
502+
);
503+
await screen.getByRole("button", { name: "Connect Registry" }).click();
504+
await screen.getByLabelText("Registry key").fill("registry-test-key");
505+
506+
// When
507+
await screen
508+
.getByRole("button", { name: "Connect", exact: true })
509+
.click();
510+
511+
// Then
512+
await expectRegistryAccessRevoked(screen);
513+
});
514+
});
515+
516+
it("suppresses a late Add confirmation after the real access boundary revokes Registry", async () => {
389517
// Given
390518
let resolveMutation: ((result: unknown) => void) | undefined;
519+
refreshRegistryEligibilityMock
520+
.mockResolvedValueOnce({ status: "eligible", leaseDurationMs: 30_000 })
521+
.mockResolvedValueOnce({ status: "ineligible" });
391522
addRegistryArtifactMock.mockImplementation(
392523
() =>
393524
new Promise((resolve) => {
394525
resolveMutation = resolve;
395526
}),
396527
);
397-
const screen = await render(<AccessInvalidationHarness />);
528+
const screen = await render(<AuthorizedRegistryExplorer />);
398529
await screen.getByText("Multi-provider").click();
399530
await screen
400531
.getByLabelText("Registry explorer")
@@ -404,7 +535,8 @@ describe("RegistryExplorer", () => {
404535
await screen.getByRole("button", { name: "Add artifact" }).click();
405536

406537
// When
407-
await screen.getByRole("button", { name: "Invalidate access" }).click();
538+
window.dispatchEvent(new Event("focus"));
539+
await expectRegistryAccessRevoked(screen);
408540
resolveMutation?.({
409541
status: "confirmed",
410542
tenantArtifacts: [
@@ -414,9 +546,7 @@ describe("RegistryExplorer", () => {
414546
});
415547

416548
// Then
417-
await expect
418-
.poll(() => document.body.textContent)
419-
.not.toContain("Artifact added");
549+
expect(document.body.textContent).not.toContain("Artifact added");
420550
});
421551

422552
it("keeps the rendered catalog after a failed credential replacement", async () => {
@@ -615,10 +745,10 @@ describe("RegistryExplorer", () => {
615745
.toBeVisible();
616746
});
617747

618-
it("announces an Add failure through an alert without animation callbacks", async () => {
748+
it("keeps ordinary Add errors local without revoking Registry", async () => {
619749
// Given
620750
addRegistryArtifactMock.mockResolvedValue({ status: "error" });
621-
const screen = await render(<RegistryExplorer initialState={readyState} />);
751+
const screen = await render(<AuthorizedRegistryExplorer />);
622752
await screen.getByText("Multi-provider").click();
623753
await screen
624754
.getByLabelText("Registry explorer")
@@ -696,13 +826,13 @@ describe("RegistryExplorer", () => {
696826
.toBeVisible();
697827
});
698828

699-
it("shows documented Add refusals without moving membership", async () => {
829+
it("keeps documented Add refusals local without revoking Registry", async () => {
700830
// Given
701831
addRegistryArtifactMock.mockResolvedValue({
702832
status: "refused",
703833
message: "This version is not verified and cannot be added.",
704834
});
705-
const screen = await render(<RegistryExplorer initialState={readyState} />);
835+
const screen = await render(<AuthorizedRegistryExplorer />);
706836
await screen.getByText("Multi-provider").click();
707837
await screen
708838
.getByLabelText("Registry explorer")

0 commit comments

Comments
 (0)