@@ -67,6 +67,13 @@ vi.mock('@/lib/credentials/environment', () => ({
6767vi . mock ( '@/lib/credentials/atlassian-service-account' , ( ) => ( {
6868 AtlassianValidationError : class AtlassianValidationError extends Error { } ,
6969} ) )
70+ vi . mock ( '@/lib/credentials/oci-api-key-service-account.server' , ( ) => ( {
71+ OciCredentialVerificationError : class OciCredentialVerificationError extends Error {
72+ constructor ( readonly code : string ) {
73+ super ( code )
74+ }
75+ } ,
76+ } ) )
7077vi . mock ( '@/lib/credentials/token-service-accounts/errors' , ( ) => ( {
7178 TokenServiceAccountValidationError : class TokenServiceAccountValidationError extends Error { } ,
7279} ) )
@@ -176,6 +183,13 @@ describe('performUpdateCredential — service-account secret rotation', () => {
176183 principal : { kind : 'user' , id : 'ocid1.user.oc1..replacement' } ,
177184 } )
178185
186+ const { ServiceAccountSecretError } = await import ( '@/lib/credentials/service-account-secret' )
187+ mockVerifyAndBuildServiceAccountSecret . mockRejectedValueOnce (
188+ new ServiceAccountSecretError (
189+ 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials'
190+ )
191+ )
192+
179193 const incomplete = await performUpdateCredential ( {
180194 credentialId : 'cred-1' ,
181195 userId : 'user-1' ,
@@ -184,9 +198,12 @@ describe('performUpdateCredential — service-account secret rotation', () => {
184198 expect ( incomplete ) . toMatchObject ( {
185199 success : false ,
186200 errorCode : 'validation' ,
187- error : expect . stringContaining ( 'complete signing tuple ' ) ,
201+ error : expect . stringContaining ( 'tenancyOcid, userOcid, fingerprint, privateKey, and region ' ) ,
188202 } )
189- expect ( mockVerifyAndBuildServiceAccountSecret ) . not . toHaveBeenCalled ( )
203+ expect ( mockVerifyAndBuildServiceAccountSecret ) . toHaveBeenCalledWith (
204+ 'oci-api-key-service-account' ,
205+ expect . objectContaining ( { privateKey : 'replacement-key' } )
206+ )
190207
191208 const complete = await performUpdateCredential ( {
192209 credentialId : 'cred-1' ,
@@ -198,7 +215,7 @@ describe('performUpdateCredential — service-account secret rotation', () => {
198215 region : 'us-ashburn-1' ,
199216 } )
200217 expect ( complete . success ) . toBe ( true )
201- expect ( mockVerifyAndBuildServiceAccountSecret ) . toHaveBeenCalledWith (
218+ expect ( mockVerifyAndBuildServiceAccountSecret ) . toHaveBeenLastCalledWith (
202219 'oci-api-key-service-account' ,
203220 expect . objectContaining ( {
204221 tenancyOcid : 'ocid1.tenancy.oc1..tenant' ,
@@ -212,6 +229,49 @@ describe('performUpdateCredential — service-account secret rotation', () => {
212229 expect ( updatePayload ( ) . encryptedServiceAccountKey ) . toBe ( 'new-oci-cipher' )
213230 } )
214231
232+ it . each ( [
233+ [ 'invalid_credentials' , 'invalid_credentials' , 'OCI rejected the API-key credential' ] ,
234+ [
235+ 'service_unavailable' ,
236+ 'provider_unavailable' ,
237+ 'OCI is temporarily unavailable for credential verification' ,
238+ ] ,
239+ [
240+ 'invalid_response' ,
241+ 'provider_unavailable' ,
242+ 'OCI is temporarily unavailable for credential verification' ,
243+ ] ,
244+ ] as const ) (
245+ 'maps OCI %s rotation failures without changing generic service-account errors' ,
246+ async ( code , providerErrorCode , message ) => {
247+ mockCredential ( { providerId : 'oci-api-key-service-account' } )
248+ const { OciCredentialVerificationError } = await import (
249+ '@/lib/credentials/oci-api-key-service-account.server'
250+ )
251+ mockVerifyAndBuildServiceAccountSecret . mockRejectedValue (
252+ new OciCredentialVerificationError ( code )
253+ )
254+
255+ const result = await performUpdateCredential ( {
256+ credentialId : 'cred-1' ,
257+ userId : 'user-1' ,
258+ tenancyOcid : 'ocid1.tenancy.oc1..tenant' ,
259+ userOcid : 'ocid1.user.oc1..replacement' ,
260+ fingerprint : '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff' ,
261+ privateKey : 'replacement-key' ,
262+ region : 'us-ashburn-1' ,
263+ } )
264+
265+ expect ( result ) . toMatchObject ( {
266+ success : false ,
267+ errorCode : 'validation' ,
268+ error : message ,
269+ providerErrorCode,
270+ } )
271+ expect ( dbChainMockFns . update ) . not . toHaveBeenCalled ( )
272+ }
273+ )
274+
215275 it ( 'keeps a label the user typed instead of the derived identity' , async ( ) => {
216276 mockCredential ( { displayName : 'Prod billing exporter' } )
217277 mockStoredBlob ( { type : 'service_account' , client_email : OLD_EMAIL } )
@@ -481,6 +541,7 @@ describe('performUpdateCredential — service-account secret rotation', () => {
481541 } )
482542
483543 expect ( result ) . toMatchObject ( { success : false , errorCode : 'validation' } )
544+ expect ( result ) . not . toHaveProperty ( 'providerErrorCode' )
484545 expect ( dbChainMockFns . update ) . not . toHaveBeenCalled ( )
485546 expect ( mockRecordAudit ) . not . toHaveBeenCalled ( )
486547 } )
@@ -662,6 +723,54 @@ describe('createServiceAccountCredential', () => {
662723 resetDbChainMock ( )
663724 } )
664725
726+ it . each ( [
727+ [ 'invalid_credentials' , 'invalid_credentials' , false , 'OCI rejected the API-key credential' ] ,
728+ [
729+ 'service_unavailable' ,
730+ 'provider_unavailable' ,
731+ true ,
732+ 'OCI is temporarily unavailable for credential verification' ,
733+ ] ,
734+ [
735+ 'invalid_response' ,
736+ 'provider_unavailable' ,
737+ true ,
738+ 'OCI is temporarily unavailable for credential verification' ,
739+ ] ,
740+ ] as const ) (
741+ 'maps OCI %s creation failures through the existing provider result contract' ,
742+ async ( code , providerErrorCode , providerUnavailable , message ) => {
743+ const { OciCredentialVerificationError } = await import (
744+ '@/lib/credentials/oci-api-key-service-account.server'
745+ )
746+ mockVerifyAndBuildServiceAccountSecret . mockRejectedValue (
747+ new OciCredentialVerificationError ( code )
748+ )
749+
750+ const result = await createServiceAccountCredential ( {
751+ workspaceId : 'workspace-1' ,
752+ userId : 'user-1' ,
753+ providerId : 'oci-api-key-service-account' ,
754+ displayName : 'OCI signer' ,
755+ tenancyOcid : 'ocid1.tenancy.oc1..tenant' ,
756+ userOcid : 'ocid1.user.oc1..principal' ,
757+ fingerprint : '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff' ,
758+ privateKey : 'provider-secret-key' ,
759+ region : 'us-ashburn-1' ,
760+ } )
761+
762+ expect ( result ) . toMatchObject ( {
763+ success : false ,
764+ errorCode : 'validation' ,
765+ error : message ,
766+ providerErrorCode,
767+ providerUnavailable,
768+ } )
769+ expect ( JSON . stringify ( result ) ) . not . toContain ( 'provider-secret-key' )
770+ expect ( dbChainMockFns . insert ) . not . toHaveBeenCalled ( )
771+ }
772+ )
773+
665774 it ( 'rejects an existing service-account source instead of discarding the submitted secret' , async ( ) => {
666775 mockVerifyAndBuildServiceAccountSecret . mockResolvedValue ( {
667776 providerId : 'zoom-service-account' ,
0 commit comments