Artifact 75742b13a6f599fc04c8f0aef65ddbb93acbbc8cc7eda7d233c16ed38d829100:
- File
hub/cross_leaf_test.go
— part of check-in
[001f82d3d2]
at
2026-05-12 20:59:53
on branch feat/cli-project-code-and-seed-from-upstream
— feat(cli): expose hub --project-code and --seed-from-upstream flags
The hub.Config.ProjectCode and hub.Config.SeedFromUpstream fields landed in #157 but were unreachable from the `edgesync hub serve` CLI. Wires them through and fixes a latent auth bug in seedFromUpstream that prevented it from cloning against the standard nobody-caps=gio deployment.
Changes: * cli/hub_serve.go — adds --project-code (40-char lowercase hex, format pre-validated for a friendlier error than libfossil's) and --seed-from-upstream (http(s) URL of upstream hub xfer endpoint). * hub/clone.go — drop bootstrapUser from the clone CloneOpts. Passing a local-creation user as the upstream-auth identity caused libfossil's clone to send a login card for a user the upstream had never heard of ("authentication failed"). Anonymous clone falls through to the upstream's "nobody" caps, matching the typical NobodyCaps:"gio" deploy. * hub/cross_leaf_test.go — adds TestCrossLeaf_SeedFromUpstream_PropagatesCommit covering the seed-then-sync path end-to-end (clone over HTTP, leaf-link, observe A→B commit propagation via NATS). (user: 41898282+github-actions[bot]@users.noreply.github.com, size: 7814) [annotate] [blame] [check-ins using] [more...]
package hub import ( "bytes" "context" "path/filepath" "testing" "time" _ "github.com/danmestas/libfossil/db/driver/modernc" ) // sharedProjectCode is a fixed 40-char lowercase hex string used as the // project-code across the two hubs in cross-leaf tests. Format matches // libfossil's validation (^[0-9a-f]{40}$). const sharedProjectCode = "1111111111111111111111111111111111111111" // TestCrossLeaf_SharedProjectCode_PropagatesCommit asserts the headline // fix for issue #156: two hubs with a shared project-code, linked over a // NATS leafnode connection, see each other's commits via the // .commit → pull-on-notify flow. func TestCrossLeaf_SharedProjectCode_PropagatesCommit(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() dirA := t.TempDir() hubA, err := NewHub(ctx, Config{ RepoPath: filepath.Join(dirA, "hubA.fossil"), ProjectCode: sharedProjectCode, NobodyCaps: "gio", // allow unauthenticated xfer }) if err != nil { t.Fatalf("NewHub A: %v", err) } t.Cleanup(func() { _ = hubA.Stop() }) httpCtxA, httpCancelA := context.WithCancel(context.Background()) t.Cleanup(httpCancelA) go func() { _ = hubA.ServeHTTP(httpCtxA) }() dirB := t.TempDir() hubB, err := NewHub(ctx, Config{ RepoPath: filepath.Join(dirB, "hubB.fossil"), ProjectCode: sharedProjectCode, LeafUpstream: hubA.LeafURL(), NobodyCaps: "gio", }) if err != nil { t.Fatalf("NewHub B: %v", err) } t.Cleanup(func() { _ = hubB.Stop() }) httpCtxB, httpCancelB := context.WithCancel(context.Background()) t.Cleanup(httpCancelB) go func() { _ = hubB.ServeHTTP(httpCtxB) }() waitFor(t, 5*time.Second, "leaf link A↔B", func() bool { return hubA.NumLeafs() >= 1 }) // Commit on A, expect B to see the file via pull-on-notify. if _, err := hubA.Commit(ctx, CommitOpts{ Files: []FileToCommit{{Name: "from-a.txt", Content: []byte("hello-from-A")}}, Message: "commit on A", Author: "hub", }); err != nil { t.Fatalf("hubA.Commit: %v", err) } waitFor(t, 10*time.Second, "B sees from-a.txt at trunk", func() bool { got, readErr := hubB.Read(ctx, "from-a.txt") return readErr == nil && bytes.Equal(got, []byte("hello-from-A")) }) // Reverse direction: commit on B, expect A to see it. if _, err := hubB.Commit(ctx, CommitOpts{ Files: []FileToCommit{{Name: "from-b.txt", Content: []byte("hello-from-B")}}, Message: "commit on B", Author: "hub", }); err != nil { t.Fatalf("hubB.Commit: %v", err) } waitFor(t, 10*time.Second, "A sees from-b.txt at trunk", func() bool { got, readErr := hubA.Read(ctx, "from-b.txt") return readErr == nil && bytes.Equal(got, []byte("hello-from-B")) }) } // TestCrossLeaf_SeedFromUpstream_PropagatesCommit covers the other half // of the issue #156 fix: hub B bootstraps via SeedFromUpstream pointing at // hub A's HTTP xfer endpoint, inheriting A's project-code through the // clone. Once leaf-linked, A's commits should propagate to B over NATS // sync, same as the shared-ProjectCode path. func TestCrossLeaf_SeedFromUpstream_PropagatesCommit(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() dirA := t.TempDir() hubA, err := NewHub(ctx, Config{ RepoPath: filepath.Join(dirA, "hubA.fossil"), ProjectCode: sharedProjectCode, NobodyCaps: "gio", // allow unauthenticated clone }) if err != nil { t.Fatalf("NewHub A: %v", err) } t.Cleanup(func() { _ = hubA.Stop() }) httpCtxA, httpCancelA := context.WithCancel(context.Background()) t.Cleanup(httpCancelA) go func() { _ = hubA.ServeHTTP(httpCtxA) }() // Bootstrap B by cloning from A's HTTP xfer endpoint. Deliberately // omit ProjectCode on B — the whole point of SeedFromUpstream is that // the project-code rides in via the clone. dirB := t.TempDir() hubB, err := NewHub(ctx, Config{ RepoPath: filepath.Join(dirB, "hubB.fossil"), SeedFromUpstream: "http://" + hubA.HTTPAddr() + "/", LeafUpstream: hubA.LeafURL(), NobodyCaps: "gio", }) if err != nil { t.Fatalf("NewHub B: %v", err) } t.Cleanup(func() { _ = hubB.Stop() }) if hubB.FossilSyncSubject() != hubA.FossilSyncSubject() { t.Fatalf("after seed, sync subjects diverge: A=%q B=%q", hubA.FossilSyncSubject(), hubB.FossilSyncSubject()) } httpCtxB, httpCancelB := context.WithCancel(context.Background()) t.Cleanup(httpCancelB) go func() { _ = hubB.ServeHTTP(httpCtxB) }() waitFor(t, 5*time.Second, "leaf link A↔B after seed", func() bool { return hubA.NumLeafs() >= 1 }) if _, err := hubA.Commit(ctx, CommitOpts{ Files: []FileToCommit{{Name: "seeded.txt", Content: []byte("seeded-then-synced")}}, Message: "commit on A after seed", Author: "hub", }); err != nil { t.Fatalf("hubA.Commit: %v", err) } waitFor(t, 10*time.Second, "B sees seeded.txt at trunk", func() bool { got, readErr := hubB.Read(ctx, "seeded.txt") return readErr == nil && bytes.Equal(got, []byte("seeded-then-synced")) }) } // TestCrossLeaf_SubjectsAlign asserts that two hubs declaring the same // ProjectCode end up subscribed to the same .sync and .commit subjects. // Cheap structural check separate from the propagation gold-path. func TestCrossLeaf_SubjectsAlign(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() dirA := t.TempDir() hubA, err := NewHub(ctx, Config{ RepoPath: filepath.Join(dirA, "hubA.fossil"), ProjectCode: sharedProjectCode, }) if err != nil { t.Fatalf("NewHub A: %v", err) } t.Cleanup(func() { _ = hubA.Stop() }) dirB := t.TempDir() hubB, err := NewHub(ctx, Config{ RepoPath: filepath.Join(dirB, "hubB.fossil"), ProjectCode: sharedProjectCode, }) if err != nil { t.Fatalf("NewHub B: %v", err) } t.Cleanup(func() { _ = hubB.Stop() }) if hubA.FossilSyncSubject() != hubB.FossilSyncSubject() { t.Errorf("sync subjects diverge: A=%q B=%q", hubA.FossilSyncSubject(), hubB.FossilSyncSubject()) } if hubA.FossilCommitSubject() != hubB.FossilCommitSubject() { t.Errorf("commit subjects diverge: A=%q B=%q", hubA.FossilCommitSubject(), hubB.FossilCommitSubject()) } wantSync := "fossil." + sharedProjectCode + ".sync" wantCommit := "fossil." + sharedProjectCode + ".commit" if hubA.FossilSyncSubject() != wantSync { t.Errorf("sync subject = %q, want %q", hubA.FossilSyncSubject(), wantSync) } if hubA.FossilCommitSubject() != wantCommit { t.Errorf("commit subject = %q, want %q", hubA.FossilCommitSubject(), wantCommit) } } // TestNewHub_ProjectCodeMismatchOnExistingRepo asserts the drift-guard: // reopening a repo with a different ProjectCode than what's on disk // fails fast. func TestNewHub_ProjectCodeMismatchOnExistingRepo(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() dir := t.TempDir() path := filepath.Join(dir, "hub.fossil") h, err := NewHub(ctx, Config{ RepoPath: path, ProjectCode: sharedProjectCode, }) if err != nil { t.Fatalf("first NewHub: %v", err) } if err := h.Stop(); err != nil { t.Fatalf("Stop: %v", err) } const otherCode = "2222222222222222222222222222222222222222" if _, err := NewHub(ctx, Config{ RepoPath: path, ProjectCode: otherCode, }); err == nil { t.Fatal("NewHub with mismatched ProjectCode returned nil err; want config-drift error") } } // waitFor polls cond every 50ms until it returns true or timeout fires. // Reports the description on timeout for diagnostic context. func waitFor(t *testing.T, timeout time.Duration, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if cond() { return } time.Sleep(50 * time.Millisecond) } t.Fatalf("timeout after %v waiting for: %s", timeout, what) }