package nixcache import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "reflect" "strconv" "strings" "testing" "time" ) type recorded struct { method string path string header http.Header body []byte } func fakeAtticd(t *testing.T, status int, body string) (*Client, *recorded) { t.Helper() rec := &recorded{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.method, rec.path, rec.header = r.Method, r.URL.Path, r.Header rec.body, _ = io.ReadAll(r.Body) w.WriteHeader(status) io.WriteString(w, body) })) t.Cleanup(srv.Close) return &Client{BaseURL: srv.URL + "/", HTTP: srv.Client()}, rec } func (r *recorded) checkRequest(t *testing.T, method, path string) { t.Helper() if r.method != method || r.path != path { t.Errorf("request = %s %s, want %s %s", r.method, r.path, method, path) } if got := r.header.Get("Authorization"); got != "Bearer tok" { t.Errorf("Authorization = %q, want Bearer tok", got) } } func splitUpload(t *testing.T, rec *recorded) (preamble, nar []byte) { t.Helper() size, err := strconv.Atoi(rec.header.Get("X-Attic-Nar-Info-Preamble-Size")) if err != nil || size < 0 || size > len(rec.body) { t.Fatalf("X-Attic-Nar-Info-Preamble-Size = %q for a %d byte body", rec.header.Get("X-Attic-Nar-Info-Preamble-Size"), len(rec.body)) } return rec.body[:size], rec.body[size:] } func sameJSON(t *testing.T, got []byte, want string) { t.Helper() var g, w any if err := json.Unmarshal(got, &g); err != nil { t.Fatalf("%q is not json: %v", got, err) } if err := json.Unmarshal([]byte(want), &w); err != nil { t.Fatal(err) } if !reflect.DeepEqual(g, w) { t.Errorf("json = %s\nwant %s", got, want) } } func TestCreateCache(t *testing.T) { c, rec := fakeAtticd(t, 200, "") if err := c.CreateCache(context.Background(), "tok", "plc-abc"); err != nil { t.Fatal(err) } rec.checkRequest(t, "POST", "/_api/v1/cache-config/plc-abc") if got := rec.header.Get("Content-Type"); got != "application/json" { t.Errorf("Content-Type = %q", got) } want := `{"keypair":"Generate","is_public":false,"store_dir":"/nix/store","priority":41,"upstream_cache_key_names":["cache.nixos.org-1"]}` sameJSON(t, rec.body, want) c, _ = fakeAtticd(t, 400, `{"code":400,"error":"CacheAlreadyExists","message":"The cache already exists."}`) if err := c.CreateCache(context.Background(), "tok", "plc-abc"); err != nil { t.Fatalf("existing cache: %v", err) } c, _ = fakeAtticd(t, 400, `{"code":400,"error":"RequestError","message":"nope"}`) if err := c.CreateCache(context.Background(), "tok", "plc-abc"); err == nil || errors.Is(err, ErrAlreadyExists) { t.Fatalf("err = %v, want a failure that is not ErrAlreadyExists", err) } } func TestSetRetention(t *testing.T) { c, rec := fakeAtticd(t, 200, "") if err := c.SetRetention(context.Background(), "tok", "plc-abc", 36*time.Hour); err != nil { t.Fatal(err) } rec.checkRequest(t, "PATCH", "/_api/v1/cache-config/plc-abc") if want := `{"retention_period":{"Period":129600}}`; string(rec.body) != want { t.Errorf("body = %s, want %s", rec.body, want) } } func TestPublicKey(t *testing.T) { c, rec := fakeAtticd(t, 200, `{"keypair":{"Generate":{}},"is_public":false,"public_key":"plc-abc:AAAA=","store_dir":"/nix/store","priority":41}`) got, err := c.PublicKey(context.Background(), "tok", "plc-abc") if err != nil { t.Fatal(err) } rec.checkRequest(t, "GET", "/_api/v1/cache-config/plc-abc") if got != "plc-abc:AAAA=" { t.Errorf("public key = %q", got) } c, _ = fakeAtticd(t, 200, `{"is_public":false}`) if _, err := c.PublicKey(context.Background(), "tok", "plc-abc"); err == nil { t.Error("a config without a public key succeeded") } } func TestUploadPath(t *testing.T) { c, rec := fakeAtticd(t, 200, `{"kind":"Uploaded","file_size":12,"frac_deduplicated":null}`) info := NarInfo{ Cache: "plc-abc", StorePathHash: "0123456789abcdfghijklmnpqrsvwxyz", StorePath: "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello", Deriver: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-hello.drv", NarHash: "sha256:1b8m03r63zqhnjf7l5wnldhh7c134ap5vpj0850ymkq1iyzicy5s", NarSize: 11, } if err := c.UploadPath(context.Background(), "tok", info, strings.NewReader("raw nar data"[:11])); err != nil { t.Fatal(err) } rec.checkRequest(t, "PUT", "/_api/v1/upload-path") preamble, nar := splitUpload(t, rec) if string(nar) != "raw nar dat" { t.Errorf("nar = %q, want the nar bytes unchanged", nar) } if got := rec.header.Get("X-Attic-Nar-Info"); got != "" { t.Errorf("X-Attic-Nar-Info = %q, want the narinfo only in the body: proxies cap header size", got) } sameJSON(t, preamble, `{ "cache": "plc-abc", "store_path_hash": "0123456789abcdfghijklmnpqrsvwxyz", "store_path": "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello", "references": [], "sigs": [], "deriver": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-hello.drv", "nar_hash": "sha256:1b8m03r63zqhnjf7l5wnldhh7c134ap5vpj0850ymkq1iyzicy5s", "nar_size": 11 }`) if want := strconv.Itoa(len(preamble) + 11); rec.header.Get("Content-Length") != want { t.Errorf("Content-Length = %q, want %s", rec.header.Get("Content-Length"), want) } } func TestMissingAndDeletePaths(t *testing.T) { c, rec := fakeAtticd(t, 200, `{"missing_paths":["bbb"]}`) got, err := c.MissingPaths(context.Background(), "tok", "plc-abc", []string{"aaa", "bbb"}) if err != nil { t.Fatal(err) } rec.checkRequest(t, "POST", "/_api/v1/get-missing-paths") sameJSON(t, rec.body, `{"cache":"plc-abc","store_path_hashes":["aaa","bbb"]}`) if len(got) != 1 || got[0] != "bbb" { t.Errorf("missing = %v", got) } c, rec = fakeAtticd(t, 200, `{"deleted":["aaa"]}`) got, err = c.DeletePaths(context.Background(), "tok", "plc-abc", nil) if err != nil { t.Fatal(err) } rec.checkRequest(t, "POST", "/_api/v1/delete-paths") sameJSON(t, rec.body, `{"cache":"plc-abc","store_path_hashes":[]}`) if len(got) != 1 || got[0] != "aaa" { t.Errorf("deleted = %v", got) } } func TestErrorMapping(t *testing.T) { tests := []struct { name string status int body string want error }{ {"exists", 400, `{"code":400,"error":"CacheAlreadyExists","message":"x"}`, ErrAlreadyExists}, {"no cache", 404, `{"code":404,"error":"NoSuchCache","message":"x"}`, ErrNotFound}, {"no object", 404, `{"code":404,"error":"NoSuchObject","message":"x"}`, ErrNotFound}, {"unauthorized", 401, `{"code":401,"error":"Unauthorized","message":"x"}`, ErrUnauthorized}, {"plain 403", 403, ``, ErrForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c, _ := fakeAtticd(t, tt.status, tt.body) _, err := c.MissingPaths(context.Background(), "tok", "c", nil) if !errors.Is(err, tt.want) { t.Fatalf("err = %v, want %v", err, tt.want) } var apiErr *Error if !errors.As(err, &apiErr) || apiErr.Status != tt.status { t.Errorf("err = %#v, want an *Error with status %d", err, tt.status) } }) } c, _ := fakeAtticd(t, 404, `{"code":404,"error":"NotFound","message":"The URL you requested was not found."}`) if _, err := c.DeletePaths(context.Background(), "tok", "c", []string{"a"}); err == nil || errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "The URL you requested was not found.") { t.Errorf("404 NotFound: err = %v", err) } } func hashes(n int) []string { out := make([]string, n) for i := range out { out[i] = fmt.Sprintf("%032d", i) } return out } func TestPathCallsRefuseMoreHashesThanAtticTakes(t *testing.T) { c, rec := fakeAtticd(t, 200, "{}") tooMany := hashes(MaxHashesPerRequest + 1) if _, err := c.MissingPaths(context.Background(), "tok", "c", tooMany); err == nil { t.Error("MissingPaths sent more hashes than attic takes") } if _, err := c.DeletePaths(context.Background(), "tok", "c", tooMany); err == nil { t.Error("DeletePaths sent more hashes than attic takes") } if rec.method != "" { t.Errorf("attic saw %s %s", rec.method, rec.path) } if _, err := c.DeletePaths(context.Background(), "tok", "c", tooMany[:MaxHashesPerRequest]); err != nil { t.Errorf("DeletePaths of exactly %d hashes: %v", MaxHashesPerRequest, err) } } func TestCallsGiveUpAfterTimeout(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release })) t.Cleanup(srv.Close) t.Cleanup(func() { close(release) }) c := &Client{BaseURL: srv.URL, Timeout: 50 * time.Millisecond} done := make(chan error, 1) go func() { _, err := c.MissingPaths(context.Background(), "tok", "c", nil); done <- err }() select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("err = %v, want a deadline error", err) } case <-time.After(5 * time.Second): t.Fatal("call is still waiting on a silent server") } } func TestOversizedResponseIsRejected(t *testing.T) { c, _ := fakeAtticd(t, 200, `{"missing_paths":["`+strings.Repeat("a", maxResponseBytes)+`"]}`) if _, err := c.MissingPaths(context.Background(), "tok", "c", nil); err == nil || !strings.Contains(err.Error(), "over") { t.Errorf("err = %v, want an oversized response error", err) } }