package instance import ( "encoding/json" "errors" "os" "path/filepath" "sync/atomic" "testing" "time" "gitea.apointless.space/bsncubed/ipswap/internal/config" ) func testPaths(t *testing.T) config.Paths { t.Helper() dir := t.TempDir() return config.Paths{Dir: dir, Presets: filepath.Join(dir, "presets.json")} } func TestSignalWithNoPrimary(t *testing.T) { paths := testPaths(t) if err := Signal(paths); !errors.Is(err, ErrNoPrimary) { t.Errorf("Signal with no session file = %v, want ErrNoPrimary", err) } if IsRunning(paths) { t.Error("IsRunning should be false with no session file") } } func TestAcquireThenSignalOpensEditor(t *testing.T) { paths := testPaths(t) var opened atomic.Int32 p, err := Acquire(paths, func() { opened.Add(1) }) if err != nil { t.Fatal(err) } defer p.Release() if !IsRunning(paths) { t.Fatal("IsRunning should be true once a primary is up") } if err := Signal(paths); err != nil { t.Fatalf("Signal returned %v", err) } // The handler answers before running the callback, so give it a moment. deadline := time.Now().Add(2 * time.Second) for opened.Load() == 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if opened.Load() != 1 { t.Errorf("expected the open callback to fire once, got %d", opened.Load()) } } func TestSessionFileIsPrivateAndWellFormed(t *testing.T) { paths := testPaths(t) p, err := Acquire(paths, nil) if err != nil { t.Fatal(err) } defer p.Release() path := filepath.Join(paths.Dir, "session.json") info, err := os.Stat(path) if err != nil { t.Fatal(err) } // The token in here authorises control calls, so it must not be readable // by other users on a shared machine. if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("session file mode = %o, want 600", perm) } b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var s session if err := json.Unmarshal(b, &s); err != nil { t.Fatal(err) } if s.Port == 0 || s.Token == "" || s.PID != os.Getpid() { t.Errorf("session file looks wrong: %+v", s) } } func TestReleaseRemovesSessionFile(t *testing.T) { paths := testPaths(t) p, err := Acquire(paths, nil) if err != nil { t.Fatal(err) } p.Release() if _, err := os.Stat(filepath.Join(paths.Dir, "session.json")); !os.IsNotExist(err) { t.Error("Release should remove the session file") } if IsRunning(paths) { t.Error("IsRunning should be false after Release") } } // A crash leaves a session file pointing at a dead port. Startup must treat // that as "no primary" rather than hanging or refusing to start. func TestStaleSessionFileIsIgnored(t *testing.T) { paths := testPaths(t) stale, _ := json.Marshal(session{Port: 1, Token: "dead", PID: 999999}) if err := os.WriteFile(filepath.Join(paths.Dir, "session.json"), stale, 0o600); err != nil { t.Fatal(err) } start := time.Now() if IsRunning(paths) { t.Error("a stale session file must not look like a running instance") } if err := Signal(paths); !errors.Is(err, ErrNoPrimary) { t.Errorf("Signal against a stale file = %v, want ErrNoPrimary", err) } // Startup waits on this, so it must fail fast rather than block. if elapsed := time.Since(start); elapsed > 5*time.Second { t.Errorf("stale session detection took %s, too slow for startup", elapsed) } } func TestGarbageSessionFileIsIgnored(t *testing.T) { paths := testPaths(t) if err := os.WriteFile(filepath.Join(paths.Dir, "session.json"), []byte("{not json"), 0o600); err != nil { t.Fatal(err) } if IsRunning(paths) { t.Error("a corrupt session file must not look like a running instance") } } // The control channel must not answer without the token. func TestControlRequiresToken(t *testing.T) { paths := testPaths(t) var opened atomic.Int32 p, err := Acquire(paths, func() { opened.Add(1) }) if err != nil { t.Fatal(err) } defer p.Release() s, err := readSession(filepath.Join(paths.Dir, "session.json")) if err != nil { t.Fatal(err) } wrong := session{Port: s.Port, Token: "not-the-token"} if err := call(wrong, "/open"); !errors.Is(err, ErrNoPrimary) { t.Errorf("call with a bad token = %v, want it rejected", err) } time.Sleep(100 * time.Millisecond) if opened.Load() != 0 { t.Error("an unauthorised control call must not open the editor") } }