package nwsync import ( "crypto/sha1" "crypto/sha256" "encoding/hex" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" ) // fakeZone is a Bunny-shaped object store: PUT stores, GET reads, and the // Checksum header is verified the way Bunny verifies it. type fakeZone struct { mu sync.Mutex objects map[string][]byte puts []string failOn func(key string) bool // when true, the PUT fails url string // base the same objects are readable at } // pullZone reads the fake zone the way the public pull zone is read: plain // unauthenticated GETs, no storage API. func (z *fakeZone) pullZone() blobSource { return newPullZone(z.url) } func newFakeZone(t *testing.T) (*fakeZone, func(string) string) { t.Helper() zone := &fakeZone{objects: map[string][]byte{}} server := httptest.NewServer(zone) t.Cleanup(server.Close) zone.url = server.URL + "/sow-nwsync" getenv := func(name string) string { switch name { case "NWSYNC_STORAGE_ZONE": return "sow-nwsync" case "NWSYNC_STORAGE_PASSWORD": return "write-key" case "BUNNY_STORAGE_HOST": return server.URL } return "" } return zone, getenv } func (z *fakeZone) ServeHTTP(w http.ResponseWriter, r *http.Request) { key := strings.TrimPrefix(r.URL.Path, "/sow-nwsync/") switch r.Method { case http.MethodPut: if z.failOn != nil && z.failOn(key) { http.Error(w, "boom", http.StatusInternalServerError) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } sum := sha256.Sum256(body) if want := strings.ToUpper(hex.EncodeToString(sum[:])); r.Header.Get("Checksum") != want { http.Error(w, "checksum mismatch", http.StatusBadRequest) return } z.mu.Lock() z.objects[key] = body z.puts = append(z.puts, key) z.mu.Unlock() w.WriteHeader(http.StatusCreated) case http.MethodGet: z.mu.Lock() body, ok := z.objects[key] z.mu.Unlock() if !ok { http.Error(w, "not found", http.StatusNotFound) return } _, _ = w.Write(body) default: http.Error(w, "unsupported", http.StatusMethodNotAllowed) } } func (z *zoneSinkFixture) emit(t *testing.T, path string) EmitResult { t.Helper() result, err := Emit(EmitOptions{ ArtifactKey: artifactKey(t, path), ArtifactPath: path, Sink: z.sink, }) if err != nil { t.Fatalf("emit %s: %v", path, err) } return result } type zoneSinkFixture struct { zone *fakeZone sink sink } func newZoneFixture(t *testing.T) *zoneSinkFixture { t.Helper() zone, getenv := newFakeZone(t) target, err := newZoneSink(t.Context(), getenv) if err != nil { t.Fatalf("zone sink: %v", err) } return &zoneSinkFixture{zone: zone, sink: target} } func sha1Of(body []byte) [20]byte { return sha1.Sum(body) } func TestEmitUploadsBlobsThenIndex(t *testing.T) { fixture := newZoneFixture(t) dir := t.TempDir() hak := filepath.Join(dir, "sow_test_01.hak") body := []byte("texture bytes") writeHak(t, hak, map[string][]byte{"bloodstain1.tga": body, "copy1.txi": body}) key := artifactKey(t, hak) result := fixture.emit(t, hak) index, err := indexKey(key) if err != nil { t.Fatal(err) } if _, ok := fixture.zone.objects[index]; !ok { t.Fatalf("no index at %s; zone holds %v", index, fixture.zone.puts) } if result.BlobsWritten != 1 { t.Errorf("uploaded %d blobs, want 1 (identical content shares a blob)", result.BlobsWritten) } // The index is the publication marker, so it must land after every blob it // names — including its own sidecar. last := fixture.zone.puts[len(fixture.zone.puts)-1] if last != index { t.Errorf("index landed at position %d of %d; it must be last", len(fixture.zone.puts), len(fixture.zone.puts)) } for _, key := range fixture.zone.puts[:len(fixture.zone.puts)-1] { if strings.HasPrefix(key, "data/sha1/") || key == index+".json" { continue } t.Errorf("unexpected object uploaded before the index: %s", key) } } func TestEmitSkipsBlobsAlreadyInTheZone(t *testing.T) { fixture := newZoneFixture(t) dir := t.TempDir() hak := filepath.Join(dir, "sow_test_01.hak") writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")}) first := fixture.emit(t, hak) if first.BlobsWritten != 1 { t.Fatalf("first emit uploaded %d blobs, want 1", first.BlobsWritten) } second := fixture.emit(t, hak) if second.BlobsWritten != 0 { t.Errorf("re-emit uploaded %d blobs, want 0 (a blob name is its content)", second.BlobsWritten) } } func TestEmitLeavesNoIndexWhenAnUploadFails(t *testing.T) { fixture := newZoneFixture(t) fixture.zone.failOn = func(key string) bool { return strings.HasPrefix(key, "data/sha1/") } dir := t.TempDir() hak := filepath.Join(dir, "sow_test_01.hak") writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")}) _, err := Emit(EmitOptions{ ArtifactKey: artifactKey(t, hak), ArtifactPath: hak, Sink: fixture.sink, }) if err == nil { t.Fatal("emit reported success after an upload failed") } for key := range fixture.zone.objects { if strings.HasSuffix(key, ".nsym") { t.Errorf("a half-emitted artifact published an index: %s", key) } } } func TestEmitRejectsAKeyThatDoesNotMatchTheFile(t *testing.T) { fixture := newZoneFixture(t) dir := t.TempDir() hak := filepath.Join(dir, "sow_test_01.hak") writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")}) wrong := "artifacts/haks/sha256/00/11/" + strings.Repeat("0", 64) + ".hak" _, err := Emit(EmitOptions{ArtifactKey: wrong, ArtifactPath: hak, Sink: fixture.sink}) if err == nil || !strings.Contains(err.Error(), "hashes to") { t.Fatalf("emit published under a key that names another artifact: %v", err) } } func TestAssembleReadsIndexesFromTheZone(t *testing.T) { fixture := newZoneFixture(t) dir := t.TempDir() topBody := []byte("2da from sow_top") assetBody := []byte("2da from the asset hak") top := filepath.Join(dir, "sow_top.hak") core := filepath.Join(dir, "sow_core_01.hak") writeHak(t, top, map[string][]byte{"appearance.2da": topBody}) writeHak(t, core, map[string][]byte{"appearance.2da": assetBody, "bloodstain1.tga": []byte("blood")}) tlkPath := filepath.Join(dir, "sow_tlk.tlk") if err := os.WriteFile(tlkPath, []byte("TLK V3.0 payload"), 0o644); err != nil { t.Fatal(err) } fixture.emit(t, top) fixture.emit(t, core) if _, err := Emit(EmitOptions{ ArtifactKey: artifactKey(t, tlkPath), ArtifactPath: tlkPath, As: "sow_tlk.tlk", Sink: fixture.sink, }); err != nil { t.Fatalf("emit tlk: %v", err) } result, err := Assemble(AssembleOptions{ ArtifactKeys: []string{artifactKey(t, top), artifactKey(t, core)}, TLKKey: artifactKey(t, tlkPath), GroupID: 2, Sink: fixture.sink, }) if err != nil { t.Fatalf("assemble: %v", err) } if result.Entries != 3 { t.Fatalf("merged %d entries, want 3 (appearance.2da is shadowed, the TLK adds one)", result.Entries) } manifest, ok := fixture.zone.objects["manifests/"+result.SHA1] if !ok { t.Fatalf("no merged manifest in the zone; it holds %v", fixture.zone.puts) } entries, err := readManifest(manifest) if err != nil { t.Fatalf("parse merged manifest: %v", err) } for _, entry := range entries { if entry.ResRef == "appearance" && entry.SHA1 != sha1Of(topBody) { t.Errorf("appearance.2da resolved to the shadowed hak, not the first one given") } } }