dev-pod-api-build/internal/api/forgejo_sync_test.go
2026-04-16 04:16:36 +00:00

281 lines
7.8 KiB
Go

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")
}