build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
281
internal/api/forgejo_sync_test.go
Normal file
281
internal/api/forgejo_sync_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue