diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c811d6a..2ed01b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,4 +25,13 @@ jobs: run: go build ./... - name: Test - run: go test ./... + run: go test -coverprofile=coverage.out -coverpkg=./... ./... + + - name: Coverage report + run: go tool cover -func=coverage.out + + - name: Upload coverage profile + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out diff --git a/internal/core/git_test.go b/internal/core/git_test.go new file mode 100644 index 0000000..24f27a1 --- /dev/null +++ b/internal/core/git_test.go @@ -0,0 +1,39 @@ +package core_test + +import ( + "testing" + + "github.com/aissat/sysfig/internal/core" +) + +// TestSanitizeBranchName verifies that dot-prefixed path components are +// replaced with "dot-" so Git accepts them as ref names. +func TestSanitizeBranchName(t *testing.T) { + tests := []struct { + input string + want string + }{ + // Plain paths — must be unchanged. + {"etc/nginx/nginx.conf", "etc/nginx/nginx.conf"}, + {"home/user/config", "home/user/config"}, + // Dot-prefixed file at the end. + {"home/alice/.zshrc", "home/alice/dot-zshrc"}, + // Dot-prefixed directory component. + {"home/aye7/.config/nvim/init.lua", "home/aye7/dot-config/nvim/init.lua"}, + // Multiple dot-prefixed components. + {"home/aye7/.config/.nvim", "home/aye7/dot-config/dot-nvim"}, + // Dot-prefixed first component (unusual but possible with a custom SysRoot). + {".bashrc", "dot-bashrc"}, + // No dots at all. + {"etc/hosts", "etc/hosts"}, + // Component that starts with "dot-" already — must not be double-escaped. + {"home/user/dot-zshrc", "home/user/dot-zshrc"}, + } + + for _, tc := range tests { + got := core.SanitizeBranchName(tc.input) + if got != tc.want { + t.Errorf("SanitizeBranchName(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} diff --git a/internal/core/platform_test.go b/internal/core/platform_test.go new file mode 100644 index 0000000..f9de1b6 --- /dev/null +++ b/internal/core/platform_test.go @@ -0,0 +1,45 @@ +package core_test + +import ( + "runtime" + "testing" + + "github.com/aissat/sysfig/internal/core" +) + +// TestDetectPlatformTags_ContainsGOOS verifies that the result always contains +// at least one tag and that the first tag matches the current runtime.GOOS. +func TestDetectPlatformTags_ContainsGOOS(t *testing.T) { + tags := core.DetectPlatformTags() + if len(tags) == 0 { + t.Fatal("DetectPlatformTags must return at least one tag") + } + if tags[0] != runtime.GOOS { + t.Errorf("first tag = %q, want %q (runtime.GOOS)", tags[0], runtime.GOOS) + } +} + +// TestDetectPlatformTags_NonEmpty verifies that no returned tag is an empty string. +func TestDetectPlatformTags_NonEmpty(t *testing.T) { + tags := core.DetectPlatformTags() + for i, tag := range tags { + if tag == "" { + t.Errorf("tag[%d] is an empty string — all tags must be non-empty", i) + } + } +} + +// TestDetectPlatformTags_LinuxHasDistro verifies that on Linux we get at least +// two tags (OS family + distro) when /etc/os-release is readable. +// This test is skipped on non-Linux platforms. +func TestDetectPlatformTags_LinuxHasDistro(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("distro tag detection only applies to Linux") + } + tags := core.DetectPlatformTags() + // On Linux we expect at least ["linux", ""]. + // If /etc/os-release is missing (unusual) we still get ["linux"]. + if len(tags) < 1 { + t.Errorf("expected at least 1 tag on Linux, got %v", tags) + } +} diff --git a/internal/core/snap_test.go b/internal/core/snap_test.go new file mode 100644 index 0000000..845a962 --- /dev/null +++ b/internal/core/snap_test.go @@ -0,0 +1,531 @@ +package core_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/aissat/sysfig/internal/core" + "github.com/aissat/sysfig/pkg/types" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// buildSnapFixture creates a sysfig base directory with a state.json that +// has one tracked file. The tracked file content is written to sysRoot. +// Returns (baseDir, sysRoot, fileID, sysPath, fileContent). +func buildSnapFixture(t *testing.T) (baseDir, sysRoot, fileID, sysPath string, content []byte) { + t.Helper() + baseDir = t.TempDir() + sysRoot = t.TempDir() + + sysPath = "/etc/myapp.conf" + content = []byte("key=value\n") + fileID = core.DeriveID(sysPath) + + // Write the tracked file to sysRoot. + fullSysPath := filepath.Join(sysRoot, sysPath) + require.NoError(t, os.MkdirAll(filepath.Dir(fullSysPath), 0o755)) + require.NoError(t, os.WriteFile(fullSysPath, content, 0o644)) + + // Write state.json. + now := time.Now() + s := &types.State{ + Version: 1, + Files: map[string]*types.FileRecord{ + fileID: { + ID: fileID, + SystemPath: sysPath, + RepoPath: "etc/myapp.conf", + CurrentHash: "aabbccdd", + LastSync: &now, + Status: types.StatusTracked, + }, + }, + Backups: map[string][]types.BackupRecord{}, + } + data, err := json.MarshalIndent(s, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(baseDir, "state.json"), data, 0o600)) + + return baseDir, sysRoot, fileID, sysPath, content +} + +// --------------------------------------------------------------------------- +// SnapDir +// --------------------------------------------------------------------------- + +func TestSnapDir(t *testing.T) { + got := core.SnapDir("/home/user/.sysfig") + assert.Equal(t, "/home/user/.sysfig/snaps", got) +} + +// --------------------------------------------------------------------------- +// SnapList — empty +// --------------------------------------------------------------------------- + +func TestSnapList_Empty(t *testing.T) { + baseDir := t.TempDir() + snaps, err := core.SnapList(baseDir) + require.NoError(t, err) + assert.Empty(t, snaps, "no snaps dir should return empty list") +} + +// --------------------------------------------------------------------------- +// SnapTake +// --------------------------------------------------------------------------- + +func TestSnapTake_Basic(t *testing.T) { + baseDir, sysRoot, fileID, sysPath, content := buildSnapFixture(t) + + result, err := core.SnapTake(core.SnapTakeOptions{ + BaseDir: baseDir, + SysRoot: sysRoot, + Label: "pre-update", + }) + require.NoError(t, err) + require.NotNil(t, result) + + assert.NotEmpty(t, result.ID) + assert.Equal(t, "pre-update", result.Label) + assert.Len(t, result.Files, 1) + assert.Equal(t, fileID, result.Files[0].ID) + assert.Equal(t, sysPath, result.Files[0].SystemPath) + assert.NotEmpty(t, result.ShortID) + + // Verify snap directory was created. + snapPath := filepath.Join(core.SnapDir(baseDir), result.ID) + _, err = os.Stat(snapPath) + require.NoError(t, err, "snap directory must exist") + + // Verify snap.json was written. + manifestPath := filepath.Join(snapPath, "snap.json") + _, err = os.Stat(manifestPath) + require.NoError(t, err, "snap.json must exist") + + // Verify the file was captured correctly. + capturedPath := filepath.Join(snapPath, "files", "etc/myapp.conf") + capturedContent, err := os.ReadFile(capturedPath) + require.NoError(t, err) + assert.Equal(t, content, capturedContent) +} + +func TestSnapTake_NoTrackedFiles(t *testing.T) { + baseDir := t.TempDir() + // Write empty state.json. + s := &types.State{ + Version: 1, + Files: map[string]*types.FileRecord{}, + Backups: map[string][]types.BackupRecord{}, + } + data, _ := json.MarshalIndent(s, "", " ") + _ = os.WriteFile(filepath.Join(baseDir, "state.json"), data, 0o600) + + _, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no tracked files") +} + +func TestSnapTake_LabelSlugifiedInID(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + result, err := core.SnapTake(core.SnapTakeOptions{ + BaseDir: baseDir, + SysRoot: sysRoot, + Label: "my label/test", + }) + require.NoError(t, err) + // Spaces and slashes should be replaced with dashes in the ID. + assert.Contains(t, result.ID, "my-label-test") +} + +func TestSnapTake_MissingSystemFile(t *testing.T) { + // File is tracked in state.json but absent from disk → snap captures + // the record with the stored hash rather than erroring. + baseDir := t.TempDir() + sysRoot := t.TempDir() // intentionally empty + sysPath := "/etc/absent.conf" + fileID := core.DeriveID(sysPath) + now := time.Now() + s := &types.State{ + Version: 1, + Files: map[string]*types.FileRecord{ + fileID: { + ID: fileID, + SystemPath: sysPath, + RepoPath: "etc/absent.conf", + CurrentHash: "stored-hash", + LastSync: &now, + Status: types.StatusTracked, + }, + }, + Backups: map[string][]types.BackupRecord{}, + } + data, _ := json.MarshalIndent(s, "", " ") + _ = os.WriteFile(filepath.Join(baseDir, "state.json"), data, 0o600) + + result, err := core.SnapTake(core.SnapTakeOptions{ + BaseDir: baseDir, + SysRoot: sysRoot, + }) + require.NoError(t, err) + require.Len(t, result.Files, 1) + // Hash falls back to the stored hash when the file is missing. + assert.Equal(t, "stored-hash", result.Files[0].Hash) +} + +// --------------------------------------------------------------------------- +// SnapList +// --------------------------------------------------------------------------- + +func TestSnapList_ReturnsSortedNewestFirst(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + // Take two snaps with a short pause to ensure distinct IDs. + snap1, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot, Label: "first"}) + require.NoError(t, err) + snap2, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot, Label: "second"}) + require.NoError(t, err) + + snaps, err := core.SnapList(baseDir) + require.NoError(t, err) + require.Len(t, snaps, 2) + + // Newest first — snap2 was taken after snap1. + if snaps[0].CreatedAt.Before(snaps[1].CreatedAt) { + t.Errorf("snaps not sorted newest-first: %v before %v", snaps[0].ID, snaps[1].ID) + } + + // Both snaps must have ShortIDs set. + assert.NotEmpty(t, snaps[0].ShortID) + assert.NotEmpty(t, snaps[1].ShortID) + + _ = snap1 + _ = snap2 +} + +// --------------------------------------------------------------------------- +// SnapResolveID +// --------------------------------------------------------------------------- + +func TestSnapResolveID_ExactMatch(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + resolved, err := core.SnapResolveID(baseDir, snap.ID) + require.NoError(t, err) + assert.Equal(t, snap.ID, resolved) +} + +func TestSnapResolveID_ShortHashMatch(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + resolved, err := core.SnapResolveID(baseDir, snap.ShortID) + require.NoError(t, err) + assert.Equal(t, snap.ID, resolved) +} + +func TestSnapResolveID_NotFound(t *testing.T) { + baseDir := t.TempDir() + + _, err := core.SnapResolveID(baseDir, "abcd1234") + require.Error(t, err) + assert.Contains(t, err.Error(), "no snapshot matches") +} + +// --------------------------------------------------------------------------- +// SnapRestore +// --------------------------------------------------------------------------- + +func TestSnapRestore_Basic(t *testing.T) { + baseDir, sysRoot, _, sysPath, origContent := buildSnapFixture(t) + + // Take a snapshot. + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + // Modify the system file. + modifiedContent := []byte("key=modified\n") + require.NoError(t, os.WriteFile(filepath.Join(sysRoot, sysPath), modifiedContent, 0o644)) + + // Restore the snapshot. + restoreRoot := t.TempDir() + result, err := core.SnapRestore(core.SnapRestoreOptions{ + BaseDir: baseDir, + SysRoot: restoreRoot, + SnapID: snap.ID, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Restored, 1) + assert.Empty(t, result.Skipped) + + // Verify the restored file contains the original content. + restored, err := os.ReadFile(filepath.Join(restoreRoot, sysPath)) + require.NoError(t, err) + assert.Equal(t, origContent, restored) +} + +func TestSnapRestore_DryRun(t *testing.T) { + baseDir, sysRoot, _, sysPath, _ := buildSnapFixture(t) + + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + restoreRoot := t.TempDir() + result, err := core.SnapRestore(core.SnapRestoreOptions{ + BaseDir: baseDir, + SysRoot: restoreRoot, + SnapID: snap.ID, + DryRun: true, + }) + require.NoError(t, err) + assert.True(t, result.DryRun) + assert.Len(t, result.Restored, 1) + + // DryRun must NOT create any files. + _, err = os.Stat(filepath.Join(restoreRoot, sysPath)) + assert.True(t, os.IsNotExist(err), "dry-run must not write files to disk") +} + +func TestSnapRestore_FilterByID(t *testing.T) { + // Build a fixture with two tracked files. + baseDir := t.TempDir() + sysRoot := t.TempDir() + + id1 := core.DeriveID("/etc/a.conf") + id2 := core.DeriveID("/etc/b.conf") + now := time.Now() + s := &types.State{ + Version: 1, + Files: map[string]*types.FileRecord{ + id1: {ID: id1, SystemPath: "/etc/a.conf", RepoPath: "etc/a.conf", CurrentHash: "aa", LastSync: &now, Status: types.StatusTracked}, + id2: {ID: id2, SystemPath: "/etc/b.conf", RepoPath: "etc/b.conf", CurrentHash: "bb", LastSync: &now, Status: types.StatusTracked}, + }, + Backups: map[string][]types.BackupRecord{}, + } + data, _ := json.MarshalIndent(s, "", " ") + _ = os.WriteFile(filepath.Join(baseDir, "state.json"), data, 0o600) + + // Write both system files. + for _, p := range []string{"/etc/a.conf", "/etc/b.conf"} { + full := filepath.Join(sysRoot, p) + _ = os.MkdirAll(filepath.Dir(full), 0o755) + _ = os.WriteFile(full, []byte("content"), 0o644) + } + + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + restoreRoot := t.TempDir() + result, err := core.SnapRestore(core.SnapRestoreOptions{ + BaseDir: baseDir, + SysRoot: restoreRoot, + SnapID: snap.ID, + IDs: []string{id1}, // only restore id1 + }) + require.NoError(t, err) + assert.Len(t, result.Restored, 1) + assert.Len(t, result.Skipped, 1) + assert.Equal(t, id1, result.Restored[0].ID) +} + +// --------------------------------------------------------------------------- +// SnapFilterByDir +// --------------------------------------------------------------------------- + +func TestSnapFilterByDir_EmptyDir(t *testing.T) { + snaps := []core.SnapInfo{ + {ID: "a", Files: []core.SnapFile{{SystemPath: "/etc/nginx.conf"}}}, + {ID: "b", Files: []core.SnapFile{{SystemPath: "/home/user/.bashrc"}}}, + } + result := core.SnapFilterByDir(snaps, "") + assert.Equal(t, snaps, result, "empty dir must return all snaps") +} + +func TestSnapFilterByDir_FiltersByPrefix(t *testing.T) { + snaps := []core.SnapInfo{ + {ID: "a", Files: []core.SnapFile{{SystemPath: "/etc/nginx.conf"}}}, + {ID: "b", Files: []core.SnapFile{{SystemPath: "/etc/pacman.conf"}}}, + {ID: "c", Files: []core.SnapFile{{SystemPath: "/home/user/.bashrc"}}}, + } + result := core.SnapFilterByDir(snaps, "/etc") + assert.Len(t, result, 2, "only snaps with files under /etc should be included") + for _, s := range result { + assert.True(t, s.ID == "a" || s.ID == "b") + } +} + +func TestSnapFilterByDir_ExactPath(t *testing.T) { + snaps := []core.SnapInfo{ + {ID: "a", Files: []core.SnapFile{{SystemPath: "/etc"}}}, + } + result := core.SnapFilterByDir(snaps, "/etc") + assert.Len(t, result, 1) +} + +func TestSnapFilterByDir_NoMatch(t *testing.T) { + snaps := []core.SnapInfo{ + {ID: "a", Files: []core.SnapFile{{SystemPath: "/var/log/syslog"}}}, + } + result := core.SnapFilterByDir(snaps, "/etc") + assert.Empty(t, result) +} + +// --------------------------------------------------------------------------- +// SnapFilesUnderDir +// --------------------------------------------------------------------------- + +func TestSnapFilesUnderDir_EmptyDir(t *testing.T) { + info := core.SnapInfo{ + Files: []core.SnapFile{ + {ID: "1", SystemPath: "/etc/a.conf"}, + {ID: "2", SystemPath: "/home/user/.bashrc"}, + }, + } + result := core.SnapFilesUnderDir(info, "") + assert.Equal(t, info.Files, result, "empty dir must return all files") +} + +func TestSnapFilesUnderDir_FiltersByPrefix(t *testing.T) { + info := core.SnapInfo{ + Files: []core.SnapFile{ + {ID: "1", SystemPath: "/etc/a.conf"}, + {ID: "2", SystemPath: "/etc/b.conf"}, + {ID: "3", SystemPath: "/home/user/.bashrc"}, + }, + } + result := core.SnapFilesUnderDir(info, "/etc") + assert.Len(t, result, 2) + for _, f := range result { + assert.True(t, strings.HasPrefix(f.SystemPath, "/etc")) + } +} + +// --------------------------------------------------------------------------- +// SnapUndo +// --------------------------------------------------------------------------- + +func TestSnapUndo_RestoresMostRecent(t *testing.T) { + baseDir, sysRoot, _, sysPath, origContent := buildSnapFixture(t) + + // Take initial snapshot. + _, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot, Label: "first"}) + require.NoError(t, err) + + // Modify file and take a second snapshot. + updatedContent := []byte("key=updated\n") + require.NoError(t, os.WriteFile(filepath.Join(sysRoot, sysPath), updatedContent, 0o644)) + _, err = core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot, Label: "second"}) + require.NoError(t, err) + + // Undo should restore from the MOST RECENT snap (second = updatedContent). + restoreRoot := t.TempDir() + result, snapID, err := core.SnapUndo(core.SnapUndoOptions{ + BaseDir: baseDir, + SysRoot: restoreRoot, + }) + require.NoError(t, err) + assert.NotEmpty(t, snapID) + assert.NotNil(t, result) + assert.Len(t, result.Restored, 1) + + restored, err := os.ReadFile(filepath.Join(restoreRoot, sysPath)) + require.NoError(t, err) + // Most recent snapshot had updatedContent. + assert.Equal(t, updatedContent, restored) + + _ = origContent +} + +func TestSnapUndo_NoSnapshots(t *testing.T) { + baseDir := t.TempDir() + + _, _, err := core.SnapUndo(core.SnapUndoOptions{BaseDir: baseDir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no snapshots found") +} + +func TestSnapUndo_WithDirScope(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + _, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + restoreRoot := t.TempDir() + result, _, err := core.SnapUndo(core.SnapUndoOptions{ + BaseDir: baseDir, + SysRoot: restoreRoot, + Dir: "/etc", + }) + require.NoError(t, err) + assert.Len(t, result.Restored, 1) +} + +func TestSnapUndo_WithDirScope_NoMatch(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + _, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + _, _, err = core.SnapUndo(core.SnapUndoOptions{ + BaseDir: baseDir, + Dir: "/var", // no snaps for /var + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no snapshots found for") +} + +// --------------------------------------------------------------------------- +// SnapDrop +// --------------------------------------------------------------------------- + +func TestSnapDrop_Basic(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + err = core.SnapDrop(baseDir, snap.ID) + require.NoError(t, err) + + // Snap directory must be gone. + _, err = os.Stat(filepath.Join(core.SnapDir(baseDir), snap.ID)) + assert.True(t, os.IsNotExist(err), "snap directory must be deleted") +} + +func TestSnapDrop_NotFound(t *testing.T) { + baseDir := t.TempDir() + _ = os.MkdirAll(core.SnapDir(baseDir), 0o700) + + err := core.SnapDrop(baseDir, "abcd1234") + require.Error(t, err) + assert.Contains(t, err.Error(), "no snapshot matches") +} + +func TestSnapDrop_ByShortID(t *testing.T) { + baseDir, sysRoot, _, _, _ := buildSnapFixture(t) + + snap, err := core.SnapTake(core.SnapTakeOptions{BaseDir: baseDir, SysRoot: sysRoot}) + require.NoError(t, err) + + // Drop using short ID. + err = core.SnapDrop(baseDir, snap.ShortID) + require.NoError(t, err) + + snaps, err := core.SnapList(baseDir) + require.NoError(t, err) + assert.Empty(t, snaps) +} diff --git a/internal/core/sources_test.go b/internal/core/sources_test.go new file mode 100644 index 0000000..43f1a71 --- /dev/null +++ b/internal/core/sources_test.go @@ -0,0 +1,234 @@ +package core_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/aissat/sysfig/internal/core" +) + +// --------------------------------------------------------------------------- +// SourceCacheDir / SourceRepoDir +// --------------------------------------------------------------------------- + +func TestSourceCacheDir(t *testing.T) { + got := core.SourceCacheDir("/home/user/.sysfig", "corporate") + assert.Equal(t, "/home/user/.sysfig/sources/corporate", got) +} + +func TestSourceRepoDir(t *testing.T) { + got := core.SourceRepoDir("/home/user/.sysfig", "corporate") + assert.Equal(t, "/home/user/.sysfig/sources/corporate/repo.git", got) +} + +// --------------------------------------------------------------------------- +// LoadSourcesConfig +// --------------------------------------------------------------------------- + +func TestLoadSourcesConfig_NonExistent(t *testing.T) { + // A missing sources.yaml should return an empty config without error. + baseDir := t.TempDir() + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Empty(t, cfg.Sources) + assert.Empty(t, cfg.Profiles) +} + +func TestLoadSourcesConfig_ValidYAML(t *testing.T) { + baseDir := t.TempDir() + yaml := ` +sources: + - name: corporate + url: bundle+local:///mnt/usb/bundle.tar.gz +profiles: + - source: corporate/system-proxy + variables: + HTTP_PROXY: http://proxy.corp.example.com:3128 +` + require.NoError(t, os.WriteFile(filepath.Join(baseDir, "sources.yaml"), []byte(yaml), 0o600)) + + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + require.Len(t, cfg.Sources, 1) + assert.Equal(t, "corporate", cfg.Sources[0].Name) + assert.Equal(t, "bundle+local:///mnt/usb/bundle.tar.gz", cfg.Sources[0].URL) + require.Len(t, cfg.Profiles, 1) + assert.Equal(t, "corporate/system-proxy", cfg.Profiles[0].Source) + assert.Equal(t, "http://proxy.corp.example.com:3128", cfg.Profiles[0].Variables["HTTP_PROXY"]) +} + +func TestLoadSourcesConfig_InvalidYAML(t *testing.T) { + baseDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(baseDir, "sources.yaml"), []byte(":\tinvalid:\tyaml"), 0o600)) + + _, err := core.LoadSourcesConfig(baseDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") +} + +// --------------------------------------------------------------------------- +// SaveSourcesConfig +// --------------------------------------------------------------------------- + +func TestSaveSourcesConfig_WritesYAML(t *testing.T) { + baseDir := t.TempDir() + cfg := &core.SourcesConfig{ + Sources: []core.SourceDecl{ + {Name: "myrepo", URL: "https://github.com/example/configs.git"}, + }, + } + + require.NoError(t, core.SaveSourcesConfig(baseDir, cfg)) + + // File must exist with correct content. + data, err := os.ReadFile(filepath.Join(baseDir, "sources.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "myrepo") + assert.Contains(t, string(data), "https://github.com/example/configs.git") +} + +func TestSaveSourcesConfig_RoundTrip(t *testing.T) { + baseDir := t.TempDir() + orig := &core.SourcesConfig{ + Sources: []core.SourceDecl{ + {Name: "corp", URL: "bundle+local:///srv/bundle.tar.gz"}, + }, + Profiles: []core.ProfileActivation{ + {Source: "corp/system-proxy", Variables: map[string]string{"PROXY": "http://proxy:3128"}}, + }, + } + require.NoError(t, core.SaveSourcesConfig(baseDir, orig)) + + got, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + + require.Len(t, got.Sources, 1) + assert.Equal(t, orig.Sources[0].Name, got.Sources[0].Name) + assert.Equal(t, orig.Sources[0].URL, got.Sources[0].URL) + + require.Len(t, got.Profiles, 1) + assert.Equal(t, orig.Profiles[0].Source, got.Profiles[0].Source) + assert.Equal(t, orig.Profiles[0].Variables["PROXY"], got.Profiles[0].Variables["PROXY"]) +} + +// --------------------------------------------------------------------------- +// SourceAdd +// --------------------------------------------------------------------------- + +func TestSourceAdd_Basic(t *testing.T) { + baseDir := t.TempDir() + + err := core.SourceAdd(baseDir, "myrepo", "https://github.com/example/configs.git") + require.NoError(t, err) + + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + require.Len(t, cfg.Sources, 1) + assert.Equal(t, "myrepo", cfg.Sources[0].Name) + assert.Equal(t, "https://github.com/example/configs.git", cfg.Sources[0].URL) +} + +func TestSourceAdd_DuplicateName(t *testing.T) { + baseDir := t.TempDir() + + require.NoError(t, core.SourceAdd(baseDir, "myrepo", "https://first.example.com/repo.git")) + + // Adding a second source with the same name must fail. + err := core.SourceAdd(baseDir, "myrepo", "https://second.example.com/other.git") + require.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} + +func TestSourceAdd_MultipleSourcesAllowed(t *testing.T) { + baseDir := t.TempDir() + + require.NoError(t, core.SourceAdd(baseDir, "corp", "https://corp.example.com/repo.git")) + require.NoError(t, core.SourceAdd(baseDir, "personal", "https://github.com/me/dots.git")) + + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + assert.Len(t, cfg.Sources, 2) +} + +// --------------------------------------------------------------------------- +// SourceUse +// --------------------------------------------------------------------------- + +func TestSourceUse_EmptyBaseDir(t *testing.T) { + err := core.SourceUse(core.SourceUseOptions{BaseDir: "", SourceProfile: "corp/proxy"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "BaseDir") +} + +func TestSourceUse_EmptySourceProfile(t *testing.T) { + err := core.SourceUse(core.SourceUseOptions{BaseDir: t.TempDir(), SourceProfile: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "SourceProfile") +} + +func TestSourceUse_AddsNewActivation(t *testing.T) { + baseDir := t.TempDir() + + err := core.SourceUse(core.SourceUseOptions{ + BaseDir: baseDir, + SourceProfile: "corp/system-proxy", + Variables: map[string]string{"PROXY": "http://proxy:3128"}, + }) + require.NoError(t, err) + + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + require.Len(t, cfg.Profiles, 1) + assert.Equal(t, "corp/system-proxy", cfg.Profiles[0].Source) + assert.Equal(t, "http://proxy:3128", cfg.Profiles[0].Variables["PROXY"]) +} + +func TestSourceUse_UpdatesExistingActivation(t *testing.T) { + baseDir := t.TempDir() + + // Add initial activation. + require.NoError(t, core.SourceUse(core.SourceUseOptions{ + BaseDir: baseDir, + SourceProfile: "corp/system-proxy", + Variables: map[string]string{"PROXY": "http://old-proxy:3128"}, + })) + + // Update with same source+vars — should be a no-op duplicate. + require.NoError(t, core.SourceUse(core.SourceUseOptions{ + BaseDir: baseDir, + SourceProfile: "corp/system-proxy", + Variables: map[string]string{"PROXY": "http://old-proxy:3128"}, + })) + + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + // Should still be one profile (updated in-place, not appended). + assert.Len(t, cfg.Profiles, 1) +} + +func TestSourceUse_DifferentVarsCreatesNewActivation(t *testing.T) { + baseDir := t.TempDir() + + // Add activation for user "alice". + require.NoError(t, core.SourceUse(core.SourceUseOptions{ + BaseDir: baseDir, + SourceProfile: "corp/git-identity", + Variables: map[string]string{"GIT_NAME": "Alice", "GIT_EMAIL": "alice@corp.example.com"}, + })) + + // Add activation for user "bob" (different vars → new activation). + require.NoError(t, core.SourceUse(core.SourceUseOptions{ + BaseDir: baseDir, + SourceProfile: "corp/git-identity", + Variables: map[string]string{"GIT_NAME": "Bob", "GIT_EMAIL": "bob@corp.example.com"}, + })) + + cfg, err := core.LoadSourcesConfig(baseDir) + require.NoError(t, err) + // Two distinct activations because the variables differ. + assert.Len(t, cfg.Profiles, 2) +} diff --git a/internal/core/tag_test.go b/internal/core/tag_test.go new file mode 100644 index 0000000..da8057f --- /dev/null +++ b/internal/core/tag_test.go @@ -0,0 +1,311 @@ +package core_test + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/aissat/sysfig/internal/core" +) + +// buildTagFixture writes a state.json containing several tracked files with +// various tag configurations. Returns the baseDir. +// +// Files in the fixture: +// - id1: /etc/nginx.conf - tags ["linux", "arch"] +// - id2: /etc/pacman.conf - tags ["linux", "arch"] +// - id3: /etc/fstab - tags ["linux"] +// - id4: /home/user/.bashrc - no tags +func buildTagFixture(t *testing.T) string { + t.Helper() + baseDir := t.TempDir() + + id1 := core.DeriveID("/etc/nginx.conf") + id2 := core.DeriveID("/etc/pacman.conf") + id3 := core.DeriveID("/etc/fstab") + id4 := core.DeriveID("/home/user/.bashrc") + + s := map[string]interface{}{ + "version": 1, + "files": map[string]interface{}{ + id1: map[string]interface{}{ + "id": id1, "system_path": "/etc/nginx.conf", + "repo_path": "etc/nginx.conf", "current_hash": "aaaa", "status": "tracked", + "tags": []string{"linux", "arch"}, + }, + id2: map[string]interface{}{ + "id": id2, "system_path": "/etc/pacman.conf", + "repo_path": "etc/pacman.conf", "current_hash": "bbbb", "status": "tracked", + "tags": []string{"linux", "arch"}, + }, + id3: map[string]interface{}{ + "id": id3, "system_path": "/etc/fstab", + "repo_path": "etc/fstab", "current_hash": "cccc", "status": "tracked", + "tags": []string{"linux"}, + }, + id4: map[string]interface{}{ + "id": id4, "system_path": "/home/user/.bashrc", + "repo_path": "home/user/dot-bashrc", "current_hash": "dddd", "status": "tracked", + }, + }, + "backups": map[string]interface{}{}, + } + data, err := json.MarshalIndent(s, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(baseDir, "state.json"), data, 0o600)) + return baseDir +} + +// --------------------------------------------------------------------------- +// TagList +// --------------------------------------------------------------------------- + +func TestTagList_EmptyBaseDir(t *testing.T) { + _, err := core.TagList(core.TagListOptions{BaseDir: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "BaseDir") +} + +func TestTagList_TagCounts(t *testing.T) { + baseDir := buildTagFixture(t) + + result, err := core.TagList(core.TagListOptions{BaseDir: baseDir}) + require.NoError(t, err) + require.NotNil(t, result) + + // 1 file (/home/user/.bashrc) has no tags. + assert.Equal(t, 1, result.Untagged, "untagged count must be 1") + + // Build a map for easy lookup. + counts := map[string]int{} + for _, e := range result.Entries { + counts[e.Tag] = e.Count + } + + // "linux" appears on id1, id2, id3 → count 3. + assert.Equal(t, 3, counts["linux"], "linux tag count") + // "arch" appears on id1, id2 → count 2. + assert.Equal(t, 2, counts["arch"], "arch tag count") +} + +func TestTagList_SortedAlphabetically(t *testing.T) { + baseDir := buildTagFixture(t) + + result, err := core.TagList(core.TagListOptions{BaseDir: baseDir}) + require.NoError(t, err) + + tags := make([]string, len(result.Entries)) + for i, e := range result.Entries { + tags[i] = e.Tag + } + sorted := make([]string, len(tags)) + copy(sorted, tags) + sort.Strings(sorted) + assert.Equal(t, sorted, tags, "entries must be sorted alphabetically by tag name") +} + +// --------------------------------------------------------------------------- +// TagAuto +// --------------------------------------------------------------------------- + +func TestTagAuto_EmptyBaseDir(t *testing.T) { + _, err := core.TagAuto(core.TagAutoOptions{BaseDir: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "BaseDir") +} + +func TestTagAuto_UpdatesUntaggedOnly(t *testing.T) { + baseDir := buildTagFixture(t) + + result, err := core.TagAuto(core.TagAutoOptions{BaseDir: baseDir, Overwrite: false}) + require.NoError(t, err) + require.NotNil(t, result) + + // Only id4 (/home/user/.bashrc) has no tags → should be updated. + assert.Equal(t, 1, result.Updated, "only untagged files should be updated") + // id1, id2, id3 are already tagged → skipped. + assert.Equal(t, 3, result.Skipped, "tagged files must be skipped") +} + +func TestTagAuto_OverwriteAll(t *testing.T) { + baseDir := buildTagFixture(t) + + result, err := core.TagAuto(core.TagAutoOptions{BaseDir: baseDir, Overwrite: true}) + require.NoError(t, err) + require.NotNil(t, result) + + // All 4 files should be updated. + assert.Equal(t, 4, result.Updated) + assert.Equal(t, 0, result.Skipped) +} + +// --------------------------------------------------------------------------- +// TagSet +// --------------------------------------------------------------------------- + +func TestTagSet_EmptyBaseDir(t *testing.T) { + _, err := core.TagSet(core.TagSetOptions{BaseDir: "", PathOrID: "/etc/nginx.conf"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "BaseDir") +} + +func TestTagSet_EmptyPathOrID(t *testing.T) { + _, err := core.TagSet(core.TagSetOptions{BaseDir: t.TempDir(), PathOrID: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "path or ID") +} + +func TestTagSet_BySystemPath(t *testing.T) { + baseDir := buildTagFixture(t) + + result, err := core.TagSet(core.TagSetOptions{ + BaseDir: baseDir, + PathOrID: "/etc/fstab", + Tags: []string{"linux", "ubuntu"}, + }) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "/etc/fstab", result.SystemPath) + assert.Equal(t, []string{"linux"}, result.OldTags) + assert.Equal(t, []string{"linux", "ubuntu"}, result.NewTags) +} + +func TestTagSet_ByID(t *testing.T) { + baseDir := buildTagFixture(t) + id := core.DeriveID("/etc/nginx.conf") + + result, err := core.TagSet(core.TagSetOptions{ + BaseDir: baseDir, + PathOrID: id, + Tags: []string{"darwin"}, + }) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "/etc/nginx.conf", result.SystemPath) + assert.Equal(t, []string{"darwin"}, result.NewTags) +} + +func TestTagSet_ByIDPrefix(t *testing.T) { + baseDir := buildTagFixture(t) + id := core.DeriveID("/etc/nginx.conf") + // Use an 8-char prefix (at least 4 chars required). + prefix := id[:8] + + result, err := core.TagSet(core.TagSetOptions{ + BaseDir: baseDir, + PathOrID: prefix, + Tags: []string{"freebsd"}, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "/etc/nginx.conf", result.SystemPath) +} + +func TestTagSet_NotFound(t *testing.T) { + baseDir := buildTagFixture(t) + + _, err := core.TagSet(core.TagSetOptions{ + BaseDir: baseDir, + PathOrID: "/nonexistent/path", + Tags: []string{"linux"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no tracked file found") +} + +func TestTagSet_ClearTags(t *testing.T) { + baseDir := buildTagFixture(t) + + result, err := core.TagSet(core.TagSetOptions{ + BaseDir: baseDir, + PathOrID: "/etc/fstab", + Tags: nil, // clears all tags + }) + require.NoError(t, err) + assert.Empty(t, result.NewTags, "tags must be cleared when nil is passed") +} + +// --------------------------------------------------------------------------- +// TagRename +// --------------------------------------------------------------------------- + +func TestTagRename_EmptyBaseDir(t *testing.T) { + _, err := core.TagRename(core.TagRenameOptions{BaseDir: "", OldTag: "a", NewTag: "b"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "BaseDir") +} + +func TestTagRename_EmptyTags(t *testing.T) { + dir := t.TempDir() + _, err := core.TagRename(core.TagRenameOptions{BaseDir: dir, OldTag: "", NewTag: "b"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + + _, err = core.TagRename(core.TagRenameOptions{BaseDir: dir, OldTag: "a", NewTag: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestTagRename_RenamesAcrossFiles(t *testing.T) { + baseDir := buildTagFixture(t) + + // Rename "arch" → "archlinux" — id1 and id2 both carry "arch". + result, err := core.TagRename(core.TagRenameOptions{ + BaseDir: baseDir, + OldTag: "arch", + NewTag: "archlinux", + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Two files (id1, id2) had "arch" → both should be updated. + assert.Equal(t, 2, result.Updated) +} + +func TestTagRename_NoMatchingTag(t *testing.T) { + baseDir := buildTagFixture(t) + + // "fedora" is not used by any file — Updated must be 0. + result, err := core.TagRename(core.TagRenameOptions{ + BaseDir: baseDir, + OldTag: "fedora", + NewTag: "rhel", + }) + require.NoError(t, err) + assert.Equal(t, 0, result.Updated) +} + +func TestTagRename_DeduplicatesIfNewTagAlreadyPresent(t *testing.T) { + // Set up a file that has both "linux" and "arch" tags. + // Renaming "arch" → "linux" should leave only one "linux" (dedup). + baseDir := t.TempDir() + id := core.DeriveID("/etc/test.conf") + s := map[string]interface{}{ + "version": 1, + "files": map[string]interface{}{ + id: map[string]interface{}{ + "id": id, "system_path": "/etc/test.conf", + "repo_path": "etc/test.conf", "current_hash": "aaaa", "status": "tracked", + "tags": []string{"linux", "arch"}, + }, + }, + "backups": map[string]interface{}{}, + } + data, _ := json.MarshalIndent(s, "", " ") + _ = os.WriteFile(filepath.Join(baseDir, "state.json"), data, 0o600) + + result, err := core.TagRename(core.TagRenameOptions{ + BaseDir: baseDir, + OldTag: "arch", + NewTag: "linux", // already present + }) + require.NoError(t, err) + // The rename happened, but the final list should be deduplicated. + assert.Equal(t, 1, result.Updated) +} diff --git a/pkg/types/state_test.go b/pkg/types/state_test.go new file mode 100644 index 0000000..38561f8 --- /dev/null +++ b/pkg/types/state_test.go @@ -0,0 +1,305 @@ +package types_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/aissat/sysfig/pkg/types" +) + +// TestFileStatus_Values verifies that the FileStatus constants have the correct +// string values that state.json consumers depend on. +func TestFileStatus_Values(t *testing.T) { + tests := []struct { + status types.FileStatus + want string + }{ + {types.StatusTracked, "tracked"}, + {types.StatusModified, "modified"}, + {types.StatusConflict, "conflict"}, + {types.StatusUntracked, "untracked"}, + } + for _, tc := range tests { + if string(tc.status) != tc.want { + t.Errorf("FileStatus %q = %q, want %q", tc.status, string(tc.status), tc.want) + } + } +} + +// TestFileMeta_JSONRoundTrip verifies that FileMeta serialises and deserialises +// correctly via JSON (the format used in state.json). +func TestFileMeta_JSONRoundTrip(t *testing.T) { + orig := types.FileMeta{ + UID: 1000, + GID: 1001, + Owner: "alice", + Group: "users", + Mode: 0o644, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal FileMeta: %v", err) + } + var got types.FileMeta + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal FileMeta: %v", err) + } + if got != orig { + t.Errorf("round-trip mismatch: got %+v, want %+v", got, orig) + } +} + +// TestFileMeta_OmitEmptyOwnerGroup verifies that the optional Owner and Group +// fields are omitted when empty (omitempty tag). +func TestFileMeta_OmitEmptyOwnerGroup(t *testing.T) { + m := types.FileMeta{UID: 0, GID: 0, Mode: 0o755} + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if _, ok := raw["owner"]; ok { + t.Error("owner key must be absent when empty") + } + if _, ok := raw["group"]; ok { + t.Error("group key must be absent when empty") + } +} + +// TestFileRecord_JSONRoundTrip verifies a full FileRecord round-trips cleanly. +func TestFileRecord_JSONRoundTrip(t *testing.T) { + now := time.Now().Truncate(time.Second) + rec := types.FileRecord{ + ID: "abc12345", + SystemPath: "/etc/myapp.conf", + RepoPath: "etc/myapp.conf", + CurrentHash: "deadbeef", + LastSync: &now, + LastApply: &now, + Status: types.StatusTracked, + Encrypt: true, + Template: true, + Tags: []string{"linux", "arch"}, + Group: "/etc/myapp", + Branch: "track/etc/myapp.conf", + SourceProfile: "corp/proxy", + LocalOnly: true, + HashOnly: true, + Meta: &types.FileMeta{UID: 1000, GID: 1000, Mode: 0o600}, + } + data, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got types.FileRecord + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Compare key fields (time is compared separately due to timezone). + if got.ID != rec.ID { + t.Errorf("ID: got %q, want %q", got.ID, rec.ID) + } + if got.SystemPath != rec.SystemPath { + t.Errorf("SystemPath: got %q, want %q", got.SystemPath, rec.SystemPath) + } + if got.Status != rec.Status { + t.Errorf("Status: got %q, want %q", got.Status, rec.Status) + } + if got.Encrypt != rec.Encrypt { + t.Errorf("Encrypt: got %v, want %v", got.Encrypt, rec.Encrypt) + } + if got.Template != rec.Template { + t.Errorf("Template: got %v, want %v", got.Template, rec.Template) + } + if got.LocalOnly != rec.LocalOnly { + t.Errorf("LocalOnly: got %v, want %v", got.LocalOnly, rec.LocalOnly) + } + if got.HashOnly != rec.HashOnly { + t.Errorf("HashOnly: got %v, want %v", got.HashOnly, rec.HashOnly) + } + if len(got.Tags) != len(rec.Tags) { + t.Errorf("Tags: got %v, want %v", got.Tags, rec.Tags) + } + if got.Meta == nil || *got.Meta != *rec.Meta { + t.Errorf("Meta: got %v, want %v", got.Meta, rec.Meta) + } + if got.LastSync == nil || !got.LastSync.Equal(*rec.LastSync) { + t.Errorf("LastSync mismatch") + } +} + +// TestFileRecord_OmitEmptyFields verifies that optional fields with zero values +// are omitted from JSON output, keeping state.json small. +func TestFileRecord_OmitEmptyFields(t *testing.T) { + rec := types.FileRecord{ + ID: "abc12345", + SystemPath: "/etc/x.conf", + RepoPath: "etc/x.conf", + Status: types.StatusTracked, + } + data, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + // These optional fields should not appear when zero/empty. + for _, key := range []string{"last_sync", "last_apply", "tags", "meta", "source_profile", "local_only", "hash_only"} { + if _, ok := raw[key]; ok { + t.Errorf("key %q must be absent for zero-value FileRecord", key) + } + } +} + +// TestBackupRecord_JSONRoundTrip verifies BackupRecord serialises correctly. +func TestBackupRecord_JSONRoundTrip(t *testing.T) { + now := time.Now().Truncate(time.Second) + orig := types.BackupRecord{ + Path: "/var/backups/myapp.conf.bak", + Hash: "cafebabe", + CreatedAt: now, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got types.BackupRecord + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Path != orig.Path { + t.Errorf("Path: got %q, want %q", got.Path, orig.Path) + } + if got.Hash != orig.Hash { + t.Errorf("Hash: got %q, want %q", got.Hash, orig.Hash) + } + if !got.CreatedAt.Equal(orig.CreatedAt) { + t.Errorf("CreatedAt: got %v, want %v", got.CreatedAt, orig.CreatedAt) + } +} + +// TestNode_JSONRoundTrip verifies that a Node serialises and deserialises cleanly. +func TestNode_JSONRoundTrip(t *testing.T) { + now := time.Now().Truncate(time.Second) + orig := types.Node{ + Name: "laptop", + PublicKey: "age1qjsz5yrqctdmq6q85e2v8xhvuwa3g4ggd6ltz7g3sj3hj7k9d4qsxkyd5", + Variables: map[string]string{"ZONE": "eu-west-1"}, + AddedAt: now, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got types.Node + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Name != orig.Name { + t.Errorf("Name: got %q, want %q", got.Name, orig.Name) + } + if got.PublicKey != orig.PublicKey { + t.Errorf("PublicKey mismatch") + } + if got.Variables["ZONE"] != orig.Variables["ZONE"] { + t.Errorf("Variables mismatch") + } +} + +// TestNode_OmitEmptyVariables verifies that Variables is omitted when nil. +func TestNode_OmitEmptyVariables(t *testing.T) { + n := types.Node{ + Name: "server", + PublicKey: "age1abc", + AddedAt: time.Now(), + } + data, err := json.Marshal(n) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if _, ok := raw["variables"]; ok { + t.Error("variables key must be absent when nil") + } +} + +// TestState_JSONRoundTrip verifies the top-level State struct. +func TestState_JSONRoundTrip(t *testing.T) { + now := time.Now().Truncate(time.Second) + orig := types.State{ + Version: 1, + Files: map[string]*types.FileRecord{ + "abc12345": { + ID: "abc12345", + SystemPath: "/etc/myapp.conf", + RepoPath: "etc/myapp.conf", + Status: types.StatusTracked, + }, + }, + Backups: map[string][]types.BackupRecord{ + "/etc/myapp.conf": { + {Path: "/var/backups/myapp.conf.bak", Hash: "deadbeef", CreatedAt: now}, + }, + }, + Nodes: map[string]*types.Node{ + "laptop": {Name: "laptop", PublicKey: "age1abc", AddedAt: now}, + }, + Excludes: []string{"/etc/myapp/secret.conf"}, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got types.State + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Version != orig.Version { + t.Errorf("Version: got %d, want %d", got.Version, orig.Version) + } + if len(got.Files) != 1 { + t.Errorf("Files: got %d, want 1", len(got.Files)) + } + if len(got.Backups) != 1 { + t.Errorf("Backups: got %d, want 1", len(got.Backups)) + } + if len(got.Nodes) != 1 { + t.Errorf("Nodes: got %d, want 1", len(got.Nodes)) + } + if len(got.Excludes) != 1 || got.Excludes[0] != "/etc/myapp/secret.conf" { + t.Errorf("Excludes: got %v, want [/etc/myapp/secret.conf]", got.Excludes) + } +} + +// TestState_OmitEmptyNodes verifies that Nodes and Excludes are omitted +// from JSON when nil/empty (omitempty tag). +func TestState_OmitEmptyNodes(t *testing.T) { + s := types.State{ + Version: 1, + Files: map[string]*types.FileRecord{}, + Backups: map[string][]types.BackupRecord{}, + } + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if _, ok := raw["nodes"]; ok { + t.Error("nodes must be absent when nil") + } + if _, ok := raw["excludes"]; ok { + t.Error("excludes must be absent when nil") + } +}