package api import ( "context" "fmt" "io" "log/slog" "net/http" "sync" "testing" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) // mockForgejoManager implements ForgejoManager for testing. type mockForgejoManager struct { mu sync.Mutex users map[string]string // username -> token createErr error deleteErr error deletedUsers []string repoFiles map[string][]byte // "owner/repo/path" -> content issueComments []mockIssueComment getFileErr error commentErr error } type mockIssueComment struct { Owner string Repo string IssueNumber int Body string } func newMockForgejoManager() *mockForgejoManager { return &mockForgejoManager{ users: make(map[string]string), repoFiles: make(map[string][]byte), } } func (m *mockForgejoManager) CreateForgejoUser(_ context.Context, username string) (string, error) { m.mu.Lock() defer m.mu.Unlock() if m.createErr != nil { return "", m.createErr } token := "forgejo_tok_" + username m.users[username] = token return token, nil } func (m *mockForgejoManager) DeleteForgejoUser(_ context.Context, username string) error { m.mu.Lock() defer m.mu.Unlock() if m.deleteErr != nil { return m.deleteErr } m.deletedUsers = append(m.deletedUsers, username) delete(m.users, username) return nil } func (m *mockForgejoManager) GetRepoFileContent(_ context.Context, owner, repo, filepath, _ string) ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() if m.getFileErr != nil { return nil, m.getFileErr } key := owner + "/" + repo + "/" + filepath content, exists := m.repoFiles[key] if !exists { return nil, nil } return content, nil } func (m *mockForgejoManager) CreateIssueComment(_ context.Context, owner, repo string, issueNumber int, body string) error { m.mu.Lock() defer m.mu.Unlock() if m.commentErr != nil { return m.commentErr } m.issueComments = append(m.issueComments, mockIssueComment{ Owner: owner, Repo: repo, IssueNumber: issueNumber, Body: body, }) return nil } func newForgejoTestRouter(fm *mockForgejoManager) (*mockKeyValidator, *mockPodManager, *mockUserStore, http.Handler) { kv := newMockValidator() pm := newMockPodManager() us := newMockUserStore() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := &Server{ Store: kv, K8s: pm, Users: us, Usage: newMockUsageStore(), Forgejo: fm, Logger: logger, GenerateKey: mockGenerateKey, } return kv, pm, us, NewRouter(srv) } func TestCreateUser_WithForgejo_Success(t *testing.T) { fm := newMockForgejoManager() kv, _, us, router := newForgejoTestRouter(fm) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"alice"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var resp createUserResponse decodeJSON(t, rr, &resp) if resp.User.ID != "alice" { t.Fatalf("expected user id 'alice', got %q", resp.User.ID) } // Verify Forgejo user was created fm.mu.Lock() token, exists := fm.users["alice"] fm.mu.Unlock() if !exists { t.Fatal("expected Forgejo user 'alice' to be created") } if token != "forgejo_tok_alice" { t.Fatalf("expected token 'forgejo_tok_alice', got %q", token) } // Verify token was saved to store us.mu.Lock() savedToken := us.forgejoTokens["alice"] us.mu.Unlock() if savedToken != "forgejo_tok_alice" { t.Fatalf("expected saved token 'forgejo_tok_alice', got %q", savedToken) } } func TestCreateUser_ForgejoFails_RollsBack(t *testing.T) { fm := newMockForgejoManager() fm.createErr = fmt.Errorf("forgejo unavailable") kv, _, us, router := newForgejoTestRouter(fm) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"alice"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) } // User should be rolled back us.mu.Lock() _, exists := us.users["alice"] us.mu.Unlock() if exists { t.Fatal("expected user 'alice' to be cleaned up after Forgejo failure") } } func TestCreateUser_NoForgejo_StillWorks(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"alice"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201 without Forgejo, got %d: %s", rr.Code, rr.Body.String()) } } func TestDeleteUser_WithForgejo_DeletesForgejoUser(t *testing.T) { fm := newMockForgejoManager() fm.users["alice"] = "tok" kv, _, us, router := newForgejoTestRouter(fm) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_admin") if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) } fm.mu.Lock() defer fm.mu.Unlock() if len(fm.deletedUsers) != 1 || fm.deletedUsers[0] != "alice" { t.Fatalf("expected Forgejo user 'alice' to be deleted, got %v", fm.deletedUsers) } } func TestDeleteUser_ForgejoFails_StillDeletesDBUser(t *testing.T) { fm := newMockForgejoManager() fm.deleteErr = fmt.Errorf("forgejo timeout") kv, _, us, router := newForgejoTestRouter(fm) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_admin") if rr.Code != http.StatusNoContent { t.Fatalf("expected 204 even with Forgejo failure, got %d: %s", rr.Code, rr.Body.String()) } // DB user should still be deleted us.mu.Lock() _, exists := us.users["alice"] us.mu.Unlock() if exists { t.Fatal("expected user 'alice' to be deleted from DB despite Forgejo failure") } } func TestCreateUser_ForgejoTokenStoreFails_RollsBack(t *testing.T) { fm := newMockForgejoManager() kv := newMockValidator() pm := newMockPodManager() us := newMockUserStore() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // Override SaveForgejoToken to fail by using a custom mock that wraps failingUS := &failingForgejoTokenStore{mockUserStore: us} srv := &Server{ Store: kv, K8s: pm, Users: failingUS, Usage: newMockUsageStore(), Forgejo: fm, Logger: logger, GenerateKey: mockGenerateKey, } router := NewRouter(srv) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"bob"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) } // Forgejo user should be cleaned up fm.mu.Lock() _, exists := fm.users["bob"] fm.mu.Unlock() if exists { t.Fatal("expected Forgejo user 'bob' to be cleaned up after token store failure") } // DB user should be cleaned up us.mu.Lock() _, dbExists := us.users["bob"] us.mu.Unlock() if dbExists { t.Fatal("expected DB user 'bob' to be cleaned up after token store failure") } } // failingForgejoTokenStore wraps mockUserStore but fails SaveForgejoToken. type failingForgejoTokenStore struct { *mockUserStore } func (f *failingForgejoTokenStore) SaveForgejoToken(_ context.Context, _, _ string) error { return fmt.Errorf("db connection lost") }