Files
cloud-svc/server_test.go
T
claude-timemachine 1752ef05a6
CI / validate (push) Successful in 26s
CI / docker (push) Failing after 8s
initial: Steam-Cloud-style per-user state sync skeleton
HTTP API + on-disk storage + auth-service token verification + dev mode.
31 tests pass, vet clean. See DESIGN.md for the architecture and
README.md for the operator surface.

Pending: pg-backed per-user quota override, snapshot retention / blob GC,
tarball-vs-manifest content cross-check, end-to-end deploy on john.
2026-06-02 18:52:25 +02:00

305 lines
8.6 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// scopedVerifier accepts a fixed token and reports a fixed user + scope set.
type scopedVerifier struct {
token string
user string
scopes []string
err error
}
func (v *scopedVerifier) Verify(_ context.Context, token string) (*AuthInfo, error) {
if v.err != nil {
return nil, v.err
}
if token != v.token {
return nil, ErrUnauthenticated
}
return &AuthInfo{User: v.user, Scopes: v.scopes}, nil
}
func newTestServer(t *testing.T) (*Server, *Storage, *scopedVerifier) {
t.Helper()
st := newStorage(t)
v := &scopedVerifier{
token: "good-token",
user: "user1",
scopes: []string{"cloud:rw"},
}
return NewServer(st, v, DefaultQuota(1<<20 /* 1 MB */)), st, v
}
func authReq(t *testing.T, method, path string, body io.Reader) *http.Request {
t.Helper()
r := httptest.NewRequest(method, path, body)
r.Header.Set("Authorization", "Bearer good-token")
return r
}
func TestServer_Auth_MissingBearer_401(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/manifest", nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", rec.Code)
}
}
func TestServer_Auth_BadToken_401(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/manifest", nil)
r.Header.Set("Authorization", "Bearer wrong-token")
s.ServeHTTP(rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", rec.Code)
}
}
func TestServer_Auth_Revoked_403(t *testing.T) {
s, _, v := newTestServer(t)
v.err = ErrRevoked
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/manifest", nil))
if rec.Code != http.StatusForbidden {
t.Errorf("got %d, want 403", rec.Code)
}
}
func TestServer_Auth_MissingScope_403(t *testing.T) {
s, _, v := newTestServer(t)
v.scopes = []string{"some-other-scope"}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/manifest", nil))
if rec.Code != http.StatusForbidden {
t.Errorf("got %d, want 403", rec.Code)
}
}
func TestServer_Auth_BackendError_502(t *testing.T) {
s, _, v := newTestServer(t)
v.err = errors.New("upstream down")
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/manifest", nil))
if rec.Code != http.StatusBadGateway {
t.Errorf("got %d, want 502", rec.Code)
}
}
func TestServer_GetManifest_NoSnapshots_204(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/manifest", nil))
if rec.Code != http.StatusNoContent {
t.Errorf("got %d, want 204", rec.Code)
}
}
func TestServer_PostSnapshot_RoundTrip(t *testing.T) {
s, st, _ := newTestServer(t)
// Write a blob first (simulates the client side).
blob := []byte("file contents")
sha, err := st.WriteBlob("user1", blob)
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
manifest := &Manifest{
SnapshotID: "01TESTSNAPSHOT0001",
CreatedAt: time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC),
Files: map[string]FileEntry{
"options.txt": {
SHA256: sha,
Size: int64(len(blob)),
Mtime: time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC),
},
},
}
manifestJSON, _ := json.Marshal(manifest)
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
fw, _ := mw.CreateFormFile("manifest", "manifest.json")
_, _ = fw.Write(manifestJSON)
fw, _ = mw.CreateFormFile("tarball", "snapshot.tar.zst")
_, _ = fw.Write([]byte("fake-tarball-bytes"))
mw.Close()
req := authReq(t, http.MethodPost, "/v1/snapshot", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, body=%s", rec.Code, rec.Body.String())
}
var resp map[string]string
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["snapshot_id"] != "01TESTSNAPSHOT0001" {
t.Errorf("snapshot_id: got %s", resp["snapshot_id"])
}
// Now GET /v1/manifest should return the same
rec = httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/manifest", nil))
if rec.Code != http.StatusOK {
t.Fatalf("manifest after push: %d", rec.Code)
}
var got Manifest
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got.SnapshotID != "01TESTSNAPSHOT0001" {
t.Errorf("manifest snapshot id: got %s", got.SnapshotID)
}
}
func TestServer_GetBlob_ReturnsContent(t *testing.T) {
s, st, _ := newTestServer(t)
sha, _ := st.WriteBlob("user1", []byte("blob-payload"))
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/blob/"+sha, nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
if rec.Body.String() != "blob-payload" {
t.Errorf("body: got %q", rec.Body.String())
}
}
func TestServer_GetBlob_404(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
missing := strings.Repeat("0", 64)
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/blob/"+missing, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
}
func TestServer_PostSnapshot_BadManifest_400(t *testing.T) {
s, _, _ := newTestServer(t)
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
fw, _ := mw.CreateFormFile("manifest", "manifest.json")
_, _ = fw.Write([]byte(`{"snapshot_id":"","created_at":"2026-01-01T00:00:00Z","files":{}}`))
fw, _ = mw.CreateFormFile("tarball", "snapshot.tar.zst")
_, _ = fw.Write([]byte("x"))
mw.Close()
req := authReq(t, http.MethodPost, "/v1/snapshot", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
}
func TestServer_PostSnapshot_QuotaExceeded_413(t *testing.T) {
st := newStorage(t)
v := &scopedVerifier{token: "good-token", user: "u", scopes: []string{"cloud:rw"}}
s := NewServer(st, v, DefaultQuota(100)) // 100 bytes
manifest := &Manifest{
SnapshotID: "01QUOTATEST00000001",
CreatedAt: time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC),
Files: map[string]FileEntry{
"f.txt": {
SHA256: HashBytes([]byte("x")),
Size: 1,
Mtime: time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC),
},
},
}
manifestJSON, _ := json.Marshal(manifest)
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
fw, _ := mw.CreateFormFile("manifest", "manifest.json")
_, _ = fw.Write(manifestJSON)
fw, _ = mw.CreateFormFile("tarball", "tar.zst")
_, _ = fw.Write(make([]byte, 200)) // > 100 byte quota
mw.Close()
req := authReq(t, http.MethodPost, "/v1/snapshot", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("got %d, want 413", rec.Code)
}
}
func TestServer_ListSnapshots(t *testing.T) {
s, st, _ := newTestServer(t)
sha, _ := st.WriteBlob("user1", []byte("y"))
for _, id := range []string{"01AAA", "01BBB"} {
m := buildManifest(id, sha)
if err := st.StoreSnapshot("user1", m, []byte("t")); err != nil {
t.Fatal(err)
}
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/snapshots", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
var resp struct {
Snapshots []SnapshotInfo `json:"snapshots"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Snapshots) != 2 {
t.Errorf("snapshots: got %d, want 2", len(resp.Snapshots))
}
}
func TestServer_GetQuota(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodGet, "/v1/quota", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if _, ok := resp["limit_bytes"]; !ok {
t.Errorf("missing limit_bytes; body=%s", rec.Body.String())
}
}
func TestServer_Healthz(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Errorf("got code=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestServer_DeleteSnapshot_RefusesLatest_400(t *testing.T) {
s, st, _ := newTestServer(t)
sha, _ := st.WriteBlob("user1", []byte("z"))
m := buildManifest("01ONLYSNAPSHOT00001", sha)
if err := st.StoreSnapshot("user1", m, []byte("tar")); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, authReq(t, http.MethodDelete, "/v1/snapshot/01ONLYSNAPSHOT00001", nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
}