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)
}
}