1- // Licensed to the Apache Software Foundation (ASF) under one
2- // or more contributor license agreements. See the NOTICE file
3- // distributed with this work for additional information
4- // regarding copyright ownership. The ASF licenses this file
5- // to you under the Apache License, Version 2.0 (the
6- // "License"); you may not use this file except in compliance
7- // with the License. You may obtain a copy of the License at
8- //
9- // http://www.apache.org/licenses/LICENSE-2.0
10- //
11- // Unless required by applicable law or agreed to in writing,
12- // software distributed under the License is distributed on an
13- // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14- // KIND, either express or implied. See the License for the
15- // specific language governing permissions and limitations
16- // under the License.
17-
18- import { render , screen } from "@testing-library/react" ;
1+ // Licensed to the Apache Software Foundation (ASF) under one or more
2+ // contributor license agreements. See the NOTICE file distributed with
3+ // this work for additional information regarding copyright ownership.
4+ // The ASF licenses this file to You under the Apache License, Version 2.0.
5+
6+ import { fireEvent , render , screen } from "@testing-library/react" ;
197import { beforeEach , describe , expect , it , vi } from "vitest" ;
8+ import { ApiError } from "@/shared/api/client" ;
209import type { Certificate } from "../schemas" ;
2110
22- // Presentation gating only — backend enforces the real authorization.
11+ const baseCertificate : Certificate = {
12+ tenant_id : "tenant-1" ,
13+ client_id : "signer-client" ,
14+ serial_number : 42 ,
15+ key_id : "key-42" ,
16+ principal : "someone-else" ,
17+ user_email : "admin@example.org" ,
18+ public_key_fingerprint : "SHA256:pk" ,
19+ ca_fingerprint : "SHA256:ca" ,
20+ valid_after : 1_700_000_000 ,
21+ valid_before : 4_102_444_800 ,
22+ issued_at : 1_700_000_000 ,
23+ source_ip : "192.0.2.42" ,
24+ granted_extensions : [ "permit-pty" , "permit-user-rc" ] ,
25+ force_command : "/usr/bin/id" ,
26+ revoked : false ,
27+ } ;
28+
2329const state = vi . hoisted ( ( ) => ( {
2430 canManage : true ,
25- cert : {
26- tenant_id : "tenant-1" ,
27- client_id : "signer-client" ,
28- serial_number : 42 ,
29- key_id : "k" ,
30- principal : "someone-else" ,
31- user_email : "admin@example.org" ,
32- public_key_fingerprint : "SHA256:pk" ,
33- ca_fingerprint : "SHA256:ca" ,
34- valid_after : 1_700_000_000 ,
35- valid_before : 4_102_444_800 ,
36- issued_at : 1_700_000_000 ,
37- revoked : false ,
38- } as Certificate ,
31+ query : { } as {
32+ data ?: Certificate ;
33+ isLoading : boolean ;
34+ error : Error | null ;
35+ refetch : ReturnType < typeof vi . fn > ;
36+ } ,
37+ mutate : vi . fn ( ) ,
38+ isPending : false ,
39+ toastSuccess : vi . fn ( ) ,
3940} ) ) ;
4041
4142vi . mock ( "@/shared/casl/AbilityProvider" , ( ) => ( {
@@ -45,52 +46,139 @@ vi.mock("@/shared/casl/AbilityProvider", () => ({
4546 } ) ,
4647} ) ) ;
4748
49+ vi . mock ( "sonner" , ( ) => ( { toast : { success : state . toastSuccess } } ) ) ;
50+
4851vi . mock ( "../queries" , ( ) => ( {
49- useCertificate : ( ) => ( { data : state . cert , isLoading : false , error : null , refetch : vi . fn ( ) } ) ,
50- useRevokeCertificate : ( ) => ( { mutate : vi . fn ( ) , isPending : false } ) ,
52+ useCertificate : ( ) => state . query ,
53+ useRevokeCertificate : ( ) => ( { mutate : state . mutate , isPending : state . isPending } ) ,
5154} ) ) ;
5255
5356import { CertificateDetail } from "../components/CertificateDetail" ;
5457
5558beforeEach ( ( ) => {
5659 state . canManage = true ;
57- state . cert = { ...state . cert , revoked : false } ;
60+ state . query = {
61+ data : { ...baseCertificate } ,
62+ isLoading : false ,
63+ error : null ,
64+ refetch : vi . fn ( ) ,
65+ } ;
66+ state . mutate . mockReset ( ) ;
67+ state . toastSuccess . mockReset ( ) ;
68+ state . isPending = false ;
5869} ) ;
5970
60- describe ( "<CertificateDetail /> revoke gating" , ( ) => {
61- it ( "shows the Revoke button for an admin with the signer write privilege" , ( ) => {
62- state . canManage = true ;
71+ function submitRevoke ( reason = "compromised" ) {
72+ fireEvent . click ( screen . getByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) ;
73+ fireEvent . change ( screen . getByLabelText ( / r e a s o n / i) , { target : { value : reason } } ) ;
74+ fireEvent . click ( screen . getByRole ( "button" , { name : / ^ C o n f i r m r e v o k e $ / } ) ) ;
75+ }
76+
77+ describe ( "<CertificateDetail />" , ( ) => {
78+ it ( "renders complete issuance metadata" , ( ) => {
79+ render ( < CertificateDetail serial = "42" /> ) ;
80+ for ( const value of [
81+ "tenant-1" ,
82+ "signer-client" ,
83+ "admin@example.org" ,
84+ "key-42" ,
85+ "SHA256:pk" ,
86+ "SHA256:ca" ,
87+ "192.0.2.42" ,
88+ "permit-pty, permit-user-rc" ,
89+ "/usr/bin/id" ,
90+ ] ) {
91+ expect ( screen . getByText ( value ) ) . toBeInTheDocument ( ) ;
92+ }
93+ } ) ;
94+
95+ it ( "renders a loading skeleton" , ( ) => {
96+ state . query = { ...state . query , data : undefined , isLoading : true } ;
97+ const { container } = render ( < CertificateDetail serial = "42" /> ) ;
98+ expect ( container . querySelector ( ".animate-pulse" ) ) . toBeInTheDocument ( ) ;
99+ } ) ;
100+
101+ it ( "renders not-found handling for a 404" , ( ) => {
102+ state . query = {
103+ ...state . query ,
104+ data : undefined ,
105+ error : new ApiError ( 404 , "/certificate/99" , { error : "not_found" } ) ,
106+ } ;
107+ render ( < CertificateDetail serial = "99" /> ) ;
108+ expect ( screen . getByRole ( "heading" , { name : / c e r t i f i c a t e n o t f o u n d / i } ) ) . toBeInTheDocument ( ) ;
109+ } ) ;
110+
111+ it ( "renders a retryable detail failure" , ( ) => {
112+ state . query = { ...state . query , data : undefined , error : new Error ( "signer unavailable" ) } ;
113+ render ( < CertificateDetail serial = "42" /> ) ;
114+ fireEvent . click ( screen . getByRole ( "button" , { name : / t r y a g a i n / i } ) ) ;
115+ expect ( state . query . refetch ) . toHaveBeenCalledOnce ( ) ;
116+ } ) ;
117+
118+ it ( "shows the Revoke button only for an active writer" , ( ) => {
63119 render ( < CertificateDetail serial = "42" /> ) ;
64120 expect ( screen . getByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) . toBeInTheDocument ( ) ;
65121 } ) ;
66122
67- it ( "hides the Revoke button for a user without the privilege (ownership is irrelevant) " , ( ) => {
123+ it ( "hides Revoke without write privilege" , ( ) => {
68124 state . canManage = false ;
69125 render ( < CertificateDetail serial = "42" /> ) ;
70126 expect ( screen . queryByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) . not . toBeInTheDocument ( ) ;
71127 } ) ;
72128
73- it ( "hides the Revoke button when the certificate is already revoked" , ( ) => {
74- state . canManage = true ;
75- state . cert = {
76- ...state . cert ,
129+ it . each ( [
130+ [ "revoked" , { revoked : true , revoked_at : 1_700_500_000 , revocation_reason : "old" } ] ,
131+ [ "expired" , { valid_before : 1 } ] ,
132+ [ "not yet valid" , { valid_after : 4_102_444_800 } ] ,
133+ ] ) ( "hides Revoke when the certificate is %s" , ( _label , overrides ) => {
134+ state . query . data = { ...baseCertificate , ...overrides } ;
135+ render ( < CertificateDetail serial = "42" /> ) ;
136+ expect ( screen . queryByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) . not . toBeInTheDocument ( ) ;
137+ } ) ;
138+
139+ it ( "renders authoritative revocation metadata" , ( ) => {
140+ state . query . data = {
141+ ...baseCertificate ,
77142 revoked : true ,
78143 revoked_at : 1_700_500_000 ,
79- revocation_reason : "old" ,
144+ revocation_reason : "original reason" ,
145+ revoked_by : "admin-id" ,
80146 } ;
81147 render ( < CertificateDetail serial = "42" /> ) ;
82- expect ( screen . queryByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) . not . toBeInTheDocument ( ) ;
148+ expect ( screen . getByText ( "original reason" ) ) . toBeInTheDocument ( ) ;
149+ expect ( screen . getByText ( "admin-id" ) ) . toBeInTheDocument ( ) ;
83150 } ) ;
84151
85- it ( "hides the Revoke button when the certificate is expired" , ( ) => {
86- state . cert = { ...state . cert , valid_before : 1 } ;
152+ it . each ( [
153+ [ 403 , "You no longer have permission to revoke certificates." ] ,
154+ [ 409 , "This certificate is no longer active and cannot be revoked." ] ,
155+ ] ) ( "keeps the dialog open for API %s" , ( status , expected ) => {
156+ state . mutate . mockImplementation ( ( _variables , options ) => {
157+ options . onError ( new ApiError ( status , "/revoke" , { error : "failure" } ) ) ;
158+ } ) ;
87159 render ( < CertificateDetail serial = "42" /> ) ;
88- expect ( screen . queryByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) . not . toBeInTheDocument ( ) ;
160+ submitRevoke ( ) ;
161+ expect ( screen . getByRole ( "dialog" ) ) . toBeInTheDocument ( ) ;
162+ expect ( screen . getByText ( expected ) ) . toBeInTheDocument ( ) ;
89163 } ) ;
90164
91- it ( "hides the Revoke button when the certificate is not yet valid" , ( ) => {
92- state . cert = { ...state . cert , valid_after : 4_102_444_800 } ;
165+ it ( "keeps the dialog open after a retryable network failure" , ( ) => {
166+ state . mutate . mockImplementation ( ( _variables , options ) => {
167+ options . onError ( new Error ( "Network request failed" ) ) ;
168+ } ) ;
93169 render ( < CertificateDetail serial = "42" /> ) ;
94- expect ( screen . queryByRole ( "button" , { name : / ^ R e v o k e $ / } ) ) . not . toBeInTheDocument ( ) ;
170+ submitRevoke ( ) ;
171+ expect ( screen . getByRole ( "dialog" ) ) . toBeInTheDocument ( ) ;
172+ expect ( screen . getByText ( "Network request failed" ) ) . toBeInTheDocument ( ) ;
173+ } ) ;
174+
175+ it ( "treats an already-revoked response as informational success" , ( ) => {
176+ state . mutate . mockImplementation ( ( _variables , options ) => {
177+ options . onSuccess ( { already_revoked : true } ) ;
178+ } ) ;
179+ render ( < CertificateDetail serial = "42" /> ) ;
180+ submitRevoke ( ) ;
181+ expect ( state . toastSuccess ) . toHaveBeenCalledWith ( "Certificate was already revoked" ) ;
182+ expect ( screen . queryByRole ( "dialog" ) ) . not . toBeInTheDocument ( ) ;
95183 } ) ;
96184} ) ;
0 commit comments