diff --git a/act/artifactcache/handler_test.go b/act/artifactcache/handler_test.go index b8ff6769..d078b90e 100644 --- a/act/artifactcache/handler_test.go +++ b/act/artifactcache/handler_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "code.forgejo.org/forgejo/runner/v9/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/timshannon/bolthold" @@ -28,7 +29,8 @@ const ( var handlerExternalURL string type AuthHeaderTransport struct { - T http.RoundTripper + T http.RoundTripper + WriteIsolationKey string } func (t *AuthHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -37,11 +39,14 @@ func (t *AuthHeaderTransport) RoundTrip(req *http.Request) (*http.Response, erro req.Header.Set("Forgejo-Cache-Timestamp", cacheTimestamp) req.Header.Set("Forgejo-Cache-MAC", cacheMac) req.Header.Set("Forgejo-Cache-Host", handlerExternalURL) + if t.WriteIsolationKey != "" { + req.Header.Set("Forgejo-Cache-WriteIsolationKey", t.WriteIsolationKey) + } return t.T.RoundTrip(req) } var ( - httpClientTransport = AuthHeaderTransport{http.DefaultTransport} + httpClientTransport = AuthHeaderTransport{T: http.DefaultTransport} httpClient = http.Client{Transport: &httpClientTransport} ) @@ -88,7 +93,7 @@ func TestHandler(t *testing.T) { content := make([]byte, 100) _, err := rand.Read(content) require.NoError(t, err) - uploadCacheNormally(t, base, key, version, content) + uploadCacheNormally(t, base, key, version, "", content) }) t.Run("clean", func(t *testing.T) { @@ -380,7 +385,7 @@ func TestHandler(t *testing.T) { _, err := rand.Read(content) require.NoError(t, err) - uploadCacheNormally(t, base, key, version, content) + uploadCacheNormally(t, base, key, version, "", content) // Perform the request with the custom `httpClient` which will send correct MAC data resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) @@ -416,7 +421,7 @@ func TestHandler(t *testing.T) { for i := range contents { _, err := rand.Read(contents[i]) require.NoError(t, err) - uploadCacheNormally(t, base, keys[i], version, contents[i]) + uploadCacheNormally(t, base, keys[i], version, "", contents[i]) time.Sleep(time.Second) // ensure CreatedAt of caches are different } @@ -454,13 +459,91 @@ func TestHandler(t *testing.T) { assert.Equal(t, contents[except], content) }) + t.Run("find can't match without WriteIsolationKey match", func(t *testing.T) { + defer func() { httpClientTransport.WriteIsolationKey = "" }() + + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" + key := strings.ToLower(t.Name()) + + uploadCacheNormally(t, base, key, version, "TestWriteKey", make([]byte, 64)) + + httpClientTransport.WriteIsolationKey = "AnotherTestWriteKey" + resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + httpClientTransport.WriteIsolationKey = "" + resp, err = httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + require.NoError(t, err) + require.Equal(t, 204, resp.StatusCode) + + httpClientTransport.WriteIsolationKey = "TestWriteKey" + resp, err = httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("find prefers WriteIsolationKey match", func(t *testing.T) { + defer func() { httpClientTransport.WriteIsolationKey = "" }() + + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d21" + key := strings.ToLower(t.Name()) + + // Between two values with the same `key`... + uploadCacheNormally(t, base, key, version, "TestWriteKey", make([]byte, 64)) + uploadCacheNormally(t, base, key, version, "", make([]byte, 128)) + + // We should read the value with the matching WriteIsolationKey from the cache... + httpClientTransport.WriteIsolationKey = "TestWriteKey" + resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + got := struct { + ArchiveLocation string `json:"archiveLocation"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + contentResp, err := httpClient.Get(got.ArchiveLocation) + require.NoError(t, err) + require.Equal(t, 200, contentResp.StatusCode) + content, err := io.ReadAll(contentResp.Body) + require.NoError(t, err) + // Which we finally check matches the correct WriteIsolationKey's content here. + assert.Equal(t, make([]byte, 64), content) + }) + + t.Run("find falls back if matching WriteIsolationKey not available", func(t *testing.T) { + defer func() { httpClientTransport.WriteIsolationKey = "" }() + + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d21" + key := strings.ToLower(t.Name()) + + uploadCacheNormally(t, base, key, version, "", make([]byte, 128)) + + httpClientTransport.WriteIsolationKey = "TestWriteKey" + resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + got := struct { + ArchiveLocation string `json:"archiveLocation"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + contentResp, err := httpClient.Get(got.ArchiveLocation) + require.NoError(t, err) + require.Equal(t, 200, contentResp.StatusCode) + content, err := io.ReadAll(contentResp.Body) + require.NoError(t, err) + assert.Equal(t, make([]byte, 128), content) + }) + t.Run("case insensitive", func(t *testing.T) { version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" key := strings.ToLower(t.Name()) content := make([]byte, 100) _, err := rand.Read(content) require.NoError(t, err) - uploadCacheNormally(t, base, key+"_ABC", version, content) + uploadCacheNormally(t, base, key+"_ABC", version, "", content) { reqKey := key + "_aBc" @@ -494,7 +577,7 @@ func TestHandler(t *testing.T) { for i := range contents { _, err := rand.Read(contents[i]) require.NoError(t, err) - uploadCacheNormally(t, base, keys[i], version, contents[i]) + uploadCacheNormally(t, base, keys[i], version, "", contents[i]) time.Sleep(time.Second) // ensure CreatedAt of caches are different } @@ -545,7 +628,7 @@ func TestHandler(t *testing.T) { for i := range contents { _, err := rand.Read(contents[i]) require.NoError(t, err) - uploadCacheNormally(t, base, keys[i], version, contents[i]) + uploadCacheNormally(t, base, keys[i], version, "", contents[i]) time.Sleep(time.Second) // ensure CreatedAt of caches are different } @@ -581,9 +664,144 @@ func TestHandler(t *testing.T) { require.NoError(t, err) assert.Equal(t, contents[expect], content) }) + + t.Run("upload across WriteIsolationKey", func(t *testing.T) { + defer testutils.MockVariable(&httpClientTransport.WriteIsolationKey, "CorrectKey")() + + key := strings.ToLower(t.Name()) + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" + content := make([]byte, 256) + + var id uint64 + // reserve + { + body, err := json.Marshal(&Request{ + Key: key, + Version: version, + Size: int64(len(content)), + }) + require.NoError(t, err) + resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + got := struct { + CacheID uint64 `json:"cacheId"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + id = got.CacheID + } + // upload, but with the incorrect write isolation key relative to the cache obj created + { + httpClientTransport.WriteIsolationKey = "WrongKey" + req, err := http.NewRequest(http.MethodPatch, + fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Range", "bytes 0-99/*") + resp, err := httpClient.Do(req) + require.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) + } + }) + + t.Run("commit across WriteIsolationKey", func(t *testing.T) { + defer testutils.MockVariable(&httpClientTransport.WriteIsolationKey, "CorrectKey")() + + key := strings.ToLower(t.Name()) + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" + content := make([]byte, 256) + + var id uint64 + // reserve + { + body, err := json.Marshal(&Request{ + Key: key, + Version: version, + Size: int64(len(content)), + }) + require.NoError(t, err) + resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + got := struct { + CacheID uint64 `json:"cacheId"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + id = got.CacheID + } + // upload + { + req, err := http.NewRequest(http.MethodPatch, + fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Range", "bytes 0-99/*") + resp, err := httpClient.Do(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + } + // commit, but with the incorrect write isolation key relative to the cache obj created + { + httpClientTransport.WriteIsolationKey = "WrongKey" + resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) + require.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) + } + }) + + t.Run("get across WriteIsolationKey", func(t *testing.T) { + defer func() { httpClientTransport.WriteIsolationKey = "" }() + + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d21" + key := strings.ToLower(t.Name()) + uploadCacheNormally(t, base, key, version, "", make([]byte, 128)) + keyIsolated := strings.ToLower(t.Name()) + "_isolated" + uploadCacheNormally(t, base, keyIsolated, version, "CorrectKey", make([]byte, 128)) + + // Perform the 'get' without the right WriteIsolationKey for the cache entry... should be OK for `key` since it + // was written with WriteIsolationKey "" meaning it is available for non-isolated access + { + httpClientTransport.WriteIsolationKey = "WhoopsWrongKey" + + resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + got := struct { + ArchiveLocation string `json:"archiveLocation"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + + contentResp, err := httpClient.Get(got.ArchiveLocation) + require.NoError(t, err) + require.Equal(t, 200, contentResp.StatusCode) + httpClientTransport.WriteIsolationKey = "CorrectKey" // reset for next find + } + + // Perform the 'get' without the right WriteIsolationKey for the cache entry... should be 403 for `keyIsolated` + // because it was written with a different WriteIsolationKey. + { + httpClientTransport.WriteIsolationKey = "CorrectKey" // for test purposes make the `find` successful... + resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, keyIsolated, version)) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + got := struct { + ArchiveLocation string `json:"archiveLocation"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + + httpClientTransport.WriteIsolationKey = "WhoopsWrongKey" // but then access w/ the wrong key for `get` + contentResp, err := httpClient.Get(got.ArchiveLocation) + require.NoError(t, err) + require.Equal(t, 403, contentResp.StatusCode) + } + }) } -func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { +func uploadCacheNormally(t *testing.T, base, key, version, writeIsolationKey string, content []byte) { + defer testutils.MockVariable(&httpClientTransport.WriteIsolationKey, writeIsolationKey)() + var id uint64 { body, err := json.Marshal(&Request{