367 lines
10 KiB
Go
367 lines
10 KiB
Go
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 TestTestConnectionFallsBackToSavedSecret(t *testing.T) {
|
|
svc, _, _, remote, _, _ := newTestService(t)
|
|
saveValidConfig(t, svc)
|
|
cfg, err := svc.LoadConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig returned error: %v", err)
|
|
}
|
|
|
|
_, err = svc.TestConnection(context.Background(), domain.SaveConfigRequest{Config: cfg})
|
|
if err != nil {
|
|
t.Fatalf("TestConnection returned error: %v", err)
|
|
}
|
|
|
|
if remote.lastSecret != "secret" {
|
|
t.Fatalf("remote maker got secret %q, want saved secret", remote.lastSecret)
|
|
}
|
|
}
|
|
|
|
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 TestRunAutoUpdateKeepsCurrentCommitError(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.remoteCommit[local] = "new"
|
|
git.currentErr[local] = errors.New("rev-parse failed")
|
|
|
|
if err := svc.RunAutoUpdate(context.Background()); err != nil {
|
|
t.Fatalf("RunAutoUpdate returned error: %v", err)
|
|
}
|
|
|
|
reloaded, err := svc.store.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, _ := skillstore.FindSkill(reloaded, "skills", "demo")
|
|
if saved.LastError != "rev-parse failed" {
|
|
t.Fatalf("LastError = %q, want rev-parse failed", 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{}, currentErr: map[string]error{}}
|
|
target := &fakeTargets{}
|
|
svc := New(Options{
|
|
Paths: paths,
|
|
Secrets: secrets,
|
|
Git: git,
|
|
Targets: target,
|
|
RemoteMaker: func(_ context.Context, _ domain.Config, secret string) (RemoteClient, error) {
|
|
remote.lastSecret = secret
|
|
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
|
|
lastSecret string
|
|
}
|
|
|
|
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
|
|
currentErr map[string]error
|
|
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 err := f.currentErr[path]; err != nil {
|
|
return "", err
|
|
}
|
|
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 }
|