|
| 1 | +//go:build !windows |
| 2 | + |
| 3 | +/*************************************************************** |
| 4 | +* |
| 5 | +* Copyright (C) 2026, Pelican Project, Morgridge Institute for Research |
| 6 | +* |
| 7 | +* Licensed under the Apache License, Version 2.0 (the "License"); you |
| 8 | +* may not use this file except in compliance with the License. You may |
| 9 | +* obtain a copy of the License at |
| 10 | +* |
| 11 | +* http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | +* |
| 13 | +* Unless required by applicable law or agreed to in writing, software |
| 14 | +* distributed under the License is distributed on an "AS IS" BASIS, |
| 15 | +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 16 | +* See the License for the specific language governing permissions and |
| 17 | +* limitations under the License. |
| 18 | +* |
| 19 | +***************************************************************/ |
| 20 | + |
| 21 | +package fed_tests |
| 22 | + |
| 23 | +import ( |
| 24 | + "bytes" |
| 25 | + "context" |
| 26 | + "encoding/json" |
| 27 | + "fmt" |
| 28 | + "net/http" |
| 29 | + "net/url" |
| 30 | + "os" |
| 31 | + "path/filepath" |
| 32 | + "strings" |
| 33 | + "testing" |
| 34 | + "time" |
| 35 | + |
| 36 | + _ "github.com/glebarez/sqlite" |
| 37 | + "github.com/stretchr/testify/assert" |
| 38 | + "github.com/stretchr/testify/require" |
| 39 | + |
| 40 | + "github.com/pelicanplatform/pelican/client" |
| 41 | + "github.com/pelicanplatform/pelican/config" |
| 42 | + "github.com/pelicanplatform/pelican/fed_test_utils" |
| 43 | + "github.com/pelicanplatform/pelican/param" |
| 44 | + "github.com/pelicanplatform/pelican/server_structs" |
| 45 | + "github.com/pelicanplatform/pelican/server_utils" |
| 46 | + "github.com/pelicanplatform/pelican/test_utils" |
| 47 | + "github.com/pelicanplatform/pelican/token" |
| 48 | + "github.com/pelicanplatform/pelican/token_scopes" |
| 49 | +) |
| 50 | + |
| 51 | +// TestCacheScitokensConfigOverride tests that Xrootd.ScitokensConfig works for caches |
| 52 | +// to serve cached objects during origin downtime. This test: |
| 53 | +// 1. Sets up a full federation with private reads and pulls a file through the cache |
| 54 | +// 2. Simulates origin downtime by POSTing a new origin ad without the /test namespace |
| 55 | +// 3. Triggers cache authz refresh by overwriting Xrootd.ScitokensConfig with unrelated issuer |
| 56 | +// 4. Verifies data is no longer accessible through the cache (authorization removed) |
| 57 | +// 5. Triggers another authz refresh with proper authorization for the test prefix |
| 58 | +// 6. Verifies cached object is now accessible even with origin "offline" |
| 59 | +func TestCacheScitokensConfigOverride(t *testing.T) { |
| 60 | + t.Cleanup(test_utils.SetupTestLogging(t)) |
| 61 | + server_utils.ResetTestState() |
| 62 | + defer server_utils.ResetTestState() |
| 63 | + ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) |
| 64 | + t.Cleanup(func() { |
| 65 | + cancel() |
| 66 | + require.NoError(t, egrp.Wait()) |
| 67 | + server_utils.ResetTestState() |
| 68 | + }) |
| 69 | + |
| 70 | + // Create test directories and file |
| 71 | + tmpDir := t.TempDir() |
| 72 | + testFileContent := "test file content for cache scitokens override test" |
| 73 | + testFileName := "test_file.txt" |
| 74 | + testFilePath := filepath.Join(tmpDir, testFileName) |
| 75 | + err := os.WriteFile(testFilePath, []byte(testFileContent), 0644) |
| 76 | + require.NoError(t, err) |
| 77 | + |
| 78 | + // Set up Xrootd.ScitokensConfig location |
| 79 | + scitokensConfigPath := filepath.Join(tmpDir, "scitokens.cfg") |
| 80 | + require.NoError(t, param.Set(param.Xrootd_ScitokensConfig.GetName(), scitokensConfigPath)) |
| 81 | + |
| 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 | + |
| 88 | + // Create origin configuration with private reads (requires tokens) |
| 89 | + originConfig := ` |
| 90 | +Origin: |
| 91 | + StorageType: posix |
| 92 | + Exports: |
| 93 | + - FederationPrefix: /test |
| 94 | + StoragePrefix: ` + tmpDir + ` |
| 95 | + Capabilities: ["Reads", "Writes", "Listings"] |
| 96 | +` |
| 97 | + |
| 98 | + // Set up the federation |
| 99 | + _ = fed_test_utils.NewFedTest(t, originConfig) |
| 100 | + |
| 101 | + // Get the server issuer URL for creating tokens |
| 102 | + serverIssuerUrl, err := config.GetServerIssuerURL() |
| 103 | + require.NoError(t, err, "Failed to get server issuer URL") |
| 104 | + |
| 105 | + // Create a token for accessing the object |
| 106 | + tokenConfig := token.NewWLCGToken() |
| 107 | + tokenConfig.Lifetime = 30 * time.Minute |
| 108 | + tokenConfig.Issuer = serverIssuerUrl |
| 109 | + tokenConfig.Subject = "test-subject" |
| 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...) |
| 120 | + |
| 121 | + tok, err := tokenConfig.CreateToken() |
| 122 | + require.NoError(t, err) |
| 123 | + |
| 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 | + |
| 132 | + // Step 1: Download through the federation to populate the cache |
| 133 | + destPath1 := filepath.Join(tmpDir, "downloaded1.txt") |
| 134 | + transferResults, err := client.DoGet(ctx, pelicanUrl, destPath1, false, client.WithToken(tok)) |
| 135 | + require.NoError(t, err, "Should be able to download file through federation") |
| 136 | + assert.Equal(t, int64(len(testFileContent)), transferResults[0].TransferredBytes) |
| 137 | + |
| 138 | + content1, err := os.ReadFile(destPath1) |
| 139 | + require.NoError(t, err) |
| 140 | + assert.Equal(t, testFileContent, string(content1), "Downloaded content should match original") |
| 141 | + |
| 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 | + } |
| 209 | + |
| 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") |
| 218 | + |
| 219 | + // 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. |
| 223 | + unrelatedConfig := ` |
| 224 | +[Global] |
| 225 | +audience = https://wlcg.cern.ch/jwt/v1/any |
| 226 | +
|
| 227 | +[Issuer UnrelatedIssuer] |
| 228 | +issuer = https://unrelated-issuer.example.com |
| 229 | +base_path = /unrelated/path |
| 230 | +default_user = xrootd |
| 231 | +` |
| 232 | + err = os.WriteFile(scitokensConfigPath, []byte(unrelatedConfig), 0644) |
| 233 | + require.NoError(t, err) |
| 234 | + |
| 235 | + // Wait for the background cache process to emit the config with /unrelated/path |
| 236 | + generatedConfigPath := filepath.Join(param.Cache_RunLocation.GetString(), "scitokens-cache-generated.cfg") |
| 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") |
| 249 | + |
| 250 | + // Step 4: Verify data is no longer accessible through the cache |
| 251 | + // Try to download directly from cache - should fail because authorization is missing |
| 252 | + destPath2 := filepath.Join(tmpDir, "downloaded2.txt") |
| 253 | + cacheUrl := param.Cache_Url.GetString() |
| 254 | + _, err = client.DoGet(ctx, cacheUrl+"/test/"+testFileName, destPath2, false, client.WithToken(tok)) |
| 255 | + assert.Error(t, err, "Should not be able to access cached data without proper authorization") |
| 256 | + |
| 257 | + // Step 5: Trigger another cache authz refresh with proper authorization for /test |
| 258 | + properConfig := ` |
| 259 | +[Global] |
| 260 | +audience = https://wlcg.cern.ch/jwt/v1/any |
| 261 | +
|
| 262 | +[Issuer TestIssuer] |
| 263 | +issuer = ` + serverIssuerUrl + ` |
| 264 | +base_path = /test |
| 265 | +default_user = xrootd |
| 266 | +` |
| 267 | + err = os.WriteFile(scitokensConfigPath, []byte(properConfig), 0644) |
| 268 | + require.NoError(t, err) |
| 269 | + |
| 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") |
| 283 | + |
| 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 |
| 286 | + destPath3 := filepath.Join(tmpDir, "downloaded3.txt") |
| 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)) |
| 290 | + require.NoError(t, err, "Should be able to access cached data with proper authorization") |
| 291 | + assert.Equal(t, int64(len(testFileContent)), transferResults[0].TransferredBytes) |
| 292 | + |
| 293 | + content3, err := os.ReadFile(destPath3) |
| 294 | + require.NoError(t, err) |
| 295 | + assert.Equal(t, testFileContent, string(content3), "Content from cache should match original") |
| 296 | +} |
0 commit comments