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) } }