@@ -353,6 +353,217 @@ func TestEnsureAudienceScope_VerifyRecreatesMissingMapper(t *testing.T) {
353353 }
354354}
355355
356+ // TestEnsureAudienceScope_DeletesWrongTypeMapper verifies that when a mapper with the
357+ // correct name exists but has the wrong protocolMapper type (not oidc-audience-mapper),
358+ // the operator deletes it and recreates the correct mapper. This is the fix for #358.
359+ func TestEnsureAudienceScope_DeletesWrongTypeMapper (t * testing.T ) {
360+ var deleteMapperCalls , recreatePostCalls int
361+ spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
362+
363+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
364+ path := r .URL .Path
365+ switch {
366+ case path == testMasterRealmTokenPath :
367+ w .Header ().Set ("Content-Type" , "application/json" )
368+ _ = json .NewEncoder (w ).Encode (map [string ]string {"access_token" : "tok" })
369+
370+ // Scope already exists
371+ case path == "/admin/realms/kagenti/client-scopes" && r .Method == http .MethodGet :
372+ _ = json .NewEncoder (w ).Encode ([]clientScopeListItem {{ID : "scope-123" , Name : "agent-ns-wl-aud" }})
373+
374+ // ensureAudienceMapper POST — 409 conflict (mapper name taken)
375+ case strings .Contains (path , "/client-scopes/scope-123/protocol-mappers/models" ) && r .Method == http .MethodPost :
376+ if deleteMapperCalls > 0 {
377+ // After delete, the POST succeeds
378+ recreatePostCalls ++
379+ w .WriteHeader (http .StatusCreated )
380+ } else {
381+ w .WriteHeader (http .StatusConflict )
382+ }
383+
384+ // GET mappers — returns a mapper with wrong type (e.g. "oidc-hardcoded-claim-mapper")
385+ case strings .Contains (path , "/client-scopes/scope-123/protocol-mappers/models" ) && r .Method == http .MethodGet :
386+ if deleteMapperCalls > 0 {
387+ // After delete+recreate, verify sees the correct mapper
388+ _ = json .NewEncoder (w ).Encode ([]protocolMapperRep {{
389+ ID : "m-new" , Name : "agent-ns-wl-aud" , Protocol : "openid-connect" ,
390+ ProtocolMapper : "oidc-audience-mapper" ,
391+ Config : map [string ]string {"included.custom.audience" : spiffeURI },
392+ }})
393+ } else {
394+ _ = json .NewEncoder (w ).Encode ([]protocolMapperRep {{
395+ ID : "mapper-stale" ,
396+ Name : "agent-ns-wl-aud" ,
397+ Protocol : "openid-connect" ,
398+ ProtocolMapper : "oidc-hardcoded-claim-mapper" , // WRONG TYPE
399+ Config : map [string ]string {"claim.value" : "something" },
400+ }})
401+ }
402+
403+ // DELETE the stale mapper
404+ case strings .Contains (path , "/protocol-mappers/models/mapper-stale" ) && r .Method == http .MethodDelete :
405+ deleteMapperCalls ++
406+ w .WriteHeader (http .StatusNoContent )
407+
408+ // Realm default scope
409+ case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r .Method == http .MethodPut :
410+ w .WriteHeader (http .StatusNoContent )
411+
412+ default :
413+ t .Fatalf ("unexpected %s %s" , r .Method , path )
414+ }
415+ }))
416+ defer srv .Close ()
417+
418+ a := Admin {BaseURL : srv .URL , HTTPClient : srv .Client ()}
419+ token , err := a .PasswordGrantToken (context .Background (), "u" , "p" )
420+ if err != nil {
421+ t .Fatal (err )
422+ }
423+
424+ err = a .EnsureAudienceScope (context .Background (), token , AudienceParams {
425+ Realm : "kagenti" ,
426+ ClientName : "ns/wl" ,
427+ AudienceClientID : spiffeURI ,
428+ AudienceScopeEnabled : true ,
429+ })
430+ if err != nil {
431+ t .Fatal (err )
432+ }
433+ if deleteMapperCalls != 1 {
434+ t .Fatalf ("expected 1 DELETE call for stale mapper, got %d" , deleteMapperCalls )
435+ }
436+ if recreatePostCalls != 1 {
437+ t .Fatalf ("expected 1 POST call to recreate mapper after delete, got %d" , recreatePostCalls )
438+ }
439+ }
440+
441+ // TestEnsureAudienceScope_PhantomConflictRetry verifies that when the mapper POST
442+ // returns 409 but the scope's mapper list is empty (phantom Keycloak conflict),
443+ // the operator retries the POST directly and succeeds. This is the actual scenario
444+ // observed on Kind clusters where Keycloak reports a conflict for a non-existent mapper.
445+ func TestEnsureAudienceScope_PhantomConflictRetry (t * testing.T ) {
446+ var postCalls int
447+ spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
448+
449+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
450+ path := r .URL .Path
451+ switch {
452+ case path == testMasterRealmTokenPath :
453+ w .Header ().Set ("Content-Type" , "application/json" )
454+ _ = json .NewEncoder (w ).Encode (map [string ]string {"access_token" : "tok" })
455+
456+ // Scope already exists
457+ case path == "/admin/realms/kagenti/client-scopes" && r .Method == http .MethodGet :
458+ _ = json .NewEncoder (w ).Encode ([]clientScopeListItem {{ID : "scope-123" , Name : "agent-ns-wl-aud" }})
459+
460+ // POST mapper — first call returns 409 (phantom), subsequent calls succeed
461+ case strings .Contains (path , "/client-scopes/scope-123/protocol-mappers/models" ) && r .Method == http .MethodPost :
462+ postCalls ++
463+ if postCalls == 1 {
464+ w .WriteHeader (http .StatusConflict )
465+ } else {
466+ w .WriteHeader (http .StatusCreated )
467+ }
468+
469+ // GET mappers — returns empty (the phantom: 409 but no actual mapper)
470+ case strings .Contains (path , "/client-scopes/scope-123/protocol-mappers/models" ) && r .Method == http .MethodGet :
471+ if postCalls >= 2 {
472+ // After retry POST succeeds, verify sees the mapper
473+ _ = json .NewEncoder (w ).Encode ([]protocolMapperRep {{
474+ ID : "m-new" , Name : "agent-ns-wl-aud" , Protocol : "openid-connect" ,
475+ ProtocolMapper : "oidc-audience-mapper" ,
476+ Config : map [string ]string {"included.custom.audience" : spiffeURI },
477+ }})
478+ } else {
479+ _ = json .NewEncoder (w ).Encode ([]protocolMapperRep {})
480+ }
481+
482+ // Realm default scope
483+ case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r .Method == http .MethodPut :
484+ w .WriteHeader (http .StatusNoContent )
485+
486+ default :
487+ t .Fatalf ("unexpected %s %s" , r .Method , path )
488+ }
489+ }))
490+ defer srv .Close ()
491+
492+ a := Admin {BaseURL : srv .URL , HTTPClient : srv .Client ()}
493+ token , err := a .PasswordGrantToken (context .Background (), "u" , "p" )
494+ if err != nil {
495+ t .Fatal (err )
496+ }
497+
498+ err = a .EnsureAudienceScope (context .Background (), token , AudienceParams {
499+ Realm : "kagenti" ,
500+ ClientName : "ns/wl" ,
501+ AudienceClientID : spiffeURI ,
502+ AudienceScopeEnabled : true ,
503+ })
504+ if err != nil {
505+ t .Fatal (err )
506+ }
507+ if postCalls != 2 {
508+ t .Fatalf ("expected 2 POST calls (first 409, then retry succeeds), got %d" , postCalls )
509+ }
510+ }
511+
512+ // TestEnsureAudienceScope_ConcurrentCreate409 verifies that when both the initial
513+ // and retry POST return 409 (concurrent reconcile created the mapper), the operator
514+ // treats it as success rather than entering an error loop.
515+ func TestEnsureAudienceScope_ConcurrentCreate409 (t * testing.T ) {
516+ spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
517+
518+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
519+ path := r .URL .Path
520+ switch {
521+ case path == testMasterRealmTokenPath :
522+ w .Header ().Set ("Content-Type" , "application/json" )
523+ _ = json .NewEncoder (w ).Encode (map [string ]string {"access_token" : "tok" })
524+
525+ case path == "/admin/realms/kagenti/client-scopes" && r .Method == http .MethodGet :
526+ _ = json .NewEncoder (w ).Encode ([]clientScopeListItem {{ID : "scope-123" , Name : "agent-ns-wl-aud" }})
527+
528+ // All POSTs return 409 (concurrent reconcile already created it)
529+ case strings .Contains (path , "/client-scopes/scope-123/protocol-mappers/models" ) && r .Method == http .MethodPost :
530+ w .WriteHeader (http .StatusConflict )
531+
532+ // GET mappers — empty during updateAudienceMapperIfNeeded (race: mapper not yet visible)
533+ // but present during verifyAudienceMapper (transaction committed)
534+ case strings .Contains (path , "/client-scopes/scope-123/protocol-mappers/models" ) && r .Method == http .MethodGet :
535+ _ = json .NewEncoder (w ).Encode ([]protocolMapperRep {{
536+ ID : "m-concurrent" , Name : "agent-ns-wl-aud" , Protocol : "openid-connect" ,
537+ ProtocolMapper : "oidc-audience-mapper" ,
538+ Config : map [string ]string {"included.custom.audience" : spiffeURI },
539+ }})
540+
541+ case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r .Method == http .MethodPut :
542+ w .WriteHeader (http .StatusNoContent )
543+
544+ default :
545+ t .Fatalf ("unexpected %s %s" , r .Method , path )
546+ }
547+ }))
548+ defer srv .Close ()
549+
550+ a := Admin {BaseURL : srv .URL , HTTPClient : srv .Client ()}
551+ token , err := a .PasswordGrantToken (context .Background (), "u" , "p" )
552+ if err != nil {
553+ t .Fatal (err )
554+ }
555+
556+ err = a .EnsureAudienceScope (context .Background (), token , AudienceParams {
557+ Realm : "kagenti" ,
558+ ClientName : "ns/wl" ,
559+ AudienceClientID : spiffeURI ,
560+ AudienceScopeEnabled : true ,
561+ })
562+ if err != nil {
563+ t .Fatalf ("expected success when concurrent reconcile created mapper, got: %v" , err )
564+ }
565+ }
566+
356567func TestEnsureAudienceScope_Disabled (t * testing.T ) {
357568 a := Admin {}
358569 err := a .EnsureAudienceScope (context .Background (), "t" , AudienceParams {AudienceScopeEnabled : false })
0 commit comments