1- import { useState } from "react" ;
1+ import { useRouter } from "next/navigation" ;
2+ import type { ReactElement } from "react" ;
23import { beforeEach , describe , expect , it , vi } from "vitest" ;
34import { 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+
513const {
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
3247const 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+
123150const 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