281 lines
7.8 KiB
Go
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")
|
|
}
|