Skip to content

Commit

Permalink
Merge pull request #280 from sapcc/test-trivy-ratelimit
Browse files Browse the repository at this point in the history
Add test for trivy ratelimit
  • Loading branch information
majewsky authored Sep 26, 2023
2 parents 62d8f8d + ec406fc commit 3a87615
Show file tree
Hide file tree
Showing 10 changed files with 134 additions and 62 deletions.
123 changes: 100 additions & 23 deletions internal/api/keppel/manifests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,25 @@ package keppelv1_test

import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/docker/distribution/manifest/schema2"
"github.com/go-redis/redis_rate/v10"
"github.com/opencontainers/go-digest"
"github.com/redis/go-redis/v9"
"github.com/sapcc/go-api-declarations/cadf"
"github.com/sapcc/go-bits/assert"
"github.com/sapcc/go-bits/easypg"
"github.com/sapcc/go-bits/must"

"github.com/sapcc/keppel/internal/drivers/basic"
"github.com/sapcc/keppel/internal/keppel"
"github.com/sapcc/keppel/internal/models"
"github.com/sapcc/keppel/internal/test"
Expand Down Expand Up @@ -106,7 +112,7 @@ func TestManifestsAPI(t *testing.T) {
repo := repos[repoID-1]

for idx := 1; idx <= 10; idx++ {
dummyDigest := deterministicDummyDigest(repoID*10 + idx)
dummyDigest := test.DeterministicDummyDigest(repoID*10 + idx)
sizeBytes := uint64(1000 * idx)
pushedAt := time.Unix(int64(1000*(repoID*10+idx)), 0)

Expand Down Expand Up @@ -145,21 +151,21 @@ func TestManifestsAPI(t *testing.T) {
mustInsert(t, s.DB, &keppel.Tag{
RepositoryID: int64(repoID),
Name: "first",
Digest: deterministicDummyDigest(repoID*10 + 1),
Digest: test.DeterministicDummyDigest(repoID*10 + 1),
PushedAt: time.Unix(20001, 0),
LastPulledAt: p2time(time.Unix(20101, 0)),
})
mustInsert(t, s.DB, &keppel.Tag{
RepositoryID: int64(repoID),
Name: "stillfirst",
Digest: deterministicDummyDigest(repoID*10 + 1),
Digest: test.DeterministicDummyDigest(repoID*10 + 1),
PushedAt: time.Unix(20002, 0),
LastPulledAt: nil,
})
mustInsert(t, s.DB, &keppel.Tag{
RepositoryID: int64(repoID),
Name: "second",
Digest: deterministicDummyDigest(repoID*10 + 2),
Digest: test.DeterministicDummyDigest(repoID*10 + 2),
PushedAt: time.Unix(20003, 0),
LastPulledAt: nil,
})
Expand All @@ -170,7 +176,7 @@ func TestManifestsAPI(t *testing.T) {
renderedManifests := make([]assert.JSONObject, 10)
for idx := 1; idx <= 10; idx++ {
renderedManifests[idx-1] = assert.JSONObject{
"digest": deterministicDummyDigest(10 + idx),
"digest": test.DeterministicDummyDigest(10 + idx),
"media_type": schema2.MediaTypeManifest,
"size_bytes": uint64(1000 * idx),
"pushed_at": int64(1000 * (10 + idx)),
Expand Down Expand Up @@ -270,14 +276,14 @@ func TestManifestsAPI(t *testing.T) {
easypg.AssertDBContent(t, s.DB.DbMap.Db, "fixtures/before-delete-manifest.sql")
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(11).String(),
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(11).String(),
Header: map[string]string{"X-Test-Perms": "view:tenant1,delete:tenant1"},
ExpectStatus: http.StatusNoContent,
}.Check(t, h)
easypg.AssertDBContent(t, s.DB.DbMap.Db, "fixtures/after-delete-manifest.sql")

s.Auditor.ExpectEvents(t, cadf.Event{
RequestPath: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(11).String(),
RequestPath: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(11).String(),
Action: cadf.DeleteAction,
Outcome: "success",
Reason: test.CADFReasonOK,
Expand All @@ -288,8 +294,8 @@ func TestManifestsAPI(t *testing.T) {
Content: "[\"first\",\"stillfirst\"]",
}},
TypeURI: "docker-registry/account/repository/manifest",
Name: "test1/repo1-1@" + deterministicDummyDigest(11).String(),
ID: deterministicDummyDigest(11).String(),
Name: "test1/repo1-1@" + test.DeterministicDummyDigest(11).String(),
ID: test.DeterministicDummyDigest(11).String(),
ProjectID: "tenant1",
},
})
Expand All @@ -311,41 +317,41 @@ func TestManifestsAPI(t *testing.T) {
Target: cadf.Resource{
TypeURI: "docker-registry/account/repository/tag",
Name: "test1/repo1-2:stillfirst",
ID: deterministicDummyDigest(21).String(),
ID: test.DeterministicDummyDigest(21).String(),
ProjectID: "tenant1",
},
})

//test DELETE manifest failure cases
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/test2/repositories/repo2-1/_manifests/" + deterministicDummyDigest(31).String(),
Path: "/keppel/v1/accounts/test2/repositories/repo2-1/_manifests/" + test.DeterministicDummyDigest(31).String(),
Header: map[string]string{"X-Test-Perms": "delete:tenant1,view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusForbidden,
ExpectBody: assert.StringData("no permission for repository:test2/repo2-1:delete\n"),
}.Check(t, h)
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/test1/repositories/repo1-2/_manifests/" + deterministicDummyDigest(21).String(),
Path: "/keppel/v1/accounts/test1/repositories/repo1-2/_manifests/" + test.DeterministicDummyDigest(21).String(),
Header: map[string]string{"X-Test-Perms": "view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusForbidden,
}.Check(t, h)
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/doesnotexist/repositories/repo1-2/_manifests/" + deterministicDummyDigest(11).String(),
Path: "/keppel/v1/accounts/doesnotexist/repositories/repo1-2/_manifests/" + test.DeterministicDummyDigest(11).String(),
Header: map[string]string{"X-Test-Perms": "delete:tenant1,view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusForbidden,
ExpectBody: assert.StringData("no permission for repository:doesnotexist/repo1-2:delete\n"),
}.Check(t, h)
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/test1/repositories/doesnotexist/_manifests/" + deterministicDummyDigest(11).String(),
Path: "/keppel/v1/accounts/test1/repositories/doesnotexist/_manifests/" + test.DeterministicDummyDigest(11).String(),
Header: map[string]string{"X-Test-Perms": "delete:tenant1,view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusNotFound,
}.Check(t, h)
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(11).String(),
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(11).String(),
Header: map[string]string{"X-Test-Perms": "view:tenant1,delete:tenant1"},
ExpectStatus: http.StatusNotFound,
}.Check(t, h)
Expand All @@ -371,7 +377,7 @@ func TestManifestsAPI(t *testing.T) {
}.Check(t, h)
assert.HTTPRequest{
Method: "DELETE",
Path: "/keppel/v1/accounts/test2/repositories/repo2-1/_tags/" + deterministicDummyDigest(31).String(), //this endpoint only works with tags
Path: "/keppel/v1/accounts/test2/repositories/repo2-1/_tags/" + test.DeterministicDummyDigest(31).String(), //this endpoint only works with tags
Header: map[string]string{"X-Test-Perms": "delete:tenant2,view:tenant2"},
ExpectStatus: http.StatusNotFound,
}.Check(t, h)
Expand All @@ -391,13 +397,13 @@ func TestManifestsAPI(t *testing.T) {
//test GET vulnerability report failure cases
assert.HTTPRequest{
Method: "GET",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(11).String() + "/trivy_report",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(11).String() + "/trivy_report",
Header: map[string]string{"X-Test-Perms": "view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusNotFound, //this manifest was deleted above
}.Check(t, h)
assert.HTTPRequest{
Method: "GET",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(12).String() + "/trivy_report",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(12).String() + "/trivy_report",
Header: map[string]string{"X-Test-Perms": "view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusMethodNotAllowed, //manifest cannot have vulnerability report because it does not have manifest-blob refs
}.Check(t, h)
Expand All @@ -406,7 +412,7 @@ func TestManifestsAPI(t *testing.T) {
//so that the vulnerability report can actually be shown
dummyBlob := keppel.Blob{
AccountName: "test1",
Digest: deterministicDummyDigest(101),
Digest: test.DeterministicDummyDigest(101),
}
mustInsert(t, s.DB, &dummyBlob)
err := keppel.MountBlobIntoRepo(s.DB, dummyBlob, *repos[0])
Expand All @@ -415,21 +421,21 @@ func TestManifestsAPI(t *testing.T) {
}
_, err = s.DB.Exec(
`INSERT INTO manifest_blob_refs (repo_id, digest, blob_id) VALUES ($1, $2, $3)`,
repos[0].ID, deterministicDummyDigest(12), dummyBlob.ID,
repos[0].ID, test.DeterministicDummyDigest(12), dummyBlob.ID,
)
if err != nil {
t.Fatal(err.Error())
}

//test GET vulnerability report success case
imageRef, _, err := models.ParseImageReference("registry.example.org/test1/repo1-1@" + deterministicDummyDigest(12).String())
imageRef, _, err := models.ParseImageReference("registry.example.org/test1/repo1-1@" + test.DeterministicDummyDigest(12).String())
if err != nil {
t.Fatal(err.Error())
}
s.TrivyDouble.ReportFixtures[imageRef] = "../../tasks/fixtures/trivy/report-vulnerable-with-fixes.json"
assert.HTTPRequest{
Method: "GET",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(12).String() + "/trivy_report",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(12).String() + "/trivy_report",
Header: map[string]string{"X-Test-Perms": "view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusOK,
ExpectBody: assert.JSONFixtureFile("../../tasks/fixtures/trivy/report-vulnerable-with-fixes.json"),
Expand Down Expand Up @@ -460,7 +466,7 @@ func TestManifestsAPI(t *testing.T) {

assert.HTTPRequest{
Method: "GET",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + deterministicDummyDigest(12).String() + "/trivy_report",
Path: "/keppel/v1/accounts/test1/repositories/repo1-1/_manifests/" + test.DeterministicDummyDigest(12).String() + "/trivy_report",
Header: map[string]string{"X-Test-Perms": "view:tenant1,pull:tenant1"},
ExpectStatus: http.StatusOK,
ExpectBody: assert.JSONFixtureFile("../../tasks/fixtures/trivy/report-vulnerable-with-fixes-enriched.json"),
Expand All @@ -471,3 +477,74 @@ func TestManifestsAPI(t *testing.T) {
func p2time(x time.Time) *time.Time {
return &x
}

func TestRateLimitsTrivyReport(t *testing.T) {
limit := redis_rate.Limit{Rate: 2, Period: time.Minute, Burst: 3}
rld := basic.RateLimitDriver{
Limits: map[keppel.RateLimitedAction]redis_rate.Limit{
keppel.TrivyReportRetrieveAction: limit,
},
}
rle := &keppel.RateLimitEngine{Driver: rld, Client: nil}

test.WithRoundTripper(func(tt *test.RoundTripper) {
s := test.NewSetup(t,
test.WithKeppelAPI,
test.WithTrivyDouble,
test.WithRateLimitEngine(rle),
test.WithAccount(keppel.Account{Name: "test1"}),
)
h := s.Handler

sr := miniredis.RunT(t)
s.Clock.AddListener(sr.SetTime)
rle.Client = redis.NewClient(&redis.Options{Addr: sr.Addr()})

_, err := keppel.FindOrCreateRepository(s.DB, "foo", keppel.Account{Name: "test1"})
if err != nil {
t.Fatal(err.Error())
}

token := s.GetToken(t, "repository:test1/foo:pull,push")

req := assert.HTTPRequest{
Method: "GET",
Path: fmt.Sprintf("/keppel/v1/accounts/test1/repositories/foo/_manifests/%s/trivy_report", test.DeterministicDummyDigest(1)),
Header: map[string]string{"Authorization": "Bearer " + token},
ExpectStatus: http.StatusNotFound,
ExpectHeader: map[string]string{},
ExpectBody: assert.StringData("not found\n"),
}

s.Clock.StepBy(time.Hour)

//we can always execute 1 request initially, and then we can burst on top of that
for i := 0; i < limit.Burst; i++ {
req.Check(t, h)
s.Clock.StepBy(time.Second)
}

//then the next request should be rate-limited
failingReq := req
failingReq.ExpectBody = test.ErrorCode(keppel.ErrTooManyRequests)
failingReq.ExpectStatus = http.StatusTooManyRequests
failingReq.ExpectHeader = map[string]string{
"Retry-After": strconv.Itoa(30 - limit.Burst),
}
failingReq.Check(t, h)

//be impatient
s.Clock.StepBy(time.Duration(29-limit.Burst) * time.Second)
failingReq.ExpectHeader["Retry-After"] = "1"
failingReq.Check(t, h)

//finally!
s.Clock.StepBy(time.Second)
req.Check(t, h)

//aaaand... we're rate-limited again immediately because we haven't
//recovered our burst budget yet
failingReq.ExpectHeader["Retry-After"] = "30"
failingReq.Check(t, h)
})
}
4 changes: 2 additions & 2 deletions internal/api/keppel/quotas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,15 @@ func TestQuotasAPI(t *testing.T) {
pushedAt := time.Unix(int64(10000+10*idx), 0)
mustInsert(t, s.DB, &keppel.Manifest{
RepositoryID: 1,
Digest: deterministicDummyDigest(idx),
Digest: test.DeterministicDummyDigest(idx),
MediaType: "",
SizeBytes: uint64(1000 * idx),
PushedAt: pushedAt,
ValidatedAt: pushedAt,
})
mustInsert(t, s.DB, &keppel.TrivySecurityInfo{
RepositoryID: 1,
Digest: deterministicDummyDigest(idx),
Digest: test.DeterministicDummyDigest(idx),
VulnerabilityStatus: trivy.PendingVulnerabilityStatus,
NextCheckAt: time.Unix(0, 0),
})
Expand Down
10 changes: 2 additions & 8 deletions internal/api/keppel/repos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@
package keppelv1_test

import (
"bytes"
"fmt"
"net/http"
"testing"
"time"

"github.com/opencontainers/go-digest"
"github.com/sapcc/go-bits/assert"
"github.com/sapcc/go-bits/easypg"

Expand Down Expand Up @@ -57,10 +55,6 @@ func mustExec(t *testing.T, db *keppel.DB, query string, args ...interface{}) {
}
}

func deterministicDummyDigest(counter int) digest.Digest {
return digest.SHA256.FromBytes(bytes.Repeat([]byte{1}, counter))
}

func TestReposAPI(t *testing.T) {
s := test.NewSetup(t, test.WithKeppelAPI)
h := s.Handler
Expand Down Expand Up @@ -107,7 +101,7 @@ func TestReposAPI(t *testing.T) {
//blob size statistics
filledRepo := keppel.Repository{ID: 5} //repo1-3
for idx := 1; idx <= 10; idx++ {
dummyDigest := deterministicDummyDigest(1000 + idx)
dummyDigest := test.DeterministicDummyDigest(1000 + idx)
blobPushedAt := time.Unix(int64(1000+10*idx), 0)
blob := keppel.Blob{
AccountName: "test1",
Expand All @@ -126,7 +120,7 @@ func TestReposAPI(t *testing.T) {
//insert some dummy manifests and tags into one of the repos to check the
//manifest/tag counting
for idx := 1; idx <= 10; idx++ {
dummyDigest := deterministicDummyDigest(idx)
dummyDigest := test.DeterministicDummyDigest(idx)
manifestPushedAt := time.Unix(int64(10000+10*idx), 0)
mustInsert(t, s.DB, &keppel.Manifest{
RepositoryID: filledRepo.ID,
Expand Down
10 changes: 4 additions & 6 deletions internal/api/registry/blobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func TestBlobMonolithicUpload(t *testing.T) {
})

//test failure cases: digest is wrong
for _, wrongDigest := range []string{"wrong", "sha256:" + sha256Of([]byte("something else"))} {
for _, wrongDigest := range []string{"wrong", test.DeterministicDummyDigest(1).String()} {
assert.HTTPRequest{
Method: "POST",
Path: "/v2/test1/foo/blobs/uploads/?digest=" + wrongDigest,
Expand Down Expand Up @@ -408,7 +408,7 @@ func TestBlobStreamedAndChunkedUpload(t *testing.T) {
}

//test failure cases during PUT: digest is missing or wrong
for _, wrongDigest := range []string{"", "wrong", "sha256:" + sha256Of([]byte("something else"))} {
for _, wrongDigest := range []string{"", "wrong", test.DeterministicDummyDigest(2).String()} {
//upload all the blob contents at once (we're only interested in the final PUT)
resp, _ := assert.HTTPRequest{
Method: "PATCH",
Expand Down Expand Up @@ -793,10 +793,9 @@ func TestDeleteBlob(t *testing.T) {
}.Check(t, h)

//test failure case: no such blob
bogusDigest := "sha256:" + sha256Of([]byte("something else"))
assert.HTTPRequest{
Method: "DELETE",
Path: "/v2/test1/foo/blobs/" + bogusDigest,
Path: "/v2/test1/foo/blobs/" + test.DeterministicDummyDigest(1).String(),
Header: map[string]string{"Authorization": "Bearer " + deleteToken},
ExpectStatus: http.StatusNotFound,
ExpectHeader: test.VersionHeader,
Expand Down Expand Up @@ -892,7 +891,6 @@ func TestCrossRepositoryBlobMount(t *testing.T) {
}.Check(t, h)

//test failure cases: digest is malformed or wrong
bogusDigest := "sha256:" + sha256Of([]byte("something else"))
assert.HTTPRequest{
Method: "POST",
Path: "/v2/test1/foo/blobs/uploads/?from=test1/bar&mount=wrong",
Expand All @@ -903,7 +901,7 @@ func TestCrossRepositoryBlobMount(t *testing.T) {
}.Check(t, h)
assert.HTTPRequest{
Method: "POST",
Path: "/v2/test1/foo/blobs/uploads/?from=test1/bar&mount=" + bogusDigest,
Path: "/v2/test1/foo/blobs/uploads/?from=test1/bar&mount=" + test.DeterministicDummyDigest(1).String(),
Header: map[string]string{"Authorization": "Bearer " + token},
ExpectStatus: http.StatusNotFound,
ExpectHeader: test.VersionHeader,
Expand Down
Loading

0 comments on commit 3a87615

Please sign in to comment.