Files
multica/server/internal/daemon/identity_test.go
Bohan Jiang 4bd8533269 fix(daemon): machine-scoped daemon.id so CLI + desktop share one identity (#1263)
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.

Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.

Changes:

- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
  of the `profile` argument. The argument is retained for migration-only
  use (see promotion below).
- Migration path: when the canonical file is missing and the requested
  profile has a pre-change per-profile daemon.id, promote that UUID in
  place so a user who only ever ran under a named profile keeps the same
  identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
  returns every UUID that survives parsing. `config.go` now appends those
  to the daemon's `legacy_daemon_ids` payload, so any runtime rows
  previously registered under a per-profile UUID (on any backend) get
  merged into the canonical machine UUID at register time.

Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
2026-04-17 15:29:30 +08:00

243 lines
6.4 KiB
Go

package daemon
import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"github.com/google/uuid"
)
func TestEnsureDaemonID_Persists(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
first, err := EnsureDaemonID("")
if err != nil {
t.Fatalf("EnsureDaemonID first call: %v", err)
}
if _, err := uuid.Parse(first); err != nil {
t.Fatalf("EnsureDaemonID returned non-UUID: %q", first)
}
path := filepath.Join(home, ".multica", "daemon.id")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("daemon.id not written: %v", err)
}
if strings.TrimSpace(string(data)) != first {
t.Fatalf("file contents %q differ from returned UUID %q", data, first)
}
second, err := EnsureDaemonID("")
if err != nil {
t.Fatalf("EnsureDaemonID second call: %v", err)
}
if second != first {
t.Fatalf("UUID changed on second call: %q → %q", first, second)
}
}
func TestEnsureDaemonID_SharedAcrossProfiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
defaultID, err := EnsureDaemonID("")
if err != nil {
t.Fatalf("default profile: %v", err)
}
stagingID, err := EnsureDaemonID("staging")
if err != nil {
t.Fatalf("staging profile: %v", err)
}
if defaultID != stagingID {
t.Fatalf("profiles should share one machine id, got default=%s staging=%s", defaultID, stagingID)
}
// Profile-scoped file must not be created under the new layout — the
// only source of truth is ~/.multica/daemon.id.
profileFile := filepath.Join(home, ".multica", "profiles", "staging", "daemon.id")
if _, err := os.Stat(profileFile); !os.IsNotExist(err) {
t.Fatalf("profile-scoped daemon.id should not be created, stat err: %v", err)
}
}
func TestEnsureDaemonID_PromotesPreChangeProfileFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Seed a per-profile daemon.id the way pre-#1220 daemons laid it out.
legacyID := uuid.Must(uuid.NewV7()).String()
profileDir := filepath.Join(home, ".multica", "profiles", "staging")
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatalf("mkdir profile: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "daemon.id"), []byte(legacyID+"\n"), 0o600); err != nil {
t.Fatalf("seed legacy id: %v", err)
}
// First call on the post-change daemon with the matching profile must
// reuse the pre-change UUID so existing runtime rows continue to match
// without needing a merge round-trip.
got, err := EnsureDaemonID("staging")
if err != nil {
t.Fatalf("EnsureDaemonID: %v", err)
}
if got != legacyID {
t.Fatalf("expected promoted UUID %s, got %s", legacyID, got)
}
// The canonical file now holds that same UUID.
data, err := os.ReadFile(filepath.Join(home, ".multica", "daemon.id"))
if err != nil {
t.Fatalf("read canonical file: %v", err)
}
if strings.TrimSpace(string(data)) != legacyID {
t.Fatalf("canonical file %q != promoted %q", data, legacyID)
}
}
func TestEnsureDaemonID_RegeneratesCorruptFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, ".multica")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "daemon.id")
if err := os.WriteFile(path, []byte("not-a-uuid"), 0o600); err != nil {
t.Fatalf("seed corrupt file: %v", err)
}
id, err := EnsureDaemonID("")
if err != nil {
t.Fatalf("EnsureDaemonID: %v", err)
}
if _, err := uuid.Parse(id); err != nil {
t.Fatalf("expected valid UUID, got %q", id)
}
data, _ := os.ReadFile(path)
if strings.TrimSpace(string(data)) != id {
t.Fatalf("file not rewritten with new UUID")
}
}
func TestLegacyDaemonUUIDs_ScansProfileDirs(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
uuidA := uuid.Must(uuid.NewV7()).String()
uuidB := uuid.Must(uuid.NewV7()).String()
for name, id := range map[string]string{"prod": uuidA, "desktop-multica": uuidB} {
dir := filepath.Join(home, ".multica", "profiles", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(dir, "daemon.id"), []byte(id+"\n"), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
// A profile directory with a corrupt file must be skipped, not fail.
corruptDir := filepath.Join(home, ".multica", "profiles", "corrupt")
if err := os.MkdirAll(corruptDir, 0o755); err != nil {
t.Fatalf("mkdir corrupt: %v", err)
}
if err := os.WriteFile(filepath.Join(corruptDir, "daemon.id"), []byte("not-a-uuid"), 0o600); err != nil {
t.Fatalf("seed corrupt: %v", err)
}
got, err := LegacyDaemonUUIDs()
if err != nil {
t.Fatalf("LegacyDaemonUUIDs: %v", err)
}
sort.Strings(got)
want := []string{uuidA, uuidB}
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
t.Fatalf("LegacyDaemonUUIDs = %v, want %v", got, want)
}
}
func TestLegacyDaemonUUIDs_MissingProfilesDirIsNil(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
ids, err := LegacyDaemonUUIDs()
if err != nil {
t.Fatalf("LegacyDaemonUUIDs: %v", err)
}
if ids != nil {
t.Fatalf("expected nil on missing profiles dir, got %v", ids)
}
}
func TestLegacyDaemonIDs(t *testing.T) {
cases := []struct {
name string
hostname string
profile string
want []string
}{
{
name: "plain hostname, no profile",
hostname: "MacBook-Pro",
want: []string{"MacBook-Pro", "MacBook-Pro.local"},
},
{
name: "dot-local hostname, no profile",
hostname: "MacBook-Pro.local",
want: []string{"MacBook-Pro", "MacBook-Pro.local"},
},
{
name: "plain hostname with profile",
hostname: "MacBook-Pro",
profile: "staging",
want: []string{
"MacBook-Pro",
"MacBook-Pro.local",
"MacBook-Pro-staging",
"MacBook-Pro.local-staging",
},
},
{
name: "dot-local hostname with profile",
hostname: "MacBook-Pro.local",
profile: "staging",
want: []string{
"MacBook-Pro",
"MacBook-Pro.local",
"MacBook-Pro-staging",
"MacBook-Pro.local-staging",
},
},
{
name: "empty hostname",
hostname: "",
want: nil,
},
{
name: "mixed case hostname preserved as-is",
hostname: "Jiayuans-MacBook-Pro.local",
want: []string{
"Jiayuans-MacBook-Pro",
"Jiayuans-MacBook-Pro.local",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := LegacyDaemonIDs(tc.hostname, tc.profile)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("LegacyDaemonIDs(%q, %q) = %v, want %v", tc.hostname, tc.profile, got, tc.want)
}
})
}
}