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.
This commit is contained in:
+193
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newStorage(t *testing.T) *Storage {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := NewStorage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStorage: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func buildManifest(id, sha string) *Manifest {
|
||||
return &Manifest{
|
||||
SnapshotID: id,
|
||||
CreatedAt: time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC),
|
||||
Files: map[string]FileEntry{
|
||||
"options.txt": {
|
||||
SHA256: sha,
|
||||
Size: 5,
|
||||
Mtime: time.Date(2026, 6, 2, 11, 30, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_RoundTripSnapshot(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
|
||||
sha, err := s.WriteBlob("123456789", []byte("hello"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteBlob: %v", err)
|
||||
}
|
||||
if sha != HashBytes([]byte("hello")) {
|
||||
t.Fatalf("sha mismatch: got %s", sha)
|
||||
}
|
||||
|
||||
m := buildManifest("01J9XQK4Z3DEMO0001", sha)
|
||||
if err := s.StoreSnapshot("123456789", m, []byte("fake-tarball-bytes")); err != nil {
|
||||
t.Fatalf("StoreSnapshot: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.ReadManifest("123456789")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest: %v", err)
|
||||
}
|
||||
if got.SnapshotID != "01J9XQK4Z3DEMO0001" {
|
||||
t.Errorf("snapshot id mismatch: got %s", got.SnapshotID)
|
||||
}
|
||||
if _, ok := got.Files["options.txt"]; !ok {
|
||||
t.Errorf("files key missing")
|
||||
}
|
||||
|
||||
rc, err := s.ReadBlob("123456789", sha)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadBlob: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, _ := io.ReadAll(rc)
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("blob content: got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_BlobDedupe(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
a, _ := s.WriteBlob("u1", []byte("xyz"))
|
||||
b, _ := s.WriteBlob("u1", []byte("xyz"))
|
||||
if a != b {
|
||||
t.Errorf("dedupe broken: got %s vs %s", a, b)
|
||||
}
|
||||
// Both calls should leave one blob on disk
|
||||
p := s.blobPath("u1", a)
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatalf("stat blob: %v", err)
|
||||
}
|
||||
if info.Size() != 3 {
|
||||
t.Errorf("blob size: got %d, want 3", info.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_ReadManifest_MissingIsNotExist(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
_, err := s.ReadManifest("never-stored")
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected os.ErrNotExist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_ListSnapshots_NewestFirst(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
user := "u1"
|
||||
sha, _ := s.WriteBlob(user, []byte("x"))
|
||||
for _, id := range []string{"01ABC", "01BBB", "01ZZZ"} {
|
||||
m := buildManifest(id, sha)
|
||||
if err := s.StoreSnapshot(user, m, []byte("tar")); err != nil {
|
||||
t.Fatalf("StoreSnapshot %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
list, err := s.ListSnapshots(user)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSnapshots: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("expected 3 snapshots, got %d", len(list))
|
||||
}
|
||||
if list[0].ID != "01ZZZ" || list[2].ID != "01ABC" {
|
||||
t.Errorf("ordering wrong: %v", []string{list[0].ID, list[1].ID, list[2].ID})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_DeleteSnapshot_RefusesLatest(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
sha, _ := s.WriteBlob("u", []byte("y"))
|
||||
m := buildManifest("01OLD0", sha)
|
||||
if err := s.StoreSnapshot("u", m, []byte("t1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2 := buildManifest("01NEW0", sha)
|
||||
if err := s.StoreSnapshot("u", m2, []byte("t2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Latest = 01NEW0; deleting it should fail
|
||||
if err := s.DeleteSnapshot("u", "01NEW0"); err == nil {
|
||||
t.Error("expected error deleting latest, got nil")
|
||||
}
|
||||
// Old one deletes
|
||||
if err := s.DeleteSnapshot("u", "01OLD0"); err != nil {
|
||||
t.Errorf("delete old: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_StoreSnapshot_RejectsBadManifest(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
bad := &Manifest{}
|
||||
if err := s.StoreSnapshot("u", bad, []byte("x")); err == nil {
|
||||
t.Error("expected validation error for empty manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_RejectsBadUserID(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
cases := []string{"", "../etc", "u/path", "user\\back", "with.dot"}
|
||||
for _, u := range cases {
|
||||
t.Run(u, func(t *testing.T) {
|
||||
_, err := s.WriteBlob(u, []byte("x"))
|
||||
if err == nil {
|
||||
t.Errorf("user %q: expected error", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_RejectsBadSnapshotID(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
cases := []string{"", "../escape", "with/slash", strings.Repeat("a", 100), "with space"}
|
||||
for _, id := range cases {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
_, err := s.OpenSnapshot("u", id)
|
||||
if err == nil {
|
||||
t.Errorf("id %q: expected error", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorage_UsageBytes(t *testing.T) {
|
||||
s := newStorage(t)
|
||||
user := "u"
|
||||
_, _ = s.WriteBlob(user, []byte("0123456789")) // 10 bytes
|
||||
m := buildManifest("01X", HashBytes([]byte("0123456789")))
|
||||
if err := s.StoreSnapshot(user, m, make([]byte, 50)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.UsageBytes(user)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes: %v", err)
|
||||
}
|
||||
// 10 (blob) + 50 (tar) + manifest JSON on disk (variable but small)
|
||||
if got < 60 {
|
||||
t.Errorf("expected at least 60 bytes, got %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user