| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "net" |
| 6 | "net/http" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // withTempOpt points optDir at a fresh temp dir for a test and restores it after, |
| 13 | // so the version-store helpers never touch the real /opt/lchat. |
| 14 | func withTempOpt(t *testing.T) string { |
| 15 | t.Helper() |
| 16 | dir := t.TempDir() |
| 17 | prev := optDir |
| 18 | optDir = dir |
| 19 | t.Cleanup(func() { optDir = prev }) |
| 20 | return dir |
| 21 | } |
| 22 | |
| 23 | // writeFakeBinary drops an executable file to stand in for a built binary. |
| 24 | func writeFakeBinary(t *testing.T, path, content string) { |
| 25 | t.Helper() |
| 26 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if err := os.WriteFile(path, []byte(content), 0o755); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func TestValidVersion(t *testing.T) { |
| 35 | ok := []string{"abc123", "v1.2.3", "test-scroll", "dev", "a_b.c-d"} |
| 36 | bad := []string{"", ".", "..", "a/b", "with space", "semi;colon", "../etc"} |
| 37 | for _, v := range ok { |
| 38 | if !validVersion(v) { |
| 39 | t.Errorf("validVersion(%q) = false, want true", v) |
| 40 | } |
| 41 | } |
| 42 | for _, v := range bad { |
| 43 | if validVersion(v) { |
| 44 | t.Errorf("validVersion(%q) = true, want false", v) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestStageAndPointLive(t *testing.T) { |
| 50 | withTempOpt(t) |
| 51 | src := filepath.Join(t.TempDir(), "lchat-build") |
| 52 | writeFakeBinary(t, src, "#!/bin/sh\necho hi\n") |
| 53 | |
| 54 | if err := stageVersion(src, "abc123"); err != nil { |
| 55 | t.Fatalf("stageVersion: %v", err) |
| 56 | } |
| 57 | // The staged binary exists and is executable. |
| 58 | info, err := os.Stat(versionBinPath("abc123")) |
| 59 | if err != nil { |
| 60 | t.Fatalf("staged binary missing: %v", err) |
| 61 | } |
| 62 | if info.Mode().Perm()&0o100 == 0 { |
| 63 | t.Errorf("staged binary not executable: %v", info.Mode()) |
| 64 | } |
| 65 | |
| 66 | // Pointing an instance's live link resolves to the staged binary and reports |
| 67 | // the right version. |
| 68 | if err := pointLive("test.example.com", "abc123"); err != nil { |
| 69 | t.Fatalf("pointLive: %v", err) |
| 70 | } |
| 71 | got, err := liveVersion("test.example.com") |
| 72 | if err != nil { |
| 73 | t.Fatalf("liveVersion: %v", err) |
| 74 | } |
| 75 | if got != "abc123" { |
| 76 | t.Errorf("liveVersion = %q, want abc123", got) |
| 77 | } |
| 78 | // The symlink actually points at the version binary. |
| 79 | resolved, err := filepath.EvalSymlinks(liveLink("test.example.com")) |
| 80 | if err != nil { |
| 81 | t.Fatalf("EvalSymlinks: %v", err) |
| 82 | } |
| 83 | if want, _ := filepath.EvalSymlinks(versionBinPath("abc123")); resolved != want { |
| 84 | t.Errorf("live link resolves to %q, want %q", resolved, want) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestPointLiveUnstagedFails(t *testing.T) { |
| 89 | withTempOpt(t) |
| 90 | if err := pointLive("test.example.com", "nope"); err == nil { |
| 91 | t.Error("pointLive to an unstaged version should fail, but succeeded") |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestPointLiveAtomicSwap(t *testing.T) { |
| 96 | withTempOpt(t) |
| 97 | src := filepath.Join(t.TempDir(), "lchat-build") |
| 98 | writeFakeBinary(t, src, "bin") |
| 99 | for _, v := range []string{"v1", "v2"} { |
| 100 | if err := stageVersion(src, v); err != nil { |
| 101 | t.Fatalf("stageVersion %s: %v", v, err) |
| 102 | } |
| 103 | } |
| 104 | if err := pointLive("h", "v1"); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | // Repointing to a new version replaces the link in place (no leftover .tmp). |
| 108 | if err := pointLive("h", "v2"); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if got, _ := liveVersion("h"); got != "v2" { |
| 112 | t.Errorf("after repoint liveVersion = %q, want v2", got) |
| 113 | } |
| 114 | if _, err := os.Lstat(liveLink("h") + ".tmp"); !os.IsNotExist(err) { |
| 115 | t.Errorf("leftover temp symlink after swap: %v", err) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // liveVersion on an instance that was never versioned returns ("", nil), not an |
| 120 | // error — init-versions/deploy rely on that to detect an unmanaged instance. |
| 121 | func TestLiveVersionMissing(t *testing.T) { |
| 122 | withTempOpt(t) |
| 123 | got, err := liveVersion("never.seen") |
| 124 | if err != nil { |
| 125 | t.Errorf("liveVersion(missing) err = %v, want nil", err) |
| 126 | } |
| 127 | if got != "" { |
| 128 | t.Errorf("liveVersion(missing) = %q, want empty", got) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // probeVersion reads the build a socket actually serves — the honest half of |
| 133 | // `update status --probe` (docs/awake-instance-convergence.md). A tiny HTTP |
| 134 | // server over a real unix socket stands in for a running instance. |
| 135 | func TestProbeVersion(t *testing.T) { |
| 136 | dir := t.TempDir() |
| 137 | sock := filepath.Join(dir, "inst.sock") |
| 138 | ln, err := net.Listen("unix", sock) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 143 | if r.URL.Path == "/api/version" { |
| 144 | io.WriteString(w, `{"build":"abc1234","version":"deadbeef"}`) |
| 145 | return |
| 146 | } |
| 147 | http.NotFound(w, r) |
| 148 | })} |
| 149 | go srv.Serve(ln) |
| 150 | defer srv.Close() |
| 151 | |
| 152 | build, ok := probeVersion(sock) |
| 153 | if !ok || build != "abc1234" { |
| 154 | t.Fatalf("probeVersion served socket: build=%q ok=%v, want abc1234/true", build, ok) |
| 155 | } |
| 156 | |
| 157 | // A socket that doesn't answer → ok=false (asleep / no instance), never a panic. |
| 158 | if _, ok := probeVersion(filepath.Join(dir, "nope.sock")); ok { |
| 159 | t.Fatal("probeVersion of a dead socket returned ok=true") |
| 160 | } |
| 161 | } |
| 162 | |