package server_test
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"git.t5de.dev/t5de/t5de-catalog/internal/hub"
"git.t5de.dev/t5de/t5de-catalog/internal/server"
"git.t5de.dev/t5de/t5de-catalog/internal/store"
"git.t5de.dev/t5de/t5de-catalog/internal/token"
"git.t5de.dev/t5de/t5de-catalog/internal/token/tokentest"
)
const (
catalogID = "cat1"
catalogKey = "secret-key"
webOrigin = "https://t5de.example"
)
// fakeHub serves a JWKS, issues tokens and accepts revisions as docs/products.md says.
type fakeHub struct {
*httptest.Server
t *testing.T
key *tokentest.Key
mu sync.Mutex
down bool
products map[string][][]store.File // product -> file list of each revision
last *hub.NewRevision
calls int
next int
}
func newFakeHub(t *testing.T) *fakeHub {
h := &fakeHub{t: t, key: tokentest.NewKey("hubkey"), products: map[string][][]store.File{}}
h.Server = httptest.NewServer(http.HandlerFunc(h.serve))
t.Cleanup(h.Close)
return h
}
func (h *fakeHub) serve(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == token.KeysPath:
w.Header().Set("Content-Type", "application/json")
w.Write(tokentest.JWKS(h.key))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/catalog/revisions":
h.revisions(w, r)
default:
http.NotFound(w, r)
}
}
func refuse(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg})
}
func (h *fakeHub) revisions(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
defer h.mu.Unlock()
h.calls++
if h.down {
http.Error(w, "down", http.StatusServiceUnavailable)
return
}
if r.Header.Get("Authorization") != "Catalog "+catalogID+":"+catalogKey {
refuse(w, http.StatusUnauthorized, "bad_catalog", "Unknown catalog.")
return
}
var req hub.NewRevision
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
refuse(w, http.StatusBadRequest, "invalid", err.Error())
return
}
h.last = &req
v := token.NewVerifier(h.URL, h.Client())
c, err := v.Verify(r.Context(), req.UploadToken, token.Upload)
if err != nil || c.Catalog != catalogID {
refuse(w, http.StatusForbidden, "bad_token", "Not for this catalog.")
return
}
product := c.Product
if product == "" {
h.next++
product = fmt.Sprintf("prod%06d", h.next)
product = strings.NewReplacer("0", "a", "1", "b", "8", "c", "9", "d").Replace(product)
} else {
revs := h.products[product]
if revs == nil {
refuse(w, http.StatusNotFound, "not_found", "No such product.")
return
}
if req.Notes == "" {
refuse(w, http.StatusBadRequest, "notes_required", "Notes are required.")
return
}
if slices.Equal(revs[len(revs)-1], req.Files) {
refuse(w, http.StatusConflict, "unchanged", "These files are the same as the latest revision's.")
return
}
}
h.products[product] = append(h.products[product], req.Files)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(hub.Registered{Product: product, Revision: len(h.products[product])})
}
func (h *fakeHub) token(typ string, claims map[string]any) string {
c := map[string]any{"iss": h.URL, "typ": typ, "sub": "person1", "iat": time.Now().Unix(), "exp": time.Now().Add(30 * time.Minute).Unix()}
for k, v := range claims {
c[k] = v
}
return h.key.Sign(c)
}
func (h *fakeHub) uploadToken(product string) string {
c := map[string]any{"catalog": catalogID}
if product != "" {
c["product"] = product
}
return h.token(token.Upload, c)
}
type env struct {
hub *fakeHub
catalog *httptest.Server
store *store.Store
}
func setup(t *testing.T) *env {
h := newFakeHub(t)
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
srv := server.New(server.Config{
Store: st,
Verifier: token.NewVerifier(h.URL, h.Client()),
Hub: hub.New(h.URL, catalogID, catalogKey, h.Client()),
CatalogID: catalogID,
WebOrigin: webOrigin,
Logger: log.New(io.Discard, "", 0),
})
c := httptest.NewServer(srv)
t.Cleanup(c.Close)
return &env{hub: h, catalog: c, store: st}
}
type file struct{ name, data string }
func indexXML(rating, name string) string {
return `
` + name + `
` + rating + `
female
<__DATAIMPORT>product://12345/index.xml
`
}
func makeZip(t *testing.T, files ...file) []byte {
var b bytes.Buffer
z := zip.NewWriter(&b)
for _, f := range files {
w, err := z.Create(f.name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(f.data))
}
if err := z.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// texture is a tiny synthetic "PNG": the catalog doesn't decode images.
const texture = "\x89PNG\r\n\x1a\nnot really an image, just test bytes"
type answer struct {
status int
header http.Header
body map[string]any
raw []byte
}
func (e *env) upload(t *testing.T, tok string, zipData []byte, notes *string) answer {
t.Helper()
var b bytes.Buffer
mw := multipart.NewWriter(&b)
if notes != nil {
mw.WriteField("notes", *notes)
}
if zipData != nil {
fw, _ := mw.CreateFormFile("file", "product.chkn")
fw.Write(zipData)
}
mw.Close()
req, _ := http.NewRequest(http.MethodPost, e.catalog.URL+"/api/v1/uploads", &b)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Origin", webOrigin)
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
return do(t, req)
}
func do(t *testing.T, req *http.Request) answer {
t.Helper()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
a := answer{status: resp.StatusCode, header: resp.Header, raw: raw}
json.Unmarshal(raw, &a.body)
return a
}
func (e *env) get(t *testing.T, path string, headers ...string) answer {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, e.catalog.URL+path, nil)
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
return do(t, req)
}
func sum(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func ptr(s string) *string { return &s }
func TestUploadAndServe(t *testing.T) {
e := setup(t)
index := indexXML("GA", "Test Hat")
a := e.upload(t, e.hub.uploadToken(""), makeZip(t, file{"tex.png", texture}, file{"index.xml", index}), nil)
if a.status != http.StatusCreated {
t.Fatalf("upload: %d %s", a.status, a.raw)
}
if a.header.Get("Access-Control-Allow-Origin") != webOrigin {
t.Errorf("no CORS header on the upload's answer")
}
product, _ := a.body["product"].(string)
if !hub.ValidProduct(product) || a.body["revision"] != 1.0 {
t.Fatalf("answer %s", a.raw)
}
// What the hub was sent.
got := e.hub.last
wantFiles := []store.File{
{Name: "index.xml", SHA256: sum(index), Size: int64(len(index))},
{Name: "tex.png", SHA256: sum(texture), Size: int64(len(texture))},
}
if !slices.Equal(got.Files, wantFiles) || got.Rating != "general" || got.Parent != 12345 || got.Name != "Test Hat" || got.Notes != "" {
t.Fatalf("hub got %+v", got)
}
// Files come back, cacheable, with ranges and ETags.
f := e.get(t, "/api/v1/files/"+sum(texture))
if f.status != http.StatusOK || string(f.raw) != texture {
t.Fatalf("file: %d %q", f.status, f.raw)
}
if cc := f.header.Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
t.Errorf("Cache-Control %q", cc)
}
if et := f.header.Get("ETag"); et != `"`+sum(texture)+`"` {
t.Errorf("ETag %q", et)
}
if r := e.get(t, "/api/v1/files/"+sum(texture), "Range", "bytes=0-3"); r.status != http.StatusPartialContent || string(r.raw) != texture[:4] {
t.Errorf("range: %d %q", r.status, r.raw)
}
if r := e.get(t, "/api/v1/files/"+sum(texture), "If-None-Match", `"`+sum(texture)+`"`); r.status != http.StatusNotModified {
t.Errorf("If-None-Match: %d", r.status)
}
if r := e.get(t, "/api/v1/files/"+sum(index)); r.status != http.StatusOK || string(r.raw) != index {
t.Errorf("index.xml: %d", r.status)
}
// The revision.
rev := e.get(t, fmt.Sprintf("/api/v1/revisions/%s/1", product))
var r store.Revision
if err := json.Unmarshal(rev.raw, &r); rev.status != http.StatusOK || err != nil {
t.Fatalf("revision: %d %s", rev.status, rev.raw)
}
if r.Product != product || r.Revision != 1 || r.Rating != "general" || !slices.Equal(r.Files, wantFiles) {
t.Fatalf("revision %+v", r)
}
if rev := e.get(t, fmt.Sprintf("/api/v1/revisions/%s/2", product)); rev.status != http.StatusNotFound {
t.Errorf("missing revision: %d", rev.status)
}
if rev := e.get(t, "/api/v1/revisions/NOT-VALID/1"); rev.status != http.StatusNotFound {
t.Errorf("bad product: %d", rev.status)
}
// Not found.
if r := e.get(t, "/api/v1/files/"+sum("nothing")); r.status != http.StatusNotFound {
t.Errorf("unknown file: %d", r.status)
}
if r := e.get(t, "/api/v1/files/abc"); r.status != http.StatusNotFound {
t.Errorf("bad hash: %d", r.status)
}
// A new revision needs notes (checked before storing anything or calling the hub).
newIndex := indexXML("GA", "Test Hat")
tex2 := texture + " v2"
zip2 := makeZip(t, file{"index.xml", newIndex}, file{"tex.png", tex2})
calls := e.hub.calls
if a := e.upload(t, e.hub.uploadToken(product), zip2, nil); a.status != http.StatusBadRequest || a.body["error"] != "notes_required" {
t.Fatalf("no notes: %d %s", a.status, a.raw)
}
if e.hub.calls != calls {
t.Fatal("hub called without notes")
}
a = e.upload(t, e.hub.uploadToken(product), zip2, ptr("A brighter texture."))
if a.status != http.StatusCreated || a.body["product"] != product || a.body["revision"] != 2.0 {
t.Fatalf("revision 2: %d %s", a.status, a.raw)
}
if e.hub.last.Notes != "A brighter texture." {
t.Errorf("notes %q", e.hub.last.Notes)
}
// The hub refusing (the same files again) is a 502 with its error.
a = e.upload(t, e.hub.uploadToken(product), zip2, ptr("Again."))
if a.status != http.StatusBadGateway || a.body["error"] != "unchanged" {
t.Fatalf("unchanged: %d %s", a.status, a.raw)
}
// The hub unreachable: 502, and the stored files aren't served (no hosted revision has them).
e.hub.mu.Lock()
e.hub.down = true
e.hub.mu.Unlock()
tex3 := texture + " v3"
a = e.upload(t, e.hub.uploadToken(""), makeZip(t, file{"index.xml", index}, file{"tex.png", tex3}), nil)
if a.status != http.StatusBadGateway {
t.Fatalf("hub down: %d %s", a.status, a.raw)
}
if r := e.get(t, "/api/v1/files/"+sum(tex3)); r.status != http.StatusNotFound {
t.Errorf("file of a refused upload: %d", r.status)
}
}
func TestAdultFiles(t *testing.T) {
e := setup(t)
index := indexXML("AP", "Adult Thing")
adultTex := texture + " adult"
a := e.upload(t, e.hub.uploadToken(""), makeZip(t, file{"index.xml", index}, file{"a.png", adultTex}, file{"shared.png", texture}), nil)
if a.status != http.StatusCreated {
t.Fatalf("upload: %d %s", a.status, a.raw)
}
if e.hub.last.Rating != "adult" {
t.Fatalf("rating %q", e.hub.last.Rating)
}
path := "/api/v1/files/" + sum(adultTex)
r := e.get(t, path)
if r.status != http.StatusUnauthorized || !strings.HasPrefix(r.header.Get("Cache-Control"), "private") {
t.Fatalf("no token: %d %q", r.status, r.header.Get("Cache-Control"))
}
r = e.get(t, path, "Authorization", "Bearer garbage")
if r.status != http.StatusUnauthorized {
t.Fatalf("bad token: %d", r.status)
}
r = e.get(t, path, "Authorization", "Bearer "+e.hub.uploadToken(""))
if r.status != http.StatusUnauthorized {
t.Fatalf("upload token as access token: %d", r.status)
}
r = e.get(t, path, "Authorization", "Bearer "+e.hub.token(token.Access, map[string]any{"adult": false}))
if r.status != http.StatusForbidden || !strings.HasPrefix(r.header.Get("Cache-Control"), "private") {
t.Fatalf("not adult: %d %q", r.status, r.header.Get("Cache-Control"))
}
r = e.get(t, path, "Authorization", "Bearer "+e.hub.token(token.Access, map[string]any{"adult": true}))
if r.status != http.StatusOK || string(r.raw) != adultTex {
t.Fatalf("adult: %d %q", r.status, r.raw)
}
if cc := r.header.Get("Cache-Control"); !strings.HasPrefix(cc, "private") {
t.Errorf("adult file's Cache-Control %q", cc)
}
// shared.png also belongs to a general revision once one uses it: then it's public.
if r := e.get(t, "/api/v1/files/"+sum(texture)); r.status != http.StatusUnauthorized {
t.Fatalf("shared file before a general revision: %d", r.status)
}
a = e.upload(t, e.hub.uploadToken(""), makeZip(t, file{"index.xml", indexXML("GA", "Plain")}, file{"shared.png", texture}), nil)
if a.status != http.StatusCreated {
t.Fatalf("general upload: %d %s", a.status, a.raw)
}
if r := e.get(t, "/api/v1/files/"+sum(texture)); r.status != http.StatusOK {
t.Fatalf("shared file after a general revision: %d", r.status)
}
}
func TestBadUploads(t *testing.T) {
e := setup(t)
good := makeZip(t, file{"index.xml", indexXML("GA", "Hat")}, file{"tex.png", texture})
tokens := map[string]string{
"no token": "",
"garbage": "garbage",
"other catalog": e.hub.token(token.Upload, map[string]any{"catalog": "cat2"}),
"access token": e.hub.token(token.Access, map[string]any{"adult": true}),
"expired": e.hub.token(token.Upload, map[string]any{"catalog": catalogID, "exp": time.Now().Add(-time.Hour).Unix()}),
"wrong issuer": e.hub.token(token.Upload, map[string]any{"catalog": catalogID, "iss": "https://other.example"}),
"other key": tokentest.NewKey("hubkey").Sign(map[string]any{"iss": e.hub.URL, "typ": token.Upload, "catalog": catalogID, "exp": time.Now().Add(time.Hour).Unix()}),
}
for name, tok := range tokens {
if a := e.upload(t, tok, good, nil); a.status != http.StatusUnauthorized || a.body["error"] != "bad_token" {
t.Errorf("%s: %d %s", name, a.status, a.raw)
}
}
big := strings.Repeat("x", 100)
zips := map[string][]byte{
"not a zip": []byte("this is not a zip file"),
"no index.xml": makeZip(t, file{"tex.png", texture}),
"bad rating": makeZip(t, file{"index.xml", indexXML("XX", "Hat")}),
"no rating": makeZip(t, file{"index.xml", "Hat"}),
"not a template": makeZip(t, file{"index.xml", ""}),
"file in a folder": makeZip(t, file{"index.xml", indexXML("GA", "Hat")}, file{"sub/tex.png", texture}),
"_contents.json": makeZip(t, file{"index.xml", indexXML("GA", "Hat")}, file{"_contents.json", "{}"}),
"duplicate name": makeZip(t, file{"index.xml", indexXML("GA", "Hat")}, file{"a.png", big}, file{"a.png", big}),
"non-Latin-1 name": makeZip(t, file{"index.xml", indexXML("GA", "Hat")}, file{"δΈ€.png", big}),
"bad __DATAIMPORT": makeZip(t, file{"index.xml", strings.Replace(indexXML("GA", "Hat"), "product://12345/index.xml", "http://x", 1)}),
"index.xml nested": makeZip(t, file{"x/index.xml", indexXML("GA", "Hat")}),
"backslash in name": makeZip(t, file{"index.xml", indexXML("GA", "Hat")}, file{`a\b.png`, big}),
}
for name, z := range zips {
a := e.upload(t, e.hub.uploadToken(""), z, nil)
t.Logf("%s: %s", name, a.body["message"])
if a.status != http.StatusBadRequest || a.body["error"] != "bad_files" {
t.Errorf("%s: %d %s", name, a.status, a.raw)
}
}
if a := e.upload(t, e.hub.uploadToken(""), nil, ptr("notes but no file")); a.status != http.StatusBadRequest {
t.Errorf("no file: %d %s", a.status, a.raw)
}
req, _ := http.NewRequest(http.MethodPost, e.catalog.URL+"/api/v1/uploads", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+e.hub.uploadToken(""))
if a := do(t, req); a.status != http.StatusBadRequest {
t.Errorf("not multipart: %d %s", a.status, a.raw)
}
if e.hub.calls != 0 {
t.Errorf("hub called %d times for bad uploads", e.hub.calls)
}
}
// A zip whose files are larger than its headers say is cut off by the pack rules' counted reads.
func TestUploadSizeCountedWhileReading(t *testing.T) {
e := setup(t)
// A file over the 64 MiB limit, compressed down to a small upload.
var b bytes.Buffer
z := zip.NewWriter(&b)
w, _ := z.Create("index.xml")
w.Write([]byte(indexXML("GA", "Hat")))
w, _ = z.Create("huge.png")
chunk := make([]byte, 1<<20)
for range 65 {
w.Write(chunk)
}
z.Close()
a := e.upload(t, e.hub.uploadToken(""), b.Bytes(), nil)
if a.status != http.StatusBadRequest || !strings.Contains(a.body["message"].(string), "64 MiB") {
t.Fatalf("huge file: %d %s", a.status, a.raw)
}
}
func TestCORSAndHealth(t *testing.T) {
e := setup(t)
preflight := func(origin string) http.Header {
req, _ := http.NewRequest(http.MethodOptions, e.catalog.URL+"/api/v1/uploads", nil)
req.Header.Set("Origin", origin)
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "authorization")
a := do(t, req)
if a.status != http.StatusNoContent {
t.Fatalf("preflight: %d", a.status)
}
return a.header
}
h := preflight(webOrigin)
if h.Get("Access-Control-Allow-Origin") != webOrigin || !strings.Contains(h.Get("Access-Control-Allow-Headers"), "Authorization") ||
h.Get("Access-Control-Allow-Methods") != "POST" {
t.Errorf("preflight headers: %v", h)
}
if h := preflight("https://evil.example"); h.Get("Access-Control-Allow-Origin") != "" {
t.Errorf("other origin allowed: %v", h)
}
a := e.get(t, "/health")
if a.status != http.StatusOK || a.body["ok"] != true {
t.Errorf("health: %d %s", a.status, a.raw)
}
}