2121package fed_tests
2222
2323import (
24+ "bytes"
2425 "context"
26+ "encoding/json"
27+ "fmt"
28+ "net/http"
29+ "net/url"
2530 "os"
2631 "path/filepath"
32+ "strings"
2733 "testing"
2834 "time"
2935
3036 _ "github.com/glebarez/sqlite"
3137 "github.com/stretchr/testify/assert"
3238 "github.com/stretchr/testify/require"
3339
34- "github.com/pelicanplatform/pelican/cache"
3540 "github.com/pelicanplatform/pelican/client"
3641 "github.com/pelicanplatform/pelican/config"
3742 "github.com/pelicanplatform/pelican/fed_test_utils"
@@ -41,17 +46,16 @@ import (
4146 "github.com/pelicanplatform/pelican/test_utils"
4247 "github.com/pelicanplatform/pelican/token"
4348 "github.com/pelicanplatform/pelican/token_scopes"
44- "github.com/pelicanplatform/pelican/xrootd"
4549)
4650
4751// TestCacheScitokensConfigOverride tests that Xrootd.ScitokensConfig works for caches
4852// to serve cached objects during origin downtime. This test:
4953// 1. Sets up a full federation with private reads and pulls a file through the cache
50- // 2. Simulates origin downtime by removing namespace ads from the cache
54+ // 2. Simulates origin downtime by POSTing a new origin ad without the /test namespace
5155// 3. Triggers cache authz refresh by overwriting Xrootd.ScitokensConfig with unrelated issuer
52- // 4. Verifies data is no longer accessible through the cache
56+ // 4. Verifies data is no longer accessible through the cache (authorization removed)
5357// 5. Triggers another authz refresh with proper authorization for the test prefix
54- // 6. Verifies cached object is now accessible even without origin
58+ // 6. Verifies cached object is now accessible even with origin "offline"
5559func TestCacheScitokensConfigOverride (t * testing.T ) {
5660 t .Cleanup (test_utils .SetupTestLogging (t ))
5761 server_utils .ResetTestState ()
@@ -75,6 +79,12 @@ func TestCacheScitokensConfigOverride(t *testing.T) {
7579 scitokensConfigPath := filepath .Join (tmpDir , "scitokens.cfg" )
7680 require .NoError (t , param .Set (param .Xrootd_ScitokensConfig .GetName (), scitokensConfigPath ))
7781
82+ // Set floor to 0 to allow immediate director refreshes when scitokens config changes
83+ require .NoError (t , param .Set (param .Cache_MinDirectorRefreshInterval .GetName (), "0s" ))
84+
85+ // Use long-lived ads so they don't expire during test
86+ require .NoError (t , param .Set (param .Server_AdLifetime .GetName (), "1h" ))
87+
7888 // Create origin configuration with private reads (requires tokens)
7989 originConfig := `
8090Origin:
@@ -92,38 +102,124 @@ Origin:
92102 serverIssuerUrl , err := config .GetServerIssuerURL ()
93103 require .NoError (t , err , "Failed to get server issuer URL" )
94104
95- // Get federation info
96- fedInfo , err := config .GetFederation (ctx )
97- require .NoError (t , err )
98-
99105 // Create a token for accessing the object
100106 tokenConfig := token .NewWLCGToken ()
101107 tokenConfig .Lifetime = 30 * time .Minute
102108 tokenConfig .Issuer = serverIssuerUrl
103109 tokenConfig .Subject = "test-subject"
104- tokenConfig .AddAudiences ("https://wlcg.cern.ch/jwt/v1/any" )
105- tokenConfig .AddResourceScopes (token_scopes .NewResourceScope (token_scopes .Wlcg_Storage_Read , "/test" ))
110+ tokenConfig .AddAudienceAny ()
111+
112+ scopes := []token_scopes.TokenScope {}
113+ readScope , err := token_scopes .Wlcg_Storage_Read .Path ("/" )
114+ require .NoError (t , err )
115+ scopes = append (scopes , readScope )
116+ modScope , err := token_scopes .Wlcg_Storage_Modify .Path ("/" )
117+ require .NoError (t , err )
118+ scopes = append (scopes , modScope )
119+ tokenConfig .AddScopes (scopes ... )
106120
107121 tok , err := tokenConfig .CreateToken ()
108122 require .NoError (t , err )
109123
124+ // Construct pelican URL for file operations
125+ pelicanUrl := fmt .Sprintf ("pelican://%s:%d/test/%s" ,
126+ param .Server_Hostname .GetString (), param .Server_WebPort .GetInt (), testFileName )
127+
128+ // Upload the test file to the origin
129+ _ , err = client .DoPut (ctx , testFilePath , pelicanUrl , false , client .WithToken (tok ))
130+ require .NoError (t , err , "Should be able to upload file to origin" )
131+
110132 // Step 1: Download through the federation to populate the cache
111133 destPath1 := filepath .Join (tmpDir , "downloaded1.txt" )
112- transferResults , err := client .DoGet (ctx , "pelican://" + fedInfo . DirectorEndpoint + "/test/" + testFileName , destPath1 , false , client .WithToken (tok ))
134+ transferResults , err := client .DoGet (ctx , pelicanUrl , destPath1 , false , client .WithToken (tok ))
113135 require .NoError (t , err , "Should be able to download file through federation" )
114136 assert .Equal (t , int64 (len (testFileContent )), transferResults [0 ].TransferredBytes )
115137
116138 content1 , err := os .ReadFile (destPath1 )
117139 require .NoError (t , err )
118140 assert .Equal (t , testFileContent , string (content1 ), "Downloaded content should match original" )
119141
120- // Get the cache server to manipulate its state
121- cacheServer := & cache.CacheServer {}
142+ // Step 2: Simulate origin downtime by POSTing new origin ad without /test namespace
143+ metadata , err := server_utils .GetServerMetadata (ctx , server_structs .OriginType )
144+ require .NoError (t , err )
145+
146+ issuerUrlStr , err := config .GetServerIssuerURL ()
147+ require .NoError (t , err )
148+ issuerUrl , err := url .Parse (issuerUrlStr )
149+ require .NoError (t , err )
150+
151+ // Create advertisement with empty namespace list
152+ emptyAd := server_structs.OriginAdvertiseV2 {
153+ ServerID : metadata .ID ,
154+ DataURL : param .Origin_Url .GetString (),
155+ WebURL : param .Server_ExternalWebUrl .GetString (),
156+ Namespaces : []server_structs.NamespaceAdV2 {},
157+ Issuer : []server_structs.TokenIssuer {{
158+ IssuerUrl : * issuerUrl ,
159+ }},
160+ StorageType : server_structs .OriginStoragePosix ,
161+ }
162+ emptyAd .Initialize (metadata .Name )
163+ emptyAd .Now = time .Now ()
164+
165+ body , err := json .Marshal (emptyAd )
166+ require .NoError (t , err )
167+
168+ directorUrlStr := param .Server_ExternalWebUrl .GetString () + "/api/v1.0/director/registerOrigin"
169+ directorUrl , err := url .Parse (directorUrlStr )
170+ require .NoError (t , err )
171+
172+ // Create advertisement token
173+ advTokenCfg := token .NewWLCGToken ()
174+ advTokenCfg .Lifetime = time .Minute
175+ advTokenCfg .Issuer = issuerUrlStr
176+ advTokenCfg .AddAudienceAny ()
177+ advTokenCfg .Subject = param .Server_Hostname .GetString ()
178+ advTokenCfg .AddScopes (token_scopes .Pelican_Advertise )
179+ advTok , err := advTokenCfg .CreateToken ()
180+ require .NoError (t , err )
181+
182+ // POST the empty advertisement
183+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , directorUrl .String (), bytes .NewBuffer (body ))
184+ require .NoError (t , err )
185+ req .Header .Set ("Content-Type" , "application/json" )
186+ req .Header .Set ("Authorization" , "Bearer " + advTok )
187+ req .Header .Set ("User-Agent" , "pelican-test/" + config .GetVersion ())
188+
189+ tr := config .GetTransport ()
190+ httpClient := & http.Client {Transport : tr }
191+ resp , err := httpClient .Do (req )
192+ require .NoError (t , err )
193+ defer resp .Body .Close ()
194+ require .Equal (t , http .StatusOK , resp .StatusCode , "Failed to register empty origin advertisement" )
195+
196+ // Wait for director to process the empty advertisement and remove /test namespace
197+ namespacesUrl := param .Server_ExternalWebUrl .GetString () + "/api/v1.0/director/listNamespaces"
198+ require .Eventually (t , func () bool {
199+ resp , err := httpClient .Get (namespacesUrl )
200+ if err != nil {
201+ return false
202+ }
203+ defer resp .Body .Close ()
204+
205+ var namespaces []server_structs.NamespaceAdV2
206+ if err := json .NewDecoder (resp .Body ).Decode (& namespaces ); err != nil {
207+ return false
208+ }
122209
123- // Step 2: Simulate origin downtime by removing namespace ads from cache
124- cacheServer .SetNamespaceAds ([]server_structs.NamespaceAdV2 {})
210+ // Check that /test namespace is no longer present
211+ for _ , ns := range namespaces {
212+ if ns .Path == "/test" {
213+ return false
214+ }
215+ }
216+ return true
217+ }, 5 * time .Second , 100 * time .Millisecond , "Director should remove /test namespace after processing empty advertisement" )
125218
126219 // Step 3: Trigger cache authz refresh by overwriting Xrootd.ScitokensConfig with an unrelated issuer
220+ // The file watcher will detect this change and call EmitScitokensConfig, which uses cached namespace ads
221+ // from the cache's last GetNamespaceAdsFromDirector() call (which gets data from the director).
222+ // Since we just updated the director to remove /test, the cache's cached ads should now reflect that.
127223 unrelatedConfig := `
128224[Global]
129225audience = https://wlcg.cern.ch/jwt/v1/any
@@ -136,15 +232,20 @@ default_user = xrootd
136232 err = os .WriteFile (scitokensConfigPath , []byte (unrelatedConfig ), 0644 )
137233 require .NoError (t , err )
138234
139- // Trigger the refresh by calling EmitScitokensConfig
140- err = xrootd .EmitScitokensConfig (cacheServer )
141- require .NoError (t , err )
142-
143- // Verify the refresh happened by checking the generated file
235+ // Wait for the background cache process to emit the config with /unrelated/path
144236 generatedConfigPath := filepath .Join (param .Cache_RunLocation .GetString (), "scitokens-cache-generated.cfg" )
145- generatedContent , err := os .ReadFile (generatedConfigPath )
146- require .NoError (t , err )
147- assert .Contains (t , string (generatedContent ), "unrelated-issuer.example.com" , "Generated config should contain the unrelated issuer" )
237+ require .Eventually (t , func () bool {
238+ generatedContent , err := os .ReadFile (generatedConfigPath )
239+ if err != nil {
240+ return false
241+ }
242+ contentStr := string (generatedContent )
243+ // Check that the unrelated issuer override is present and /test is gone
244+ return len (contentStr ) > 0 &&
245+ strings .Contains (contentStr , "unrelated-issuer.example.com" ) &&
246+ strings .Contains (contentStr , "/unrelated/path" ) &&
247+ ! strings .Contains (contentStr , "/test" )
248+ }, 10 * time .Second , 100 * time .Millisecond , "Generated config should contain unrelated issuer override and not /test" )
148249
149250 // Step 4: Verify data is no longer accessible through the cache
150251 // Try to download directly from cache - should fail because authorization is missing
@@ -166,21 +267,28 @@ default_user = xrootd
166267 err = os .WriteFile (scitokensConfigPath , []byte (properConfig ), 0644 )
167268 require .NoError (t , err )
168269
169- // Trigger the refresh again
170- err = xrootd .EmitScitokensConfig (cacheServer )
171- require .NoError (t , err )
172-
173- // Verify the refresh happened by checking the generated file
174- generatedContent , err = os .ReadFile (generatedConfigPath )
175- require .NoError (t , err )
176- assert .Contains (t , string (generatedContent ), serverIssuerUrl , "Generated config should contain the server issuer" )
177- assert .Contains (t , string (generatedContent ), "/test" , "Generated config should contain the /test base path" )
270+ // Wait for the background cache process to emit the updated config with /test
271+ require .Eventually (t , func () bool {
272+ generatedContent , err := os .ReadFile (generatedConfigPath )
273+ if err != nil {
274+ return false
275+ }
276+ contentStr := string (generatedContent )
277+ // Check that the unrelated issuer is gone and our server issuer is present
278+ return len (contentStr ) > 0 &&
279+ ! strings .Contains (contentStr , "unrelated-issuer.example.com" ) &&
280+ strings .Contains (contentStr , serverIssuerUrl ) &&
281+ strings .Contains (contentStr , "TestIssuer" )
282+ }, 10 * time .Second , 100 * time .Millisecond , "Generated config should contain server issuer and not unrelated issuer" )
178283
179- // Step 6: Verify cached object is now accessible even with origin "offline"
284+ // Step 6: Verify cached object is accessible with proper auth, even with origin "offline"
285+ // Use client.DoGet with WithCaches to force use of specific cache
180286 destPath3 := filepath .Join (tmpDir , "downloaded3.txt" )
181- transferResults3 , err := client .DoGet (ctx , cacheUrl + "/test/" + testFileName , destPath3 , false , client .WithToken (tok ))
287+ cacheUrlParsed , err := url .Parse (cacheUrl )
288+ require .NoError (t , err )
289+ transferResults , err = client .DoGet (ctx , pelicanUrl , destPath3 , false , client .WithToken (tok ), client .WithCaches (cacheUrlParsed ))
182290 require .NoError (t , err , "Should be able to access cached data with proper authorization" )
183- assert .Equal (t , int64 (len (testFileContent )), transferResults3 [0 ].TransferredBytes )
291+ assert .Equal (t , int64 (len (testFileContent )), transferResults [0 ].TransferredBytes )
184292
185293 content3 , err := os .ReadFile (destPath3 )
186294 require .NoError (t , err )
0 commit comments