Artifact d3ea4a8ff289256389e3c73dd3cda632370f2188108d9fc41f496c74de5cae16:
- File
internal/sync/handler_test.go
— part of check-in
[3f290b159f]
at
2026-04-26 02:57:51
on branch trunk
— v0.4.1: xfer encoder, multi-round sync, manifest crosslink (#4)
* xfer: handle empty ServerCode/ProjectCode without arg-count error
Repo.Sync(ctx, transport, SyncOpts{Push: true, Pull: true}) with both codes empty previously wrote "push \n" to the wire. The decoder's strings.Fields collapsed adjacent spaces and failed with "xfer: pull requires 1-2 args, got 0".
Encoder now skips trailing empty args. Decoder accepts the verb with 0 args as "no specific code requested" — the same semantic as the existing 1- and 2-arg forms after fossil's normal arg interpretation.
Also fixes session.cardSummary's slice-out-of-range panic on empty codes by clamping the prefix length.
Adds a roundtrip test for the empty-codes path.
* sync: continue round-loop while either side has pending work
Sync's round-loop previously exited when the user passed Pull=false even if the peer was still requesting gimmes. The user's Pull flag controls whether we *request* new content; it should not gate whether we *service* the peer's gimmes. Symptom: agent-infra's Manager.Push had to pass Pull=true as a workaround to drive multi-round transfers.
Root cause: HandleSync.handleIGot gated gimme generation on pullOK alone. Push-only clients send igot cards from sendUnclustered every round (they always announce what they have), but the server would silently drop them because pullOK was false — leaving the server holding only what the client pushed proactively.
Continue the loop while any of: pending ungots to push, pending pulls to request, gimmes to service. Exit only when a round completes with no work in either direction. Concretely the gate is now (pushOK || pullOK), restoring parity with fossil-scm's client_sync continuation logic.
Adds an integration test (TestPushOnly_DrainsServerGimmes) that exercises the push-only path end-to-end and a unit test that asserts gimme emission under push-only handleIGot.
* sync: crosslink incoming manifests so hub-side state stays consistent
Repo.HandleSync previously stored manifest blobs in 'blob' but did not populate the relational tables (event, leaf, plink, mlink) that the timeline, fork detection, and clone protocol depend on. As a result:
- Leaves pulling from a hub got blobs but stale 'leaf' state, so WouldFork reported phantom siblings. - Verifier-clone of a hub repo failed with "sync.Clone: exceeded 100 rounds" because 'mlink' was empty and the clone protocol couldn't traverse manifest parents. - Hub timelines were always empty even after successful Push.
HandleSync now invokes manifest.Crosslink (the same scanner Clone and Sync run after convergence) when at least one file landed in the round. The scanner walks blobs that have no event/tagxref/ forumpost/attachment row yet, parses them, and populates the relational tables. Local commits already produced this state via manifest.Checkin's existing crosslink path; incoming syncs now match.
Coverage spans every artifact type the existing scanner already supports (ci, wiki, ticket, event, attachment, cluster, forum, control). The agent-infra trial that surfaced this only exercises 'ci' artifacts, so the divergence note in the prompt described the narrowest possible repair; reusing the existing scanner avoids the divergence and matches the C reference's full coverage.
Adds two tests: TestHandleSync_CrosslinksReceivedManifest verifies event/leaf/mlink rows after pushing a single checkin; the leaf- transition test verifies the parent leaves the leaf table when the child lands and that plink connects them. (user: [email protected], size: 41150) [annotate] [blame] [check-ins using]
package sync import ( "context" "crypto/sha1" "encoding/hex" "fmt" "testing" "github.com/danmestas/libfossil/internal/auth" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/content" "github.com/danmestas/libfossil/db" "github.com/danmestas/libfossil/internal/hash" "github.com/danmestas/libfossil/internal/repo" "github.com/danmestas/libfossil/internal/xfer" ) // findCards returns all cards of type T from a message. func findCards[T xfer.Card](msg *xfer.Message) []T { var out []T for _, c := range msg.Cards { if tc, ok := c.(T); ok { out = append(out, tc) } } return out } // storeTestBlob stores a blob and returns its UUID. func storeTestBlob(t *testing.T, r *repo.Repo, data []byte) string { t.Helper() uuid := hash.SHA1(data) if err := storeReceivedFile(r, uuid, "", data, nil); err != nil { t.Fatalf("storeReceivedFile: %v", err) } return uuid } func testProjectCode(t *testing.T, d *db.DB) string { t.Helper() var code string if err := d.QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&code); err != nil { t.Fatalf("project-code: %v", err) } return code } func buildTestLoginCard(user, password, projectCode string, payload []byte) *xfer.LoginCard { nonce := testSHA1Hex(payload) shared := testSHA1Hex([]byte(projectCode + "/" + user + "/" + password)) sig := testSHA1Hex([]byte(nonce + shared)) return &xfer.LoginCard{User: user, Nonce: nonce, Signature: sig} } func testSHA1Hex(data []byte) string { h := sha1.Sum(data) return hex.EncodeToString(h[:]) } func TestHandlePull(t *testing.T) { r := setupSyncTestRepo(t) uuid := storeTestBlob(t, r, []byte("pull me")) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } igots := findCards[*xfer.IGotCard](resp) found := false for _, ig := range igots { if ig.UUID == uuid { found = true } } if !found { t.Fatalf("pull response missing igot for %s", uuid) } } func TestHandleIGotGimme(t *testing.T) { r := setupSyncTestRepo(t) unknownUUID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, &xfer.IGotCard{UUID: unknownUUID}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } gimmes := findCards[*xfer.GimmeCard](resp) found := false for _, g := range gimmes { if g.UUID == unknownUUID { found = true } } if !found { t.Fatal("expected gimme for unknown UUID") } } // TestHandleIGotWithoutPushOrPull verifies the server does not gimme when // the client is neither pushing nor pulling. Without pushOK there is no // mandate for the client to send files, so issuing a gimme is wasted. func TestHandleIGotWithoutPushOrPull(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.IGotCard{UUID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } gimmes := findCards[*xfer.GimmeCard](resp) if len(gimmes) > 0 { t.Fatal("should not gimme without push or pull card") } } // TestHandleIGotWithPushOnly verifies the server emits gimme cards when // the client is push-only. This is the multi-round push-without-pull case // — without it, push-only clients exit after one round even when the // server is missing artifacts the client has just announced. func TestHandleIGotWithPushOnly(t *testing.T) { r := setupSyncTestRepo(t) unknownUUID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.IGotCard{UUID: unknownUUID}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } gimmes := findCards[*xfer.GimmeCard](resp) found := false for _, g := range gimmes { if g.UUID == unknownUUID { found = true } } if !found { t.Fatal("expected gimme for unknown UUID under push-only") } } func TestHandleGimme(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("gimme this") uuid := storeTestBlob(t, r, data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, &xfer.GimmeCard{UUID: uuid}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } files := findCards[*xfer.FileCard](resp) found := false for _, f := range files { if f.UUID == uuid && string(f.Content) == string(data) { found = true } } if !found { t.Fatal("expected file card with correct content") } } func TestHandleGimmeMissing(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.GimmeCard{UUID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } files := findCards[*xfer.FileCard](resp) if len(files) > 0 { t.Fatal("should not return file for missing blob") } } func TestHandlePushFile(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("push this") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: uuid, Content: data}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) > 0 { t.Fatalf("unexpected error: %s", errs[0].Message) } _, ok := blob.Exists(r.DB(), uuid) if !ok { t.Fatal("pushed blob not stored") } } func TestHandleFileWithoutPush(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("no push card") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.FileCard{UUID: uuid, Content: data}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) == 0 { t.Fatal("expected error for file without push") } } func TestHandleClone(t *testing.T) { r := setupSyncTestRepo(t) stored := map[string]bool{} for i := range 5 { data := []byte(fmt.Sprintf("clone test %d", i)) uuid := storeTestBlob(t, r, data) stored[uuid] = true } req := &xfer.Message{Cards: []xfer.Card{ &xfer.CloneCard{Version: 1}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } files := findCards[*xfer.FileCard](resp) for _, f := range files { delete(stored, f.UUID) } if len(stored) > 0 { t.Fatalf("clone missing blobs: %v", stored) } } func TestHandleClonePagination(t *testing.T) { r := setupSyncTestRepo(t) for i := range DefaultCloneBatchSize + 5 { data := []byte(fmt.Sprintf("page blob %d", i)) storeTestBlob(t, r, data) } // Page 1 req1 := &xfer.Message{Cards: []xfer.Card{&xfer.CloneCard{Version: 1}}} resp1, err := HandleSync(context.Background(), r, req1) if err != nil { t.Fatalf("page 1: %v", err) } files1 := findCards[*xfer.FileCard](resp1) seqnos := findCards[*xfer.CloneSeqNoCard](resp1) if len(seqnos) == 0 { t.Fatal("page 1: expected clone_seqno card for continuation") } if len(files1) != DefaultCloneBatchSize { t.Fatalf("page 1: got %d files, want %d", len(files1), DefaultCloneBatchSize) } // Page 2 req2 := &xfer.Message{Cards: []xfer.Card{ &xfer.CloneCard{Version: 1}, &xfer.CloneSeqNoCard{SeqNo: seqnos[0].SeqNo}, }} resp2, err := HandleSync(context.Background(), r, req2) if err != nil { t.Fatalf("page 2: %v", err) } files2 := findCards[*xfer.FileCard](resp2) if len(files2) < 5 { t.Fatalf("page 2: got %d files, want >= 5", len(files2)) } seqnos2 := findCards[*xfer.CloneSeqNoCard](resp2) if len(seqnos2) != 1 || seqnos2[0].SeqNo != 0 { t.Fatalf("page 2: expected clone_seqno 0 (completion), got %v", seqnos2) } } func TestHandleReqConfig(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.ReqConfigCard{Name: "project-code"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } configs := findCards[*xfer.ConfigCard](resp) found := false for _, c := range configs { if c.Name == "project-code" && len(c.Content) > 0 { found = true } } if !found { t.Fatal("expected config card for project-code") } } func TestHandlePushFileStoreFails(t *testing.T) { r := setupSyncTestRepo(t) // File with valid push but bad hash → storeReceivedFile returns error → ErrorCard badUUID := "cccccccccccccccccccccccccccccccccccccccc" req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: badUUID, Content: []byte("wrong hash content")}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) == 0 { t.Fatal("expected error card for bad hash") } } func TestHandleCFileCard(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("cfile content") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.CFileCard{UUID: uuid, Content: data, USize: len(data)}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) > 0 { t.Fatalf("unexpected error: %s", errs[0].Message) } _, ok := blob.Exists(r.DB(), uuid) if !ok { t.Fatal("cfile blob not stored") } } func TestHandleLoginAndPragma(t *testing.T) { r := setupSyncTestRepo(t) storeTestBlob(t, r, []byte("login pragma test")) // Pragma cards should be accepted (no login needed for pragma processing) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PragmaCard{Name: "client-version", Values: []string{"22800"}}, &xfer.PragmaCard{Name: "unknown-pragma", Values: []string{"ignored"}}, &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) > 0 { t.Fatalf("unexpected error: %s", errs[0].Message) } igots := findCards[*xfer.IGotCard](resp) if len(igots) == 0 { t.Fatal("expected igot cards after pragma+pull") } } func TestHandleCloneSeqNo(t *testing.T) { r := setupSyncTestRepo(t) // Store blobs, then clone with a seqno that skips them for i := range 3 { data := []byte(fmt.Sprintf("seqno test %d", i)) storeTestBlob(t, r, data) } // Get all blobs to find the max rid req1 := &xfer.Message{Cards: []xfer.Card{&xfer.CloneCard{Version: 1}}} resp1, _ := HandleSync(context.Background(), r, req1) files1 := findCards[*xfer.FileCard](resp1) // Now clone with seqno past all blobs — should get nothing req2 := &xfer.Message{Cards: []xfer.Card{ &xfer.CloneCard{Version: 1}, &xfer.CloneSeqNoCard{SeqNo: 9999}, }} resp2, err := HandleSync(context.Background(), r, req2) if err != nil { t.Fatalf("HandleSync: %v", err) } files2 := findCards[*xfer.FileCard](resp2) if len(files2) > 0 { t.Fatalf("expected no files with high seqno, got %d", len(files2)) } _ = files1 // used for context } func TestHandleEmptyRequest(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: nil} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } if len(resp.Cards) != 0 { t.Fatalf("expected empty response for empty request, got %d cards", len(resp.Cards)) } } func TestHandleReqConfigMissing(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.ReqConfigCard{Name: "nonexistent-config"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } configs := findCards[*xfer.ConfigCard](resp) if len(configs) > 0 { t.Fatal("should not return config for nonexistent key") } } // TestHandleSyncNoSpuriousGimmeForReceivedFile verifies that HandleSync // does NOT emit a GimmeCard for a blob that was delivered as a FileCard // in the same request. Regression test for the igot-before-file bug: // if IGotCard is processed before FileCard, blob.Exists returns false // and a spurious GimmeCard is emitted, causing infinite sync loops. func TestHandleSyncNoSpuriousGimmeForReceivedFile(t *testing.T) { r := setupSyncTestRepo(t) defer r.Close() // Create a blob to push to the handler. content := []byte("test content for spurious gimme check") uuid := hash.SHA1(content) // Build a request with BOTH IGotCard and FileCard for the same blob. // This mimics what a sync client sends when pushing a new blob: // it announces via igot AND delivers the file in the same round. req := &xfer.Message{ Cards: []xfer.Card{ &xfer.PushCard{ ServerCode: "test-server", ProjectCode: "test-project", }, &xfer.PullCard{ ServerCode: "test-server", ProjectCode: "test-project", }, &xfer.IGotCard{UUID: uuid}, &xfer.FileCard{UUID: uuid, Content: content}, }, } resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } // Check response for spurious gimme. for _, card := range resp.Cards { if g, ok := card.(*xfer.GimmeCard); ok && g.UUID == uuid { t.Errorf("HandleSync emitted GimmeCard for %s which was delivered as FileCard in the same request — this causes infinite sync loops", uuid[:12]) } } // Verify the blob was actually stored. if _, exists := blob.Exists(r.DB(), uuid); !exists { t.Errorf("blob %s was not stored by HandleSync", uuid[:12]) } // Server should NOT emit igot for this blob because the client // announced it via igot — remoteHas filtering suppresses it. for _, card := range resp.Cards { if ig, ok := card.(*xfer.IGotCard); ok && ig.UUID == uuid { t.Errorf("HandleSync should not emit IGotCard for %s — client already announced it via igot", uuid[:12]) } } } func TestHandlerEmitsPushCardOnClone(t *testing.T) { r := setupSyncTestRepo(t) var projectCode, serverCode string r.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projectCode) r.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&serverCode) req := &xfer.Message{Cards: []xfer.Card{ &xfer.CloneCard{Version: 1}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } pushCards := findCards[*xfer.PushCard](resp) if len(pushCards) != 1 { t.Fatalf("PushCard count = %d, want 1", len(pushCards)) } if pushCards[0].ProjectCode != projectCode { t.Errorf("ProjectCode = %q, want %q", pushCards[0].ProjectCode, projectCode) } if pushCards[0].ServerCode != serverCode { t.Errorf("ServerCode = %q, want %q", pushCards[0].ServerCode, serverCode) } } func TestHandlerNoPushCardOnPull(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } // Server must NOT emit PushCard on sync/pull — real Fossil treats // server-sent "push" as unknown command during sync. pushCards := findCards[*xfer.PushCard](resp) if len(pushCards) != 0 { t.Fatalf("PushCard count = %d, want 0 (push is clone-only)", len(pushCards)) } } // TestEmitIGots_OnlyUnclustered verifies that after clustering, emitIGots // returns only unclustered entries (not all blobs in the repo). func TestEmitIGots_AllBlobs(t *testing.T) { r := setupSyncTestRepo(t) // Store 200 blobs — above ClusterThreshold (100). for i := 0; i < 200; i++ { data := []byte(fmt.Sprintf("blob-%04d", i)) if _, _, err := blob.Store(r.DB(), data); err != nil { t.Fatalf("Store blob %d: %v", i, err) } } // Pre-cluster so we have known state before the handler runs. n, err := content.GenerateClusters(r.DB()) if err != nil { t.Fatalf("GenerateClusters: %v", err) } if n == 0 { t.Fatal("expected at least 1 cluster to be created") } // Send a pull request — handler emits igots for ALL non-phantom blobs. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, ) igots := cardsByType(resp, xfer.CardIGot) var totalBlobs int r.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&totalBlobs) if len(igots) != totalBlobs { t.Fatalf("igots = %d, total blobs = %d; emitIGots should send all blobs", len(igots), totalBlobs) } } // TestPragmaReqClusters verifies that pragma req-clusters causes the handler // to emit igot cards for cluster artifacts via sendAllClusters. func TestPragmaReqClusters(t *testing.T) { r := setupSyncTestRepo(t) // Store 200 blobs — above ClusterThreshold (100). for i := 0; i < 200; i++ { data := []byte(fmt.Sprintf("cluster-blob-%04d", i)) if _, _, err := blob.Store(r.DB(), data); err != nil { t.Fatalf("Store blob %d: %v", i, err) } } // Pre-cluster so we have known state. n, err := content.GenerateClusters(r.DB()) if err != nil { t.Fatalf("GenerateClusters: %v", err) } if n != 1 { t.Fatalf("clusters = %d, want 1", n) } resp, err := HandleSync(context.Background(), r, &xfer.Message{ Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "req-clusters"}, }, }) if err != nil { t.Fatalf("HandleSync: %v", err) } igots := cardsByType(resp, xfer.CardIGot) // emitIGots sends all blobs; sendAllClusters may add cluster igots // (deduplication happens client-side). Total should include all blobs. var totalBlobs int r.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&totalBlobs) if len(igots) < totalBlobs { t.Fatalf("igots = %d, total blobs = %d; should include at least all blobs", len(igots), totalBlobs) } } // TestPragmaReqClusters_OldClusters verifies that all blobs are advertised // even when the unclustered table is empty (all blobs have been clustered). func TestPragmaReqClusters_OldClusters(t *testing.T) { r := setupSyncTestRepo(t) // Store 200 blobs and cluster them. for i := 0; i < 200; i++ { data := []byte(fmt.Sprintf("old-cluster-blob-%04d", i)) if _, _, err := blob.Store(r.DB(), data); err != nil { t.Fatalf("Store blob %d: %v", i, err) } } n, err := content.GenerateClusters(r.DB()) if err != nil { t.Fatalf("GenerateClusters: %v", err) } if n != 1 { t.Fatalf("first pass clusters = %d, want 1", n) } // Manually remove everything from unclustered to simulate old clusters // that have been clustered in a future pass. if _, err := r.DB().Exec("DELETE FROM unclustered"); err != nil { t.Fatalf("clearing unclustered: %v", err) } resp, err := HandleSync(context.Background(), r, &xfer.Message{ Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "req-clusters"}, }, }) if err != nil { t.Fatalf("HandleSync: %v", err) } igots := cardsByType(resp, xfer.CardIGot) // emitIGots sends ALL blobs regardless of unclustered status. // sendAllClusters adds cluster igots (may duplicate). var totalBlobs int r.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&totalBlobs) if len(igots) < totalBlobs { t.Fatalf("igots = %d, total blobs = %d; should include all blobs", len(igots), totalBlobs) } } func TestHandlePushRequiresAuth(t *testing.T) { r := setupSyncTestRepo(t) // Delete nobody so anonymous push is rejected r.DB().Exec("DELETE FROM user WHERE login='nobody'") data := []byte("auth test") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: uuid, Content: data}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) == 0 { t.Fatal("expected error for unauthorized push") } if _, ok := blob.Exists(r.DB(), uuid); ok { t.Fatal("blob should not be stored without push capability") } } func TestHandlePullRequiresAuth(t *testing.T) { r := setupSyncTestRepo(t) storeTestBlob(t, r, []byte("pull auth test")) // Delete nobody so anonymous pull is rejected r.DB().Exec("DELETE FROM user WHERE login='nobody'") req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) == 0 { t.Fatal("expected error for unauthorized pull") } igots := findCards[*xfer.IGotCard](resp) if len(igots) > 0 { t.Fatal("should not emit igots without pull capability") } } func TestHandleAuthenticatedPush(t *testing.T) { r := setupSyncTestRepo(t) pc := testProjectCode(t, r.DB()) // Delete nobody, create a user with push caps r.DB().Exec("DELETE FROM user WHERE login='nobody'") auth.CreateUser(r.DB(), pc, "pusher", "secret", "oi") data := []byte("authed push") uuid := hash.SHA1(data) // Build a valid login card — nonce is SHA1 of the non-login card payload loginCard := buildTestLoginCard("pusher", "secret", pc, []byte("dummy")) req := &xfer.Message{Cards: []xfer.Card{ loginCard, &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: uuid, Content: data}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) > 0 { t.Fatalf("unexpected error: %s", errs[0].Message) } if _, ok := blob.Exists(r.DB(), uuid); !ok { t.Fatal("authenticated push should store blob") } } func TestHandleNobodyPullOnly(t *testing.T) { r := setupSyncTestRepo(t) storeTestBlob(t, r, []byte("nobody test")) // Set nobody to pull-only r.DB().Exec("UPDATE user SET cap='o' WHERE login='nobody'") // Pull should work pullReq := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }} pullResp, _ := HandleSync(context.Background(), r, pullReq) igots := findCards[*xfer.IGotCard](pullResp) if len(igots) == 0 { t.Fatal("nobody with 'o' cap should allow pull") } // Push should fail data := []byte("nobody push attempt") uuid := hash.SHA1(data) pushReq := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: uuid, Content: data}, }} pushResp, _ := HandleSync(context.Background(), r, pushReq) errs := findCards[*xfer.ErrorCard](pushResp) if len(errs) == 0 { t.Fatal("nobody with 'o' cap should reject push") } } func TestEmitIGots_ExcludesShunAndPrivate(t *testing.T) { r := setupSyncTestRepo(t) _, normalUUID, err := blob.Store(r.DB(), []byte("normal-blob-content")) if err != nil { t.Fatalf("Store normal: %v", err) } _, shunnedUUID, err := blob.Store(r.DB(), []byte("shunned-blob-content")) if err != nil { t.Fatalf("Store shunned: %v", err) } privRid, _, err := blob.Store(r.DB(), []byte("private-blob-content")) if err != nil { t.Fatalf("Store private: %v", err) } if _, err := r.DB().Exec("INSERT INTO shun(uuid, mtime) VALUES(?, 0)", shunnedUUID); err != nil { t.Fatalf("shun: %v", err) } if _, err := r.DB().Exec("INSERT INTO private(rid) VALUES(?)", privRid); err != nil { t.Fatalf("private: %v", err) } req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } igotUUIDs := make(map[string]bool) for _, c := range resp.Cards { if ig, ok := c.(*xfer.IGotCard); ok { igotUUIDs[ig.UUID] = true } } if !igotUUIDs[normalUUID] { t.Error("normal blob missing from igots") } if igotUUIDs[shunnedUUID] { t.Error("shunned blob appeared in igots") } var privUUID string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", privRid).Scan(&privUUID); err != nil { t.Fatalf("query privUUID: %v", err) } if igotUUIDs[privUUID] { t.Error("private blob appeared in igots") } } func TestHandlerPragmaSendPrivate_Accepted(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'") resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "send-private"}, ) for _, c := range resp.Cards { if e, ok := c.(*xfer.ErrorCard); ok { if e.Message == "not authorized to sync private content" { t.Error("should not get auth error with 'x' capability") } } } } func TestHandlerPragmaSendPrivate_Rejected(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'") resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "send-private"}, ) errors := findCards[*xfer.ErrorCard](resp) found := false for _, e := range errors { if e.Message == "not authorized to sync private content" { found = true } } if !found { t.Error("expected 'not authorized to sync private content' error") } } func TestHandlerPrivateCardAccepted(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'") data := []byte("private blob data") uuid := hash.SHA1(data) resp := handleReq(t, r, &xfer.PushCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PrivateCard{}, &xfer.FileCard{UUID: uuid, Content: data}, ) for _, c := range resp.Cards { if e, ok := c.(*xfer.ErrorCard); ok { t.Errorf("unexpected error: %s", e.Message) } } rid, ok := blob.Exists(r.DB(), uuid) if !ok { t.Fatal("blob not stored") } if !content.IsPrivate(r.DB(), int64(rid)) { t.Error("blob should be marked private") } } func TestHandlerPrivateCardRejected(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'") data := []byte("private blob rejected") uuid := hash.SHA1(data) resp := handleReq(t, r, &xfer.PushCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PrivateCard{}, &xfer.FileCard{UUID: uuid, Content: data}, ) errors := findCards[*xfer.ErrorCard](resp) found := false for _, e := range errors { if e.Message == "not authorized to sync private content" { found = true } } if !found { t.Error("expected 'not authorized to sync private content' error") } } func TestHandlerPublicFileClearsPrivate(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("was private now public") uuid := hash.SHA1(data) // Pre-store as private. storeReceivedFile(r, uuid, "", data, nil) rid, _ := blob.Exists(r.DB(), uuid) content.MakePrivate(r.DB(), int64(rid)) if !content.IsPrivate(r.DB(), int64(rid)) { t.Fatal("precondition: blob should be private") } // Push same blob WITHOUT private card — should clear private. resp := handleReq(t, r, &xfer.PushCard{ServerCode: "s", ProjectCode: "p"}, &xfer.FileCard{UUID: uuid, Content: data}, ) for _, c := range resp.Cards { if e, ok := c.(*xfer.ErrorCard); ok { t.Errorf("unexpected error: %s", e.Message) } } if content.IsPrivate(r.DB(), int64(rid)) { t.Error("blob should no longer be private after public file push") } } func TestEmitIGotsExcludesPrivate(t *testing.T) { r := setupSyncTestRepo(t) pubUUID := storeTestBlob(t, r, []byte("public blob")) privData := []byte("private blob for exclusion") privUUID := storeTestBlob(t, r, privData) privRid, _ := blob.Exists(r.DB(), privUUID) content.MakePrivate(r.DB(), int64(privRid)) // Pull without send-private pragma — private blob should be excluded. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, ) igotUUIDs := make(map[string]bool) for _, c := range resp.Cards { if ig, ok := c.(*xfer.IGotCard); ok { igotUUIDs[ig.UUID] = true } } if !igotUUIDs[pubUUID] { t.Error("public blob missing from igots") } if igotUUIDs[privUUID] { t.Error("private blob should be excluded from igots without send-private") } } func TestEmitIGotsIncludesPrivateWhenAuthorized(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'") pubUUID := storeTestBlob(t, r, []byte("public blob auth")) privData := []byte("private blob auth") privUUID := storeTestBlob(t, r, privData) privRid, _ := blob.Exists(r.DB(), privUUID) content.MakePrivate(r.DB(), int64(privRid)) // Pull WITH send-private pragma and x capability. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "send-private"}, ) pubFound := false privFound := false for _, c := range resp.Cards { if ig, ok := c.(*xfer.IGotCard); ok { if ig.UUID == pubUUID { pubFound = true } if ig.UUID == privUUID { if !ig.IsPrivate { t.Error("private blob igot should have IsPrivate=true") } privFound = true } } } if !pubFound { t.Error("public blob missing from igots") } if !privFound { t.Error("private blob should be included when send-private is authorized") } } func TestHandlerIGotPrivate_Authorized(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'") unknownUUID := "dddddddddddddddddddddddddddddddddddddd" resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "send-private"}, &xfer.IGotCard{UUID: unknownUUID, IsPrivate: true}, ) gimmes := findCards[*xfer.GimmeCard](resp) found := false for _, g := range gimmes { if g.UUID == unknownUUID { found = true } } if !found { t.Error("authorized private igot should produce gimme") } } func TestHandlerIGotPrivate_Unauthorized(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'") unknownUUID := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.IGotCard{UUID: unknownUUID, IsPrivate: true}, ) gimmes := findCards[*xfer.GimmeCard](resp) for _, g := range gimmes { if g.UUID == unknownUUID { t.Error("unauthorized private igot should NOT produce gimme") } } } func TestHandlerIGotDoesNotChangeServerPrivateStatus(t *testing.T) { r := setupSyncTestRepo(t) // Store a blob and mark it private. data := []byte("igot does not change server private status") uuid := storeTestBlob(t, r, data) rid, _ := blob.Exists(r.DB(), uuid) content.MakePrivate(r.DB(), int64(rid)) if !content.IsPrivate(r.DB(), int64(rid)) { t.Fatal("precondition: blob should be private") } // Client sends a public igot for the existing blob. // Server is authoritative — this should NOT change the server's private status. handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.IGotCard{UUID: uuid, IsPrivate: false}, ) if !content.IsPrivate(r.DB(), int64(rid)) { t.Error("server private status should not be changed by client igot") } } func TestHandleGimmePrivate_Authorized(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'") data := []byte("gimme private blob") uuid := storeTestBlob(t, r, data) rid, _ := blob.Exists(r.DB(), uuid) content.MakePrivate(r.DB(), int64(rid)) resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "send-private"}, &xfer.GimmeCard{UUID: uuid}, ) // Should get PrivateCard followed by FileCard. files := findCards[*xfer.FileCard](resp) found := false for _, f := range files { if f.UUID == uuid { found = true } } if !found { t.Error("authorized gimme for private blob should return file") } privCards := findCards[*xfer.PrivateCard](resp) if len(privCards) == 0 { t.Error("expected PrivateCard prefix before private file") } } func TestHandleGimmePrivate_Unauthorized(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'") data := []byte("gimme private unauthorized") uuid := storeTestBlob(t, r, data) rid, _ := blob.Exists(r.DB(), uuid) content.MakePrivate(r.DB(), int64(rid)) resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.GimmeCard{UUID: uuid}, ) files := findCards[*xfer.FileCard](resp) for _, f := range files { if f.UUID == uuid { t.Error("unauthorized gimme for private blob should NOT return file") } } } func TestEmitCloneBatchSkipsPrivate(t *testing.T) { r := setupSyncTestRepo(t) pubData := []byte("clone public blob") pubUUID := storeTestBlob(t, r, pubData) privData := []byte("clone private blob") privUUID := storeTestBlob(t, r, privData) privRid, _ := blob.Exists(r.DB(), privUUID) content.MakePrivate(r.DB(), int64(privRid)) resp := handleReq(t, r, &xfer.CloneCard{Version: 1}, ) files := findCards[*xfer.FileCard](resp) fileUUIDs := make(map[string]bool) for _, f := range files { fileUUIDs[f.UUID] = true } if !fileUUIDs[pubUUID] { t.Error("public blob missing from clone batch") } if fileUUIDs[privUUID] { t.Error("private blob should be excluded from clone batch without send-private") } } func TestEmitCloneBatchIncludesPrivateWhenAuthorized(t *testing.T) { r := setupSyncTestRepo(t) r.DB().Exec("UPDATE user SET cap='gx' WHERE login='nobody'") pubData := []byte("clone pub auth") pubUUID := storeTestBlob(t, r, pubData) privData := []byte("clone priv auth") privUUID := storeTestBlob(t, r, privData) privRid, _ := blob.Exists(r.DB(), privUUID) content.MakePrivate(r.DB(), int64(privRid)) resp := handleReq(t, r, &xfer.CloneCard{Version: 1}, &xfer.PragmaCard{Name: "send-private"}, ) files := findCards[*xfer.FileCard](resp) fileUUIDs := make(map[string]bool) for _, f := range files { fileUUIDs[f.UUID] = true } if !fileUUIDs[pubUUID] { t.Error("public blob missing from clone batch") } if !fileUUIDs[privUUID] { t.Error("private blob should be included when send-private authorized") } privCards := findCards[*xfer.PrivateCard](resp) if len(privCards) == 0 { t.Error("expected PrivateCard prefix for private blob in clone batch") } } func TestHandlerIGotFiltersEmit(t *testing.T) { r := setupSyncTestRepo(t) // Store 3 blobs with known UUIDs. data1 := []byte("igot-filter-blob-one") data2 := []byte("igot-filter-blob-two") data3 := []byte("igot-filter-blob-three") uuid1 := storeTestBlob(t, r, data1) uuid2 := storeTestBlob(t, r, data2) uuid3 := storeTestBlob(t, r, data3) // Client announces it already has blob 1 and blob 2. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.IGotCard{UUID: uuid1}, &xfer.IGotCard{UUID: uuid2}, ) igots := findCards[*xfer.IGotCard](resp) igotUUIDs := make(map[string]bool) for _, ig := range igots { igotUUIDs[ig.UUID] = true } // Server should NOT echo back blobs the client already has. if igotUUIDs[uuid1] { t.Errorf("server should not emit igot for uuid1 (%s) — client already has it", uuid1) } if igotUUIDs[uuid2] { t.Errorf("server should not emit igot for uuid2 (%s) — client already has it", uuid2) } // Server SHOULD emit igot for blob 3, which the client didn't announce. if !igotUUIDs[uuid3] { t.Errorf("server should emit igot for uuid3 (%s) — client doesn't have it", uuid3) } } func TestSendAllClustersExcludesPrivate(t *testing.T) { r := setupSyncTestRepo(t) // Store 200 blobs so clustering triggers. for i := 0; i < 200; i++ { data := []byte(fmt.Sprintf("cluster-priv-blob-%04d", i)) if _, _, err := blob.Store(r.DB(), data); err != nil { t.Fatalf("Store blob %d: %v", i, err) } } n, err := content.GenerateClusters(r.DB()) if err != nil { t.Fatalf("GenerateClusters: %v", err) } if n == 0 { t.Fatal("expected at least 1 cluster") } // Mark the cluster artifact itself as private. var clusterRid int err = r.DB().QueryRow(` SELECT tx.rid FROM tagxref tx WHERE tx.tagid = 7 LIMIT 1`, ).Scan(&clusterRid) if err != nil { t.Fatalf("find cluster rid: %v", err) } content.MakePrivate(r.DB(), int64(clusterRid)) resp, err := HandleSync(context.Background(), r, &xfer.Message{ Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "req-clusters"}, }, }) if err != nil { t.Fatalf("HandleSync: %v", err) } // The private cluster should not appear in igots. var clusterUUID string r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", clusterRid).Scan(&clusterUUID) for _, c := range resp.Cards { if ig, ok := c.(*xfer.IGotCard); ok && ig.UUID == clusterUUID { t.Error("private cluster blob should be excluded from sendAllClusters") } } } func TestHandleGimmeWithContentCache(t *testing.T) { r := setupSyncTestRepo(t) data := []byte("cached gimme blob") uuid := storeTestBlob(t, r, data) cache := content.NewCache(1 << 20) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, &xfer.GimmeCard{UUID: uuid}, }} resp, err := HandleSyncWithOpts(context.Background(), r, req, HandleOpts{ ContentCache: cache, }) if err != nil { t.Fatalf("HandleSyncWithOpts: %v", err) } files := findCards[*xfer.FileCard](resp) found := false for _, f := range files { if f.UUID == uuid && string(f.Content) == string(data) { found = true } } if !found { t.Fatal("expected file card with correct content via cached handler") } // Cache should have been populated. stats := cache.Stats() if stats.Misses == 0 { t.Fatal("expected at least 1 cache miss (the initial expand)") } // Second request for the same blob should hit cache. resp2, err := HandleSyncWithOpts(context.Background(), r, req, HandleOpts{ ContentCache: cache, }) if err != nil { t.Fatal(err) } files2 := findCards[*xfer.FileCard](resp2) found2 := false for _, f := range files2 { if f.UUID == uuid && string(f.Content) == string(data) { found2 = true } } if !found2 { t.Fatal("expected file card on second request (cache hit path)") } stats2 := cache.Stats() if stats2.Hits == 0 { t.Fatal("expected cache hit on second handler call") } } func TestSyncRoundTripWithContentCache(t *testing.T) { server := setupSyncTestRepo(t) client := setupSyncTestRepo(t) // Store a blob on the server. data := []byte("sync round trip cached data") serverUUID := storeTestBlob(t, server, data) cache := content.NewCache(1 << 20) transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, err := HandleSyncWithOpts(context.Background(), server, req, HandleOpts{ ContentCache: cache, }) if err != nil { t.Fatalf("HandleSyncWithOpts: %v", err) } return resp }, } result, err := Sync(context.Background(), client, transport, SyncOpts{ Pull: true, ContentCache: cache, }) if err != nil { t.Fatalf("Sync: %v", err) } if result.FilesRecvd == 0 { t.Fatal("expected to receive files") } // Verify client has the blob. rid, ok := blob.Exists(client.DB(), serverUUID) if !ok { t.Fatal("blob not found on client after sync") } got, err := content.Expand(client.DB(), rid) if err != nil { t.Fatal(err) } if string(got) != string(data) { t.Fatalf("got %q, want %q", got, data) } // Cache should have recorded activity. stats := cache.Stats() if stats.Hits+stats.Misses == 0 { t.Fatal("cache was never used during sync") } } func TestHandlerIGotFiltersPrivateEmit(t *testing.T) { r := setupSyncTestRepo(t) // Grant private sync capability. r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'") // Store a blob and mark it private. data := []byte("igot-filter-private-blob") uuid := storeTestBlob(t, r, data) rid, _ := blob.Exists(r.DB(), uuid) content.MakePrivate(r.DB(), int64(rid)) // Store a second private blob the client does NOT have. data2 := []byte("igot-filter-private-blob-two") uuid2 := storeTestBlob(t, r, data2) rid2, _ := blob.Exists(r.DB(), uuid2) content.MakePrivate(r.DB(), int64(rid2)) // Client announces it has the first private blob. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "send-private"}, &xfer.IGotCard{UUID: uuid, IsPrivate: true}, ) igots := findCards[*xfer.IGotCard](resp) for _, ig := range igots { if ig.UUID == uuid { t.Errorf("server should not emit private igot for %s — client already has it", uuid) } } // The second private blob should still appear. found := false for _, ig := range igots { if ig.UUID == uuid2 && ig.IsPrivate { found = true } } if !found { t.Errorf("server should emit private igot for %s — client doesn't have it", uuid2) } }