package store import ( "path/filepath" "testing" ) // seed inserts the local + remote rows a link's foreign keys require. // (links.local / links.remote reference their own tables, so a link cannot // exist on its own — the same reason ClaimFn upserts them before ReplaceLinks.) func seed(t *testing.T, st *Store, locals []string, remote string) { t.Helper() for _, n := range locals { if err := st.UpsertLocal(Local{Name: n, IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil { t.Fatal(err) } } if err := st.UpsertRemote(Remote{Name: remote, IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil { t.Fatal(err) } } // TestLinkByTripleSurvivesReplaceLinks pins the reason LinkByTriple exists. // // ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so sqlite // hands every row a FRESH autoincrement id. A Link captured before such a // write (e.g. one riding inside a ring token) therefore carries an id that // either matches a different forward or matches nothing. The natural key // (local, remote, remotePort) is what every caller actually identifies a // forward by, and it must survive those rewrites. func TestLinkByTripleSurvivesReplaceLinks(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() seed(t, st, []string{"alpha", "beta", "gamma"}, "srv") links := []Link{ {Local: "alpha", Remote: "srv", RemotePort: 100}, {Local: "beta", Remote: "srv", RemotePort: 200}, {Local: "gamma", Remote: "srv", RemotePort: 300}, } if err := st.ReplaceLinks(links); err != nil { t.Fatal(err) } // Capture the ids as the ring would have them. before := map[string]int64{} all, err := st.ListLinks() if err != nil { t.Fatal(err) } for _, l := range all { before[l.Local] = l.ID } if len(before) != 3 { t.Fatalf("expected 3 links, got %d", len(before)) } // Rewrite the table (this is what saveCanvas and ClaimFn both do). if err := st.ReplaceLinks(links); err != nil { t.Fatal(err) } after, err := st.ListLinks() if err != nil { t.Fatal(err) } if len(after) != 3 { t.Fatalf("expected 3 links after rewrite, got %d", len(after)) } // The natural key must still resolve to the right forward, with its // disabled flag and group intact. for _, l := range after { if l.Disabled { t.Errorf("link %s unexpectedly disabled after a plain rewrite", l.Local) } } got, found, err := st.LinkByTriple("beta", "srv", 200) if err != nil { t.Fatal(err) } if !found { t.Fatal("LinkByTriple failed to find beta after ReplaceLinks") } if got.Local != "beta" || got.RemotePort != 200 { t.Fatalf("LinkByTriple returned the wrong row: %+v", got) } } // TestLinkByTripleNotFoundIsNotError documents the contract callers rely on: // "no persisted opinion yet" is (Link{}, false, nil), not an error. A fresh // claim of a link with no row must be allowed to start. func TestLinkByTripleNotFoundIsNotError(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() got, found, err := st.LinkByTriple("nope", "srv", 1234) if err != nil { t.Fatalf("missing link must not be an error, got %v", err) } if found { t.Fatalf("missing link reported as found: %+v", got) } if got.Local != "" || got.RemotePort != 0 { t.Fatalf("expected zero Link on miss, got %+v", got) } } // TestLinkByTripleReadsDisabledFlag is the store-level half of the // "stopped forwards resurrect on restart" bug: the claim path asks the store // whether the user disabled this forward, so this lookup must return the flag // as persisted. func TestLinkByTripleReadsDisabledFlag(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() seed(t, st, []string{"mc"}, "srv") if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil { t.Fatal(err) } if err := st.SetLinkDisabled("mc", "srv", 25565, true); err != nil { t.Fatal(err) } ln, found, err := st.LinkByTriple("mc", "srv", 25565) if err != nil { t.Fatal(err) } if !found { t.Fatal("expected to find the link") } if !ln.Disabled { t.Fatal("expected Disabled=true to be visible through LinkByTriple") } if ln.Group != "game" { t.Fatalf("group should survive, got %q", ln.Group) } // ...and the user-facing start path must be able to clear it again. if err := st.SetLinkDisabled("mc", "srv", 25565, false); err != nil { t.Fatal(err) } if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled { t.Fatal("expected Disabled=false after clearing") } } // TestGetLinkByIDIsStaleAfterReplaceLinks documents WHY callers must not use // GetLink(id) with a previously captured id. It is not a fix — it is the trap // being pinned shut, so the hazard stays visible if someone reintroduces it. func TestGetLinkByIDIsStaleAfterReplaceLinks(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() seed(t, st, []string{"alpha", "beta", "gamma"}, "srv") if err := st.ReplaceLinks([]Link{ {Local: "alpha", Remote: "srv", RemotePort: 100}, {Local: "beta", Remote: "srv", RemotePort: 200}, }); err != nil { t.Fatal(err) } all, _ := st.ListLinks() var staleID int64 for _, l := range all { if l.Local == "alpha" { staleID = l.ID } } if err := st.ReplaceLinks([]Link{ {Local: "alpha", Remote: "srv", RemotePort: 100}, {Local: "beta", Remote: "srv", RemotePort: 200}, {Local: "gamma", Remote: "srv", RemotePort: 300}, }); err != nil { t.Fatal(err) } // The old id may still resolve, but to whatever row now occupies that // id — which is exactly the silent-mis-target hazard. Assert that the // natural key remains the only safe handle. if ln, ok := st.GetLink(staleID); ok && ln.Local != "alpha" { t.Logf("stale id %d now points at %q (hazard confirmed; use LinkByTriple)", staleID, ln.Local) } if got, found, _ := st.LinkByTriple("alpha", "srv", 100); !found || got.Local != "alpha" { t.Fatalf("natural key must stay reliable, got %+v found=%v", got, found) } } // TestReconcileLinkDisabledLearnsPeerDecision is the store half of // cluster-wide stop propagation. A node that did NOT serve the stop request has // no reason to know about it, and its links table is node-local — so the flag // arrives via the ring and lands here. The case that matters is the OWNER of a // forward on a different machine: before this existed, that node's copy still // read "enabled", so it kept (or re-spawned) the worker for a forward the user // had explicitly stopped. func TestReconcileLinkDisabledLearnsPeerDecision(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() seed(t, st, []string{"mc"}, "srv") if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil { t.Fatal(err) } // A peer's stop arrives. if err := st.ReconcileLinkDisabled("mc", "srv", 25565, true); err != nil { t.Fatal(err) } ln, found, err := st.LinkByTriple("mc", "srv", 25565) if err != nil { t.Fatal(err) } if !found { t.Fatal("link disappeared during reconcile") } if !ln.Disabled { t.Fatal("the peer's stop did not land in the local store") } if ln.Group != "game" { t.Fatalf("reconcile must not clobber other fields, group=%q", ln.Group) } // A peer's re-enable arrives. if err := st.ReconcileLinkDisabled("mc", "srv", 25565, false); err != nil { t.Fatal(err) } if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled { t.Fatal("the peer's re-enable did not land") } } // TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward: a node that has // never seen the forward still must remember that it is stopped, otherwise a // later claim on that node would resurrect it. func TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() seed(t, st, []string{"ghost"}, "srv") if err := st.ReconcileLinkDisabled("ghost", "srv", 9999, true); err != nil { t.Fatal(err) } ln, found, err := st.LinkByTriple("ghost", "srv", 9999) if err != nil { t.Fatal(err) } if !found { t.Fatal("a stopped-but-unknown forward must be remembered, or a later claim resurrects it") } if !ln.Disabled { t.Fatal("placeholder is not marked disabled") } } // TestReconcileLinkDisabledIgnoresEnableForUnknown: an enable for a forward this // node has never seen must NOT create a row. Creating one would invent forwards // out of ring state. func TestReconcileLinkDisabledIgnoresEnableForUnknown(t *testing.T) { st, err := New(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } defer st.Close() seed(t, st, []string{"other"}, "srv") if err := st.ReconcileLinkDisabled("unknown", "srv", 1234, false); err != nil { t.Fatal(err) } if _, found, _ := st.LinkByTriple("unknown", "srv", 1234); found { t.Fatal("an enable for an unknown forward must not materialise a row") } }