package main import ( "io" "net" "net/http" "os" "path/filepath" "testing" ) // withTempOpt points optDir at a fresh temp dir for a test and restores it after, // so the version-store helpers never touch the real /opt/lchat. func withTempOpt(t *testing.T) string { t.Helper() dir := t.TempDir() prev := optDir optDir = dir t.Cleanup(func() { optDir = prev }) return dir } // writeFakeBinary drops an executable file to stand in for a built binary. func writeFakeBinary(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatal(err) } } func TestValidVersion(t *testing.T) { ok := []string{"abc123", "v1.2.3", "test-scroll", "dev", "a_b.c-d"} bad := []string{"", ".", "..", "a/b", "with space", "semi;colon", "../etc"} for _, v := range ok { if !validVersion(v) { t.Errorf("validVersion(%q) = false, want true", v) } } for _, v := range bad { if validVersion(v) { t.Errorf("validVersion(%q) = true, want false", v) } } } func TestStageAndPointLive(t *testing.T) { withTempOpt(t) src := filepath.Join(t.TempDir(), "lchat-build") writeFakeBinary(t, src, "#!/bin/sh\necho hi\n") if err := stageVersion(src, "abc123"); err != nil { t.Fatalf("stageVersion: %v", err) } // The staged binary exists and is executable. info, err := os.Stat(versionBinPath("abc123")) if err != nil { t.Fatalf("staged binary missing: %v", err) } if info.Mode().Perm()&0o100 == 0 { t.Errorf("staged binary not executable: %v", info.Mode()) } // Pointing an instance's live link resolves to the staged binary and reports // the right version. if err := pointLive("test.example.com", "abc123"); err != nil { t.Fatalf("pointLive: %v", err) } got, err := liveVersion("test.example.com") if err != nil { t.Fatalf("liveVersion: %v", err) } if got != "abc123" { t.Errorf("liveVersion = %q, want abc123", got) } // The symlink actually points at the version binary. resolved, err := filepath.EvalSymlinks(liveLink("test.example.com")) if err != nil { t.Fatalf("EvalSymlinks: %v", err) } if want, _ := filepath.EvalSymlinks(versionBinPath("abc123")); resolved != want { t.Errorf("live link resolves to %q, want %q", resolved, want) } } func TestPointLiveUnstagedFails(t *testing.T) { withTempOpt(t) if err := pointLive("test.example.com", "nope"); err == nil { t.Error("pointLive to an unstaged version should fail, but succeeded") } } func TestPointLiveAtomicSwap(t *testing.T) { withTempOpt(t) src := filepath.Join(t.TempDir(), "lchat-build") writeFakeBinary(t, src, "bin") for _, v := range []string{"v1", "v2"} { if err := stageVersion(src, v); err != nil { t.Fatalf("stageVersion %s: %v", v, err) } } if err := pointLive("h", "v1"); err != nil { t.Fatal(err) } // Repointing to a new version replaces the link in place (no leftover .tmp). if err := pointLive("h", "v2"); err != nil { t.Fatal(err) } if got, _ := liveVersion("h"); got != "v2" { t.Errorf("after repoint liveVersion = %q, want v2", got) } if _, err := os.Lstat(liveLink("h") + ".tmp"); !os.IsNotExist(err) { t.Errorf("leftover temp symlink after swap: %v", err) } } // liveVersion on an instance that was never versioned returns ("", nil), not an // error — init-versions/deploy rely on that to detect an unmanaged instance. func TestLiveVersionMissing(t *testing.T) { withTempOpt(t) got, err := liveVersion("never.seen") if err != nil { t.Errorf("liveVersion(missing) err = %v, want nil", err) } if got != "" { t.Errorf("liveVersion(missing) = %q, want empty", got) } } // probeVersion reads the build a socket actually serves — the honest half of // `update status --probe` (docs/awake-instance-convergence.md). A tiny HTTP // server over a real unix socket stands in for a running instance. func TestProbeVersion(t *testing.T) { dir := t.TempDir() sock := filepath.Join(dir, "inst.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/version" { io.WriteString(w, `{"build":"abc1234","version":"deadbeef"}`) return } http.NotFound(w, r) })} go srv.Serve(ln) defer srv.Close() build, ok := probeVersion(sock) if !ok || build != "abc1234" { t.Fatalf("probeVersion served socket: build=%q ok=%v, want abc1234/true", build, ok) } // A socket that doesn't answer → ok=false (asleep / no instance), never a panic. if _, ok := probeVersion(filepath.Join(dir, "nope.sock")); ok { t.Fatal("probeVersion of a dead socket returned ok=true") } }