package service import ( "context" "errors" "os" "path/filepath" "strings" "testing" "sgg-ai-skill-manager/internal/domain" "sgg-ai-skill-manager/internal/gitops" "sgg-ai-skill-manager/internal/skillstore" ) func TestSaveConfigStoresSecretOutsideJSON(t *testing.T) { svc, paths, secrets, _, _, _ := newTestService(t) req := domain.SaveConfigRequest{ Config: domain.Config{ Gitea: domain.GiteaConfig{ BaseURL: "https://gitea.example.com", Org: "skills", AuthType: "password", Username: "alice", CredentialKey: "sgg-ai-skill-manager:gitea", }, Update: domain.UpdateConfig{AutoUpdate: true, CheckOnStartup: true, IntervalMinutes: 60}, }, Password: "password-secret", } if err := svc.SaveConfig(context.Background(), req); err != nil { t.Fatalf("SaveConfig returned error: %v", err) } if got := secrets.values["sgg-ai-skill-manager:gitea/alice"]; got != "password-secret" { t.Fatalf("stored secret = %q", got) } raw, err := os.ReadFile(paths.ConfigPath) if err != nil { t.Fatalf("ReadFile returned error: %v", err) } if stringContains(string(raw), "password-secret") { t.Fatalf("config leaked secret: %s", string(raw)) } } func TestListRemoteSkillsMergesDownloadedStatus(t *testing.T) { svc, _, _, remote, _, _ := newTestService(t) saveValidConfig(t, svc) state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: "local"}}} if err := svc.store.Save(state); err != nil { t.Fatal(err) } remote.skills = []domain.RemoteSkill{ {Name: "demo", FullName: "skills/demo", CloneURL: "https://example/demo.git"}, {Name: "new", FullName: "skills/new", CloneURL: "https://example/new.git"}, } got, err := svc.ListRemoteSkills(context.Background()) if err != nil { t.Fatalf("ListRemoteSkills returned error: %v", err) } if len(got) != 2 { t.Fatalf("skill count = %d", len(got)) } if !got[0].IsDownloaded || got[0].Status != "downloaded" { t.Fatalf("downloaded skill not marked: %+v", got[0]) } if got[1].IsDownloaded { t.Fatalf("new skill should not be downloaded: %+v", got[1]) } } func TestInstallSkillUpdatesStateAfterTargetInstall(t *testing.T) { svc, _, _, _, _, target := newTestService(t) saveValidConfig(t, svc) local := filepath.Join(t.TempDir(), "demo") if err := os.MkdirAll(local, 0o755); err != nil { t.Fatal(err) } state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: local}}} if err := svc.store.Save(state); err != nil { t.Fatal(err) } target.installResult = domain.SkillState{ Org: "skills", Repo: "demo", LocalPath: local, InstalledTargets: map[string]domain.InstalledTarget{ "codex": {Path: "link", LinkType: "junction", TargetPath: local}, }, } got, err := svc.InstallSkill(context.Background(), "skills", "demo", "codex") if err != nil { t.Fatalf("InstallSkill returned error: %v", err) } if got.InstalledTargets["codex"].LinkType != "junction" { t.Fatalf("install record = %+v", got.InstalledTargets) } reloaded, err := svc.store.Load() if err != nil { t.Fatal(err) } saved, _ := skillstore.FindSkill(reloaded, "skills", "demo") if saved.InstalledTargets["codex"].Path != "link" { t.Fatalf("saved install record = %+v", saved.InstalledTargets) } } func TestDeleteSkillUninstallsBeforeRemovingLocalRepo(t *testing.T) { svc, paths, _, _, _, target := newTestService(t) saveValidConfig(t, svc) local := filepath.Join(paths.ReposRoot, "skills", "demo") if err := os.MkdirAll(local, 0o755); err != nil { t.Fatal(err) } state := domain.State{Skills: []domain.SkillState{{ Org: "skills", Repo: "demo", LocalPath: local, InstalledTargets: map[string]domain.InstalledTarget{ "codex": {Path: "link", LinkType: "junction", TargetPath: local}, }, }}} if err := svc.store.Save(state); err != nil { t.Fatal(err) } if err := svc.DeleteSkill(context.Background(), "skills", "demo"); err != nil { t.Fatalf("DeleteSkill returned error: %v", err) } if target.uninstallCalls != 1 { t.Fatalf("uninstall calls = %d, want 1", target.uninstallCalls) } if _, err := os.Stat(local); !errors.Is(err, os.ErrNotExist) { t.Fatalf("local repo should be removed, err=%v", err) } reloaded, err := svc.store.Load() if err != nil { t.Fatal(err) } if _, ok := skillstore.FindSkill(reloaded, "skills", "demo"); ok { t.Fatal("deleted skill still exists in state") } } func TestRunAutoUpdateSkipsDirtyRepositories(t *testing.T) { svc, _, _, _, git, _ := newTestService(t) saveValidConfig(t, svc) local := filepath.Join(t.TempDir(), "demo") state := domain.State{Skills: []domain.SkillState{{ Org: "skills", Repo: "demo", LocalPath: local, DefaultBranch: "main", CurrentCommit: "old", }}} if err := svc.store.Save(state); err != nil { t.Fatal(err) } git.dirty[local] = true git.remoteCommit[local] = "new" if err := svc.RunAutoUpdate(context.Background()); err != nil { t.Fatalf("RunAutoUpdate returned error: %v", err) } if git.pullCalls != 0 { t.Fatalf("pull calls = %d, want 0", git.pullCalls) } reloaded, err := svc.store.Load() if err != nil { t.Fatal(err) } saved, _ := skillstore.FindSkill(reloaded, "skills", "demo") if saved.LastError != "local changes present" { t.Fatalf("LastError = %q", saved.LastError) } } func newTestService(t *testing.T) (*Service, Paths, *fakeSecretStore, *fakeRemoteClient, *fakeGit, *fakeTargets) { t.Helper() dir := t.TempDir() paths := Paths{ AppDir: dir, ConfigPath: filepath.Join(dir, "config.json"), StatePath: filepath.Join(dir, "state.json"), ReposRoot: filepath.Join(dir, "repos"), HomeDir: filepath.Join(dir, "home"), } secrets := &fakeSecretStore{values: map[string]string{}} remote := &fakeRemoteClient{} git := &fakeGit{dirty: map[string]bool{}, currentCommit: map[string]string{}, remoteCommit: map[string]string{}} target := &fakeTargets{} svc := New(Options{ Paths: paths, Secrets: secrets, Git: git, Targets: target, RemoteMaker: func(context.Context, domain.Config, string) (RemoteClient, error) { return remote, nil }, Opener: fakeOpener{}, }) return svc, paths, secrets, remote, git, target } func saveValidConfig(t *testing.T, svc *Service) { t.Helper() req := domain.SaveConfigRequest{ Config: domain.Config{ Gitea: domain.GiteaConfig{ BaseURL: "https://gitea.example.com", Org: "skills", AuthType: "password", Username: "alice", CredentialKey: "sgg-ai-skill-manager:gitea", }, Update: domain.UpdateConfig{AutoUpdate: true, CheckOnStartup: true, IntervalMinutes: 60}, }, Password: "secret", } if err := svc.SaveConfig(context.Background(), req); err != nil { t.Fatal(err) } } func stringContains(text, needle string) bool { return strings.Contains(text, needle) } type fakeSecretStore struct { values map[string]string } func (f *fakeSecretStore) Set(service, user, secret string) error { f.values[service+"/"+user] = secret return nil } func (f *fakeSecretStore) Get(service, user string) (string, error) { return f.values[service+"/"+user], nil } func (f *fakeSecretStore) Delete(service, user string) error { delete(f.values, service+"/"+user) return nil } type fakeRemoteClient struct { skills []domain.RemoteSkill } func (f *fakeRemoteClient) ListOrgSkills(context.Context, string) ([]domain.RemoteSkill, error) { return f.skills, nil } func (f *fakeRemoteClient) TestConnection(context.Context, string) (domain.TestConnectionResult, error) { return domain.TestConnectionResult{OK: true, Username: "alice", Org: "skills"}, nil } type fakeGit struct { dirty map[string]bool currentCommit map[string]string remoteCommit map[string]string pullCalls int } func (f *fakeGit) Clone(context.Context, string, string, gitops.Credentials) error { return nil } func (f *fakeGit) Pull(context.Context, string, gitops.Credentials) error { f.pullCalls++ return nil } func (f *fakeGit) CurrentCommit(path string) (string, error) { if commit := f.currentCommit[path]; commit != "" { return commit, nil } return "current", nil } func (f *fakeGit) RemoteCommit(_ context.Context, path, _ string, _ gitops.Credentials) (string, error) { return f.remoteCommit[path], nil } func (f *fakeGit) HasLocalChanges(path string) (bool, error) { return f.dirty[path], nil } type fakeTargets struct { installResult domain.SkillState uninstallCalls int } func (f *fakeTargets) Install(domain.SkillState, string) (domain.SkillState, error) { return f.installResult, nil } func (f *fakeTargets) Uninstall(skill domain.SkillState, targetID string) (domain.SkillState, error) { f.uninstallCalls++ delete(skill.InstalledTargets, targetID) return skill, nil } func (f *fakeTargets) Status(domain.SkillState, string) string { return "not_installed" } type fakeOpener struct{} func (fakeOpener) OpenVSCode(string) error { return nil } func (fakeOpener) OpenFolder(string) error { return nil }