Files
anonymous_github/backend/internal/server/shared_cache_integration_test.go
T

211 lines
7.2 KiB
Go

package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tdurieux/anonymous_github/backend/internal/blobcache"
"github.com/tdurieux/anonymous_github/backend/internal/provider"
"go.mongodb.org/mongo-driver/v2/bson"
)
func runSharedSourceIntegration(t *testing.T, s *Server, p *fixtureProvider, call func(string, string, string, any) *httptest.ResponseRecorder) {
t.Run("shared source cache", func(t *testing.T) {
cache, err := blobcache.New(t.Context(), blobcache.Options{Root: t.TempDir(), Budget: 65536, MaxObject: 4096, Gzip: true})
if err != nil {
t.Fatal(err)
}
defer cache.Close()
s.sourceCache = cache
defer func() { s.sourceCache = nil; p.onOpen = nil; p.revoked = false }()
ctx := t.Context()
original, err := s.Store.one(ctx, collection("repo"), M{"repoId": "second-provider"})
if err != nil {
t.Fatal(err)
}
r := selectFields(original, "owner", "source", "providerAccess", "options")
r["_id"], r["repoId"], r["status"] = bson.NewObjectID(), "shared-source-fixture", "ready"
r["treeGeneration"], r["anonymizeDate"] = "shared-generation", time.Now().Truncate(time.Millisecond)
if _, err = s.Store.DB.Collection(collection("repo")).InsertOne(ctx, r); err != nil {
t.Fatal(err)
}
row := M{"path": "", "name": "Alice.txt", "sha": "blob:one", "size": int64(len(fixtureContent))}
read := func() (string, error) {
body, err := s.blob(ctx, r, row)
if err != nil {
return "", err
}
defer body.Close()
data, err := io.ReadAll(body)
return string(data), err
}
start := p.openCount.Load()
for i := 0; i < 2; i++ {
beforeAuth := p.authorizeCount.Load()
body, err := read()
if err != nil || body != fixtureContent {
t.Fatal(body, err)
}
// Cold reads check producer and release. Warm reads authorize
// at release, after lookup, without exposing bytes beforehand.
want := int64(2)
if i == 1 {
want = 1
}
if got := p.authorizeCount.Load() - beforeAuth; got != want {
t.Fatalf("read %d: authorization calls %d, want %d", i, got, want)
}
}
if p.openCount.Load() != start+1 {
t.Fatal("warm request downloaded again")
}
stats, err := cache.Stats(ctx)
if err != nil || stats.Blobs != 1 || stats.ActiveReaders != 0 || stats.ReservedBytes != 0 {
t.Fatal(stats, err)
}
p.revoked = true
if _, err = read(); err == nil || err.Error() != "fixture_access_revoked" {
t.Fatal("revoked cache hit", err)
}
p.revoked = false
// Distinct opaque revisions cannot collide even when their paths match.
row["sha"] = "blob:two"
p.onOpen = func() (io.ReadCloser, error) {
p.revoked = true
return io.NopCloser(strings.NewReader(fixtureContent)), nil
}
if _, err = read(); err == nil || err.Error() != "fixture_access_revoked" {
t.Fatal("revocation during fill", err)
}
p.revoked = false
p.onOpen = nil
if _, err = read(); err != nil {
t.Fatal(err)
}
// A removal while bytes are fetched must reject the completed fill.
row["sha"] = "blob:three"
p.onOpen = func() (io.ReadCloser, error) {
_, err := s.Store.update(ctx, collection("repo"), M{"_id": r["_id"]}, M{"$set": M{"status": "removed"}})
return io.NopCloser(strings.NewReader(fixtureContent)), err
}
if _, err = read(); err == nil || err.Error() != "repository_changed" {
t.Fatal("stale lifecycle served", err)
}
p.onOpen = nil
// Exercise the public file handler and anonymization with shared source reads.
before := p.openCount.Load()
for i := 0; i < 2; i++ {
w := call("GET", "/api/repo/second-provider/file/XXXX-1.txt", "", nil)
if w.Code != 200 || strings.Contains(w.Body.String(), "Alice") {
t.Fatal(w.Code, w.Body)
}
}
if p.openCount.Load() != before+1 {
t.Fatal("HTTP requests did not share source", p.openCount.Load()-before)
}
p.revoked = true
if w := call("GET", "/api/repo/second-provider/file/XXXX-1.txt", "", nil); w.Code != 403 {
t.Fatal("HTTP hit bypassed authorization", w.Code, w.Body)
}
// Bodyless responses must still reject revoked grants, even with a
// matching validator that would otherwise return 304.
for _, method := range []string{"GET", "HEAD"} {
req := httptest.NewRequest(method, "/api/repo/second-provider/file/XXXX-1.txt", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("If-None-Match", "*")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 403 {
t.Fatalf("revoked conditional %s: %d", method, w.Code)
}
}
if w := call("HEAD", "/api/repo/second-provider/file/XXXX-1.txt", "", nil); w.Code != 403 {
t.Fatal("revoked HEAD", w.Code)
}
p.revoked = false
// An optional verifier receives the computed proof for an opaque
// provider revision and can reject publication without GitHub rules.
accessForVerify, err := s.providerAccess(original)
if err != nil {
t.Fatal(err)
}
verified := &verifyingFixture{fixtureProvider: p, reject: true}
verifyEntry := provider.Entry{Path: "verified.txt", Revision: "opaque:verified", Size: int64(len(fixtureContent))}
if body, err := s.sharedSource(ctx, original, verified, accessForVerify, verifyEntry); err == nil {
body.Close()
t.Fatal("verifier rejection ignored")
} else if err.Error() != "fixture_hash_rejected" {
t.Fatal(err)
}
if hit, err := cache.Lookup(ctx, sharedSourceKey(original, verified, accessForVerify, verifyEntry)); err != nil || hit != nil {
if hit != nil {
hit.Close()
}
t.Fatal("failed proof was published", err)
}
verified.reject = false
verifiedBody, err := s.sharedSource(ctx, original, verified, accessForVerify, verifyEntry)
if err != nil {
t.Fatal(err)
}
verifiedBytes, err := io.ReadAll(verifiedBody)
verifiedBody.Close()
if err != nil || string(verifiedBytes) != fixtureContent || verified.calls != 2 {
t.Fatal(string(verifiedBytes), err, verified.calls)
}
// Disk admission failure falls back to a bounded upstream stream.
tiny, err := blobcache.New(ctx, blobcache.Options{Root: t.TempDir(), Budget: 1, MaxObject: 4096})
if err != nil {
t.Fatal(err)
}
defer tiny.Close()
s.sourceCache = tiny
access, err := s.providerAccess(original)
if err != nil {
t.Fatal(err)
}
body, err := s.sharedSource(ctx, original, p, access, provider.Entry{Path: "Alice.txt", Revision: "opaque:fallback", Size: int64(len(fixtureContent))})
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(body)
body.Close()
if err != nil || string(data) != fixtureContent {
t.Fatal(string(data), err)
}
stats, err = tiny.Stats(ctx)
if err != nil || stats.Blobs != 0 || stats.ReservedBytes != 0 {
t.Fatal(stats, err)
}
})
}
type verifyingFixture struct {
afterVerify func()
*fixtureProvider
reject bool
calls int
}
func (p *verifyingFixture) VerifyContent(_ context.Context, _ provider.Access, _ provider.Entry, proof provider.ContentProof) (string, error) {
p.calls++
if p.afterVerify != nil {
defer p.afterVerify()
}
sum := sha256.Sum256([]byte(fixtureContent))
if proof.SHA256 != hex.EncodeToString(sum[:]) || proof.Size != int64(len(fixtureContent)) {
return "", errors.New("incorrect_proof")
}
if p.reject {
return "", errors.New("fixture_hash_rejected")
}
return "sha256-" + proof.SHA256, nil
}