build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
BIN
internal/api/._auth_exchange.go
Normal file
BIN
internal/api/._auth_exchange.go
Normal file
Binary file not shown.
BIN
internal/api/._billing.go
Normal file
BIN
internal/api/._billing.go
Normal file
Binary file not shown.
BIN
internal/api/._billing_test.go
Normal file
BIN
internal/api/._billing_test.go
Normal file
Binary file not shown.
BIN
internal/api/._cluster.go
Normal file
BIN
internal/api/._cluster.go
Normal file
Binary file not shown.
BIN
internal/api/._cluster_test.go
Normal file
BIN
internal/api/._cluster_test.go
Normal file
Binary file not shown.
BIN
internal/api/._forgejo_sync_test.go
Normal file
BIN
internal/api/._forgejo_sync_test.go
Normal file
Binary file not shown.
BIN
internal/api/._middleware.go
Normal file
BIN
internal/api/._middleware.go
Normal file
Binary file not shown.
BIN
internal/api/._middleware_test.go
Normal file
BIN
internal/api/._middleware_test.go
Normal file
Binary file not shown.
BIN
internal/api/._pods.go
Normal file
BIN
internal/api/._pods.go
Normal file
Binary file not shown.
BIN
internal/api/._pods_test.go
Normal file
BIN
internal/api/._pods_test.go
Normal file
Binary file not shown.
BIN
internal/api/._router.go
Normal file
BIN
internal/api/._router.go
Normal file
Binary file not shown.
BIN
internal/api/._router_test.go
Normal file
BIN
internal/api/._router_test.go
Normal file
Binary file not shown.
BIN
internal/api/._runner_callback.go
Normal file
BIN
internal/api/._runner_callback.go
Normal file
Binary file not shown.
BIN
internal/api/._runner_callback_test.go
Normal file
BIN
internal/api/._runner_callback_test.go
Normal file
Binary file not shown.
BIN
internal/api/._runners.go
Normal file
BIN
internal/api/._runners.go
Normal file
Binary file not shown.
BIN
internal/api/._runners_test.go
Normal file
BIN
internal/api/._runners_test.go
Normal file
Binary file not shown.
BIN
internal/api/._sessionhost.go
Normal file
BIN
internal/api/._sessionhost.go
Normal file
Binary file not shown.
BIN
internal/api/._users.go
Normal file
BIN
internal/api/._users.go
Normal file
Binary file not shown.
BIN
internal/api/._users_test.go
Normal file
BIN
internal/api/._users_test.go
Normal file
Binary file not shown.
BIN
internal/api/._webhooks.go
Normal file
BIN
internal/api/._webhooks.go
Normal file
Binary file not shown.
BIN
internal/api/._webhooks_test.go
Normal file
BIN
internal/api/._webhooks_test.go
Normal file
Binary file not shown.
138
internal/api/auth_exchange.go
Normal file
138
internal/api/auth_exchange.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Package api — JWT auth-exchange endpoints for web-tui (T018).
|
||||
//
|
||||
// The SPA → web-tui-gateway → dev-pod-api chain authenticates as:
|
||||
//
|
||||
// 1. SPA → POST /v1/auth/exchange with Authorization: Bearer <apiKey>.
|
||||
// 2. web-tui-gateway proxies the request to dev-pod-api's
|
||||
// /api/v1/auth/exchange, forwarding the bearer header.
|
||||
// 3. dev-pod-api validates the API key via the existing AuthMiddleware,
|
||||
// reads the authenticated user from the request context, and mints
|
||||
// a short-lived JWT (aud="web-tui", sub=user.ID, ttl=5m).
|
||||
// 4. web-tui-gateway returns the JWT to the SPA.
|
||||
// 5. SPA attaches per-pane WebSockets with Sec-WebSocket-Protocol:
|
||||
// bearer.<jwt>.
|
||||
//
|
||||
// web-tui-gateway verifies the JWT's signature against the JWKS exposed
|
||||
// at /.well-known/jwks.json — which is public (served outside any auth
|
||||
// middleware).
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JWTSigner mints short-lived JWTs used by web-tui's gateway to attach
|
||||
// per-pane WebSockets.
|
||||
type JWTSigner interface {
|
||||
Sign(sub string, ttl time.Duration) (token string, expiresIn int, err error)
|
||||
JWKS() ([]byte, error)
|
||||
}
|
||||
|
||||
// Ed25519Signer is the reference signer. Generate a key once at startup
|
||||
// and persist the seed in a Secret; in dev a freshly-generated keypair
|
||||
// is fine (it causes connected clients to re-auth on restart, by
|
||||
// design).
|
||||
type Ed25519Signer struct {
|
||||
Kid string
|
||||
Private ed25519.PrivateKey
|
||||
Public ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewEd25519SignerFromSeed reconstructs a signer from a 32-byte seed.
|
||||
// Useful when you want stable keys across pod restarts — store the
|
||||
// base64-encoded seed in a Secret and load via env.
|
||||
func NewEd25519SignerFromSeed(kid string, seed []byte) (*Ed25519Signer, error) {
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, errInvalidSeed
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
return &Ed25519Signer{Kid: kid, Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil
|
||||
}
|
||||
|
||||
var errInvalidSeed = jwt.ErrInvalidKey
|
||||
|
||||
// Sign implements JWTSigner.
|
||||
func (s *Ed25519Signer) Sign(sub string, ttl time.Duration) (string, int, error) {
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"aud": "web-tui",
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
"jti": uuid.NewString(),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
tok.Header["kid"] = s.Kid
|
||||
signed, err := tok.SignedString(s.Private)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return signed, int(ttl.Seconds()), nil
|
||||
}
|
||||
|
||||
// JWKS returns the public key in JWK Set form (RFC 7517).
|
||||
func (s *Ed25519Signer) JWKS() ([]byte, error) {
|
||||
body := map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kid": s.Kid,
|
||||
"kty": "OKP",
|
||||
"alg": "EdDSA",
|
||||
"crv": "Ed25519",
|
||||
"use": "sig",
|
||||
"x": base64.RawURLEncoding.EncodeToString(s.Public),
|
||||
}},
|
||||
}
|
||||
return json.Marshal(body)
|
||||
}
|
||||
|
||||
// ---- HTTP handlers ----
|
||||
|
||||
type exchangeBody struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
// handleAuthExchange runs under AuthMiddleware — user is in context by
|
||||
// the time we get here.
|
||||
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
||||
if s.JWTSigner == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "JWT signer not configured")
|
||||
return
|
||||
}
|
||||
u := UserFromContext(r.Context())
|
||||
if u == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
tok, ttl, err := s.JWTSigner.Sign(u.ID, 5*time.Minute)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exchangeBody{Token: tok, ExpiresIn: ttl, Sub: u.ID})
|
||||
}
|
||||
|
||||
// handleJWKS is public (no auth required). web-tui-gateway caches the
|
||||
// response so this endpoint is read rarely.
|
||||
func (s *Server) handleJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
if s.JWTSigner == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "JWT signer not configured")
|
||||
return
|
||||
}
|
||||
body, err := s.JWTSigner.JWKS()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
99
internal/api/billing.go
Normal file
99
internal/api/billing.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetUserUsage(w http.ResponseWriter, r *http.Request) {
|
||||
targetUser := chi.URLParam(r, "user")
|
||||
|
||||
if !canAccess(r, targetUser) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.Users.GetUser(r.Context(), targetUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
usage, err := s.Usage.GetUsage(r.Context(), targetUser, now.Year(), now.Month(), user.Quota.MonthlyPodHours)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get usage")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]*model.UsageSummary{
|
||||
"current_month": usage,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if RoleFromContext(r.Context()) != model.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := s.Users.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
summaries := make([]model.UserUsageSummary, 0, len(users))
|
||||
for _, u := range users {
|
||||
usage, err := s.Usage.GetUsage(r.Context(), u.ID, now.Year(), now.Month(), u.Quota.MonthlyPodHours)
|
||||
if err != nil {
|
||||
s.Logger.Error("failed to get usage for user", "user", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, model.UserUsageSummary{
|
||||
UserID: u.ID,
|
||||
Usage: *usage,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
func (s *Server) handleBillingHistory(w http.ResponseWriter, r *http.Request) {
|
||||
targetUser := chi.URLParam(r, "user")
|
||||
|
||||
if !canAccess(r, targetUser) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.Users.GetUser(r.Context(), targetUser); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
daily, err := s.Usage.GetDailyUsage(r.Context(), targetUser, now.Year(), now.Month())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get usage history")
|
||||
return
|
||||
}
|
||||
if daily == nil {
|
||||
daily = []model.DailyUsage{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, daily)
|
||||
}
|
||||
225
internal/api/billing_test.go
Normal file
225
internal/api/billing_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
// mockUsageStore implements UsageStore for testing.
|
||||
type mockUsageStore struct {
|
||||
mu sync.Mutex
|
||||
starts []usageEvent
|
||||
stops []usageEvent
|
||||
usageResult *model.UsageSummary
|
||||
dailyResult []model.DailyUsage
|
||||
}
|
||||
|
||||
type usageEvent struct {
|
||||
userID string
|
||||
podName string
|
||||
}
|
||||
|
||||
func newMockUsageStore() *mockUsageStore {
|
||||
return &mockUsageStore{
|
||||
usageResult: &model.UsageSummary{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUsageStore) RecordPodStart(_ context.Context, userID, podName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.starts = append(m.starts, usageEvent{userID, podName})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUsageStore) RecordPodStop(_ context.Context, userID, podName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stops = append(m.stops, usageEvent{userID, podName})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUsageStore) RecordResourceSample(_ context.Context, _, _ string, _, _ float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUsageStore) RecordAIRequest(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUsageStore) GetUsage(_ context.Context, _ string, _ int, _ time.Month, _ int) (*model.UsageSummary, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.usageResult, nil
|
||||
}
|
||||
|
||||
func (m *mockUsageStore) GetDailyUsage(_ context.Context, _ string, _ int, _ time.Month) ([]model.DailyUsage, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.dailyResult, nil
|
||||
}
|
||||
|
||||
// --- GET /api/v1/users/{user}/usage ---
|
||||
|
||||
func TestGetUserUsage_Success(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/alice/usage", "", "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]model.UsageSummary
|
||||
decodeJSON(t, rr, &resp)
|
||||
if _, ok := resp["current_month"]; !ok {
|
||||
t.Fatal("response should contain 'current_month' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserUsage_AdminCanViewOther(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
us.addUser(alice)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/alice/usage", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserUsage_Forbidden(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/bob/usage", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserUsage_NotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
// Don't add alice to user store
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/alice/usage", "", "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/billing/summary ---
|
||||
|
||||
func TestBillingSummary_AdminSuccess(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
us.addUser(alice)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/billing/summary", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var summaries []model.UserUsageSummary
|
||||
decodeJSON(t, rr, &summaries)
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("expected 2 user summaries, got %d", len(summaries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingSummary_Forbidden(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/billing/summary", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/billing/{user}/history ---
|
||||
|
||||
func TestBillingHistory_Success(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/billing/alice/history", "", "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var daily []model.DailyUsage
|
||||
decodeJSON(t, rr, &daily)
|
||||
// Empty history returns empty array
|
||||
if daily == nil {
|
||||
t.Fatal("expected non-nil array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingHistory_AdminCanView(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
us.addUser(alice)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/billing/alice/history", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingHistory_Forbidden(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/billing/bob/history", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth integration ---
|
||||
|
||||
func TestBillingRoutes_NoAuth(t *testing.T) {
|
||||
_, _, _, router := newPodTestRouter()
|
||||
|
||||
routes := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/users/alice/usage"},
|
||||
{http.MethodGet, "/api/v1/billing/summary"},
|
||||
{http.MethodGet, "/api/v1/billing/alice/history"},
|
||||
}
|
||||
|
||||
for _, rt := range routes {
|
||||
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
|
||||
rr := doRequest(router, rt.method, rt.path, "", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
internal/api/cluster.go
Normal file
49
internal/api/cluster.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
func (s *Server) handleClusterStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if RoleFromContext(r.Context()) != model.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Cluster == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "cluster info not available")
|
||||
return
|
||||
}
|
||||
|
||||
status, err := s.Cluster.GetClusterStatus(r.Context())
|
||||
if err != nil {
|
||||
s.Logger.Error("failed to get cluster status", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to get cluster status")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (s *Server) handleCacheStats(w http.ResponseWriter, r *http.Request) {
|
||||
if RoleFromContext(r.Context()) != model.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Cluster == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "cluster info not available")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.Cluster.GetCacheStats(r.Context())
|
||||
if err != nil {
|
||||
s.Logger.Error("failed to get cache stats", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to get cache stats")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
268
internal/api/cluster_test.go
Normal file
268
internal/api/cluster_test.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
// mockClusterInfo implements ClusterInfoProvider for testing.
|
||||
type mockClusterInfo struct {
|
||||
status *model.ClusterStatus
|
||||
stats []model.CacheStat
|
||||
statusErr error
|
||||
statsErr error
|
||||
}
|
||||
|
||||
func (m *mockClusterInfo) GetClusterStatus(_ context.Context) (*model.ClusterStatus, error) {
|
||||
if m.statusErr != nil {
|
||||
return nil, m.statusErr
|
||||
}
|
||||
return m.status, nil
|
||||
}
|
||||
|
||||
func (m *mockClusterInfo) GetCacheStats(_ context.Context) ([]model.CacheStat, error) {
|
||||
if m.statsErr != nil {
|
||||
return nil, m.statsErr
|
||||
}
|
||||
return m.stats, nil
|
||||
}
|
||||
|
||||
func newClusterTestRouter(ci ClusterInfoProvider) (*mockKeyValidator, http.Handler) {
|
||||
kv := newMockValidator()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
K8s: newMockPodManager(),
|
||||
Cluster: ci,
|
||||
Users: newMockUserStore(),
|
||||
Usage: newMockUsageStore(),
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
}
|
||||
return kv, NewRouter(srv)
|
||||
}
|
||||
|
||||
// --- GET /api/v1/cluster/status ---
|
||||
|
||||
func TestClusterStatus_AdminSuccess(t *testing.T) {
|
||||
ci := &mockClusterInfo{
|
||||
status: &model.ClusterStatus{
|
||||
Nodes: []model.NodeStatus{
|
||||
{
|
||||
Name: "node-1",
|
||||
Status: "Ready",
|
||||
CPUCapacity: "8",
|
||||
CPUAllocatable: "7800m",
|
||||
MemCapacity: "32Gi",
|
||||
MemAllocatable: "30Gi",
|
||||
CPUUsage: "2500m",
|
||||
MemUsage: "12Gi",
|
||||
},
|
||||
{
|
||||
Name: "node-2",
|
||||
Status: "Ready",
|
||||
CPUCapacity: "8",
|
||||
CPUAllocatable: "7800m",
|
||||
MemCapacity: "32Gi",
|
||||
MemAllocatable: "30Gi",
|
||||
},
|
||||
},
|
||||
Total: model.ResourceSummary{
|
||||
CPUCapacity: "16",
|
||||
CPUAllocatable: "15600m",
|
||||
MemCapacity: "64Gi",
|
||||
MemAllocatable: "60Gi",
|
||||
},
|
||||
},
|
||||
}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var status model.ClusterStatus
|
||||
decodeJSON(t, rr, &status)
|
||||
if len(status.Nodes) != 2 {
|
||||
t.Fatalf("expected 2 nodes, got %d", len(status.Nodes))
|
||||
}
|
||||
if status.Nodes[0].Name != "node-1" {
|
||||
t.Fatalf("expected node-1, got %s", status.Nodes[0].Name)
|
||||
}
|
||||
if status.Nodes[0].CPUUsage != "2500m" {
|
||||
t.Fatalf("expected cpu usage 2500m, got %s", status.Nodes[0].CPUUsage)
|
||||
}
|
||||
if status.Total.CPUCapacity != "16" {
|
||||
t.Fatalf("expected total CPU capacity 16, got %s", status.Total.CPUCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterStatus_ForbiddenForUser(t *testing.T) {
|
||||
ci := &mockClusterInfo{status: &model.ClusterStatus{}}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterStatus_NoAuth(t *testing.T) {
|
||||
ci := &mockClusterInfo{status: &model.ClusterStatus{}}
|
||||
_, router := newClusterTestRouter(ci)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterStatus_Error(t *testing.T) {
|
||||
ci := &mockClusterInfo{statusErr: errors.New("k8s unreachable")}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_admin")
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterStatus_NilCluster(t *testing.T) {
|
||||
kv, router := newClusterTestRouter(nil)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_admin")
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/cache/stats ---
|
||||
|
||||
func TestCacheStats_AdminSuccess(t *testing.T) {
|
||||
ci := &mockClusterInfo{
|
||||
stats: []model.CacheStat{
|
||||
{Name: "verdaccio", PVCName: "verdaccio-storage", Capacity: "10Gi", Status: "Bound"},
|
||||
{Name: "athens", PVCName: "athens-storage", Capacity: "10Gi", Status: "Bound"},
|
||||
{Name: "cargo-proxy", PVCName: "cargo-proxy-cache", Capacity: "10Gi", Status: "Bound"},
|
||||
},
|
||||
}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var stats []model.CacheStat
|
||||
decodeJSON(t, rr, &stats)
|
||||
if len(stats) != 3 {
|
||||
t.Fatalf("expected 3 cache stats, got %d", len(stats))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range stats {
|
||||
names[s.Name] = true
|
||||
if s.Status != "Bound" {
|
||||
t.Fatalf("expected Bound status for %s, got %s", s.Name, s.Status)
|
||||
}
|
||||
if s.Capacity != "10Gi" {
|
||||
t.Fatalf("expected 10Gi capacity for %s, got %s", s.Name, s.Capacity)
|
||||
}
|
||||
}
|
||||
for _, expected := range []string{"verdaccio", "athens", "cargo-proxy"} {
|
||||
if !names[expected] {
|
||||
t.Fatalf("missing cache stat for %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStats_ForbiddenForUser(t *testing.T) {
|
||||
ci := &mockClusterInfo{stats: []model.CacheStat{}}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStats_NoAuth(t *testing.T) {
|
||||
ci := &mockClusterInfo{stats: []model.CacheStat{}}
|
||||
_, router := newClusterTestRouter(ci)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStats_Error(t *testing.T) {
|
||||
ci := &mockClusterInfo{statsErr: errors.New("pvc query failed")}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin")
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStats_NilCluster(t *testing.T) {
|
||||
kv, router := newClusterTestRouter(nil)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin")
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStats_PartialNotFound(t *testing.T) {
|
||||
ci := &mockClusterInfo{
|
||||
stats: []model.CacheStat{
|
||||
{Name: "verdaccio", PVCName: "verdaccio-storage", Capacity: "10Gi", Status: "Bound"},
|
||||
{Name: "athens", PVCName: "athens-storage", Status: "NotFound"},
|
||||
{Name: "cargo-proxy", PVCName: "cargo-proxy-cache", Capacity: "10Gi", Status: "Bound"},
|
||||
},
|
||||
}
|
||||
kv, router := newClusterTestRouter(ci)
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var stats []model.CacheStat
|
||||
decodeJSON(t, rr, &stats)
|
||||
if len(stats) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(stats))
|
||||
}
|
||||
for _, s := range stats {
|
||||
if s.Name == "athens" && s.Status != "NotFound" {
|
||||
t.Fatalf("expected athens NotFound, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
198
internal/api/middleware.go
Normal file
198
internal/api/middleware.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ctxKeyUser contextKey = "user"
|
||||
ctxKeyRole contextKey = "role"
|
||||
)
|
||||
|
||||
// UserFromContext returns the authenticated user from the request context.
|
||||
func UserFromContext(ctx context.Context) *model.User {
|
||||
u, _ := ctx.Value(ctxKeyUser).(*model.User)
|
||||
return u
|
||||
}
|
||||
|
||||
// RoleFromContext returns the authenticated user's role from the request context.
|
||||
func RoleFromContext(ctx context.Context) string {
|
||||
r, _ := ctx.Value(ctxKeyRole).(string)
|
||||
return r
|
||||
}
|
||||
|
||||
// KeyValidator validates an API key and returns the associated user and role.
|
||||
type KeyValidator interface {
|
||||
ValidateKey(ctx context.Context, key string) (*model.User, string, error)
|
||||
}
|
||||
|
||||
// AuthMiddleware returns middleware that validates Bearer tokens via the KeyValidator.
|
||||
func AuthMiddleware(kv KeyValidator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
token, found := strings.CutPrefix(auth, "Bearer ")
|
||||
if !found || token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "invalid authorization format, expected: Bearer <token>")
|
||||
return
|
||||
}
|
||||
|
||||
user, role, err := kv.ValidateKey(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxKeyUser, user)
|
||||
ctx = context.WithValue(ctx, ctxKeyRole, role)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bucket tracks rate limiting state for a single key.
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// RateLimiter implements a per-key token bucket rate limiter.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
rate float64 // tokens per second
|
||||
burst float64 // max tokens
|
||||
nowFunc func() time.Time
|
||||
callCount int // tracks calls for periodic cleanup
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a rate limiter with the given rate (req/s) and burst size.
|
||||
func NewRateLimiter(ratePerSecond, burst float64) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
rate: ratePerSecond,
|
||||
burst: burst,
|
||||
nowFunc: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks if a request for the given key is allowed.
|
||||
func (rl *RateLimiter) Allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := rl.nowFunc()
|
||||
|
||||
// Evict stale buckets every 100 calls to prevent unbounded growth.
|
||||
rl.callCount++
|
||||
if rl.callCount%100 == 0 {
|
||||
const staleThreshold = 10 * time.Minute
|
||||
for k, b := range rl.buckets {
|
||||
if now.Sub(b.lastSeen) > staleThreshold {
|
||||
delete(rl.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b, exists := rl.buckets[key]
|
||||
if !exists {
|
||||
rl.buckets[key] = &bucket{tokens: rl.burst - 1, lastSeen: now}
|
||||
return true
|
||||
}
|
||||
|
||||
elapsed := now.Sub(b.lastSeen).Seconds()
|
||||
b.tokens += elapsed * rl.rate
|
||||
if b.tokens > rl.burst {
|
||||
b.tokens = rl.burst
|
||||
}
|
||||
b.lastSeen = now
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// MaxBodySize returns middleware that limits request body size to the given number of bytes.
|
||||
func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns HTTP middleware that rate-limits by authenticated user ID.
|
||||
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
key := "anonymous"
|
||||
if user != nil {
|
||||
key = user.ID
|
||||
}
|
||||
|
||||
if !rl.Allow(key) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter wraps http.ResponseWriter to capture the status code.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (sw *statusWriter) WriteHeader(code int) {
|
||||
if !sw.wrote {
|
||||
sw.status = code
|
||||
sw.wrote = true
|
||||
}
|
||||
sw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (sw *statusWriter) Write(b []byte) (int, error) {
|
||||
if !sw.wrote {
|
||||
sw.status = http.StatusOK
|
||||
sw.wrote = true
|
||||
}
|
||||
return sw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// RequestLogger returns middleware that logs each request using slog.
|
||||
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
logger.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
293
internal/api/middleware_test.go
Normal file
293
internal/api/middleware_test.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
// mockKeyValidator implements KeyValidator for testing.
|
||||
type mockKeyValidator struct {
|
||||
users map[string]struct {
|
||||
user *model.User
|
||||
role string
|
||||
}
|
||||
}
|
||||
|
||||
func newMockValidator() *mockKeyValidator {
|
||||
return &mockKeyValidator{
|
||||
users: make(map[string]struct {
|
||||
user *model.User
|
||||
role string
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockKeyValidator) addKey(key string, user *model.User, role string) {
|
||||
m.users[key] = struct {
|
||||
user *model.User
|
||||
role string
|
||||
}{user: user, role: role}
|
||||
}
|
||||
|
||||
func (m *mockKeyValidator) ValidateKey(_ context.Context, key string) (*model.User, string, error) {
|
||||
entry, ok := m.users[key]
|
||||
if !ok {
|
||||
return nil, "", errors.New("invalid key")
|
||||
}
|
||||
return entry.user, entry.role, nil
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_ValidKey(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_valid123", user, model.RoleUser)
|
||||
|
||||
var capturedUser *model.User
|
||||
var capturedRole string
|
||||
handler := AuthMiddleware(kv)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedUser = UserFromContext(r.Context())
|
||||
capturedRole = RoleFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer dpk_valid123")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
if capturedUser == nil || capturedUser.ID != "alice" {
|
||||
t.Fatalf("expected user alice, got %v", capturedUser)
|
||||
}
|
||||
if capturedRole != model.RoleUser {
|
||||
t.Fatalf("expected role user, got %s", capturedRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_AdminKey(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin456", admin, model.RoleAdmin)
|
||||
|
||||
var capturedRole string
|
||||
handler := AuthMiddleware(kv)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedRole = RoleFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer dpk_admin456")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
if capturedRole != model.RoleAdmin {
|
||||
t.Fatalf("expected role admin, got %s", capturedRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_MissingHeader(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
handler := AuthMiddleware(kv)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_InvalidFormat(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
handler := AuthMiddleware(kv)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not be called")
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"no bearer prefix", "dpk_abc123"},
|
||||
{"basic auth", "Basic dXNlcjpwYXNz"},
|
||||
{"empty bearer", "Bearer "},
|
||||
{"bearer lowercase", "bearer dpk_abc123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", tt.value)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_InvalidKey(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
handler := AuthMiddleware(kv)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer dpk_doesnotexist")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_UnderLimit(t *testing.T) {
|
||||
rl := NewRateLimiter(1.0, 60)
|
||||
for i := range 60 {
|
||||
if !rl.Allow("user1") {
|
||||
t.Fatalf("request %d should be allowed (under burst limit)", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_OverLimit(t *testing.T) {
|
||||
rl := NewRateLimiter(1.0, 5)
|
||||
|
||||
// Exhaust the burst
|
||||
for range 5 {
|
||||
rl.Allow("user1")
|
||||
}
|
||||
|
||||
// Next request should be rejected
|
||||
if rl.Allow("user1") {
|
||||
t.Fatal("request should be rejected after burst exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_DifferentKeys(t *testing.T) {
|
||||
rl := NewRateLimiter(1.0, 2)
|
||||
|
||||
// Exhaust user1's burst
|
||||
rl.Allow("user1")
|
||||
rl.Allow("user1")
|
||||
if rl.Allow("user1") {
|
||||
t.Fatal("user1 should be rate limited")
|
||||
}
|
||||
|
||||
// user2 should still be allowed
|
||||
if !rl.Allow("user2") {
|
||||
t.Fatal("user2 should not be affected by user1's rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Refill(t *testing.T) {
|
||||
rl := NewRateLimiter(1.0, 2)
|
||||
|
||||
// Use a controllable time function
|
||||
now := time.Now()
|
||||
rl.nowFunc = func() time.Time { return now }
|
||||
|
||||
// Exhaust burst
|
||||
rl.Allow("user1")
|
||||
rl.Allow("user1")
|
||||
if rl.Allow("user1") {
|
||||
t.Fatal("should be rate limited")
|
||||
}
|
||||
|
||||
// Advance time by 1 second (1 token refill at 1/s)
|
||||
now = now.Add(1 * time.Second)
|
||||
if !rl.Allow("user1") {
|
||||
t.Fatal("should be allowed after token refill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Middleware(t *testing.T) {
|
||||
rl := NewRateLimiter(1.0, 2)
|
||||
|
||||
handlerCalls := 0
|
||||
handler := rl.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Set up authenticated context
|
||||
user := &model.User{ID: "alice"}
|
||||
for i := range 3 {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ctx := context.WithValue(req.Context(), ctxKeyUser, user)
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if i < 2 {
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: expected 200, got %d", i+1, rr.Code)
|
||||
}
|
||||
} else {
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d: expected 429, got %d", i+1, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if handlerCalls != 2 {
|
||||
t.Fatalf("expected 2 handler calls, got %d", handlerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogger(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := RequestLogger(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusWriter_DefaultStatus(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := &statusWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
sw.Write([]byte("hello"))
|
||||
if sw.status != http.StatusOK {
|
||||
t.Fatalf("expected default 200, got %d", sw.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusWriter_ExplicitStatus(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := &statusWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
sw.WriteHeader(http.StatusNotFound)
|
||||
if sw.status != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", sw.status)
|
||||
}
|
||||
|
||||
// Second WriteHeader should be ignored
|
||||
sw.WriteHeader(http.StatusOK)
|
||||
if sw.status != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 after double write, got %d", sw.status)
|
||||
}
|
||||
}
|
||||
410
internal/api/pods.go
Normal file
410
internal/api/pods.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
// normalizeTools applies the same speckit auto-add logic as dev-pod-create.sh:
|
||||
// empty → "speckit", "none" → "", otherwise auto-prepend speckit if missing.
|
||||
func normalizeTools(tools string) string {
|
||||
if tools == "none" {
|
||||
return ""
|
||||
}
|
||||
if tools == "" {
|
||||
return "speckit"
|
||||
}
|
||||
for _, t := range strings.Split(tools, ",") {
|
||||
name, _, _ := strings.Cut(strings.TrimSpace(t), "@")
|
||||
if name == "speckit" {
|
||||
return tools
|
||||
}
|
||||
}
|
||||
return "speckit," + tools
|
||||
}
|
||||
|
||||
func (s *Server) handleCreatePod(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreatePodRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authUser := UserFromContext(r.Context())
|
||||
role := RoleFromContext(r.Context())
|
||||
if role != model.RoleAdmin && authUser.ID != req.User {
|
||||
writeError(w, http.StatusForbidden, "cannot create pods for other users")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser, err := s.Users.GetUser(r.Context(), req.User)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check monthly pod-hours budget
|
||||
if s.Usage != nil {
|
||||
now := time.Now()
|
||||
usage, usageErr := s.Usage.GetUsage(r.Context(), req.User, now.Year(), now.Month(), targetUser.Quota.MonthlyPodHours)
|
||||
if usageErr != nil {
|
||||
s.Logger.Error("failed to check usage", "user", req.User, "error", usageErr)
|
||||
} else if usage.BudgetUsedPct >= 100 {
|
||||
writeError(w, http.StatusTooManyRequests, "monthly pod-hours budget exceeded")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cpuReq := req.CPUReq
|
||||
if cpuReq == "" {
|
||||
cpuReq = "2"
|
||||
}
|
||||
cpuLimit := req.CPULimit
|
||||
if cpuLimit == "" {
|
||||
cpuLimit = cpuReq
|
||||
}
|
||||
memReq := req.MemReq
|
||||
if memReq == "" {
|
||||
memReq = "4Gi"
|
||||
}
|
||||
memLimit := req.MemLimit
|
||||
if memLimit == "" {
|
||||
memLimit = memReq
|
||||
}
|
||||
|
||||
var forgejoToken string
|
||||
if tok, tokErr := s.Users.GetForgejoToken(r.Context(), req.User); tokErr == nil {
|
||||
forgejoToken = tok
|
||||
}
|
||||
|
||||
// Persist tailscale key for future pod updates (best-effort)
|
||||
if req.TailscaleKey != "" {
|
||||
if saveErr := s.Users.SaveTailscaleKey(r.Context(), req.User, req.TailscaleKey); saveErr != nil {
|
||||
s.Logger.Error("failed to save tailscale key", "user", req.User, "error", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
pod, err := s.K8s.CreatePod(r.Context(), k8s.CreatePodOpts{
|
||||
User: req.User,
|
||||
Pod: req.Pod,
|
||||
Tools: normalizeTools(req.Tools),
|
||||
Task: req.Task,
|
||||
CPUReq: cpuReq,
|
||||
CPULimit: cpuLimit,
|
||||
MemReq: memReq,
|
||||
MemLimit: memLimit,
|
||||
MaxConcurrentPods: targetUser.Quota.MaxConcurrentPods,
|
||||
MaxCPUPerPod: targetUser.Quota.MaxCPUPerPod,
|
||||
MaxRAMGBPerPod: targetUser.Quota.MaxRAMGBPerPod,
|
||||
ForgejoToken: forgejoToken,
|
||||
TailscaleKey: req.TailscaleKey,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, k8s.ErrPodAlreadyExists):
|
||||
writeError(w, http.StatusConflict, "pod already exists")
|
||||
case errors.Is(err, k8s.ErrQuotaExceeded):
|
||||
writeError(w, http.StatusTooManyRequests, err.Error())
|
||||
default:
|
||||
s.Logger.Error("failed to create pod", "user", req.User, "pod", req.Pod, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create pod")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Record pod start for usage tracking
|
||||
if s.Usage != nil {
|
||||
if recErr := s.Usage.RecordPodStart(r.Context(), req.User, req.Pod); recErr != nil {
|
||||
s.Logger.Error("failed to record pod start", "user", req.User, "pod", req.Pod, "error", recErr)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, pod)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePod(w http.ResponseWriter, r *http.Request) {
|
||||
user := chi.URLParam(r, "user")
|
||||
pod := chi.URLParam(r, "pod")
|
||||
|
||||
if !canAccess(r, user) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
err := s.K8s.DeletePod(r.Context(), user, pod)
|
||||
if err != nil {
|
||||
if errors.Is(err, k8s.ErrPodNotFound) {
|
||||
writeError(w, http.StatusNotFound, "pod not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete pod")
|
||||
return
|
||||
}
|
||||
|
||||
// Record pod stop for usage tracking
|
||||
if s.Usage != nil {
|
||||
if recErr := s.Usage.RecordPodStop(r.Context(), user, pod); recErr != nil {
|
||||
s.Logger.Error("failed to record pod stop", "user", user, "pod", pod, "error", recErr)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAllPods(w http.ResponseWriter, r *http.Request) {
|
||||
user := chi.URLParam(r, "user")
|
||||
|
||||
if !canAccess(r, user) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
// List pods before deletion to record stops
|
||||
var podNames []string
|
||||
if s.Usage != nil {
|
||||
if pods, listErr := s.K8s.ListPods(r.Context(), user); listErr == nil {
|
||||
for _, p := range pods {
|
||||
podNames = append(podNames, p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.K8s.DeleteAllPods(r.Context(), user); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete pods")
|
||||
return
|
||||
}
|
||||
|
||||
// Record pod stops for usage tracking
|
||||
if s.Usage != nil {
|
||||
for _, name := range podNames {
|
||||
if recErr := s.Usage.RecordPodStop(r.Context(), user, name); recErr != nil {
|
||||
s.Logger.Error("failed to record pod stop", "user", user, "pod", name, "error", recErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleListAllPods(w http.ResponseWriter, r *http.Request) {
|
||||
authUser := UserFromContext(r.Context())
|
||||
role := RoleFromContext(r.Context())
|
||||
|
||||
if role == model.RoleAdmin {
|
||||
users, err := s.Users.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
var allPods []model.Pod
|
||||
for _, u := range users {
|
||||
pods, err := s.K8s.ListPods(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
s.Logger.Error("failed to list pods for user", "user", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
allPods = append(allPods, pods...)
|
||||
}
|
||||
if allPods == nil {
|
||||
allPods = []model.Pod{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, allPods)
|
||||
return
|
||||
}
|
||||
|
||||
pods, err := s.K8s.ListPods(r.Context(), authUser.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pods")
|
||||
return
|
||||
}
|
||||
if pods == nil {
|
||||
pods = []model.Pod{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pods)
|
||||
}
|
||||
|
||||
func (s *Server) handleListUserPods(w http.ResponseWriter, r *http.Request) {
|
||||
user := chi.URLParam(r, "user")
|
||||
|
||||
if !canAccess(r, user) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
pods, err := s.K8s.ListPods(r.Context(), user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pods")
|
||||
return
|
||||
}
|
||||
if pods == nil {
|
||||
pods = []model.Pod{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pods)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetPod(w http.ResponseWriter, r *http.Request) {
|
||||
user := chi.URLParam(r, "user")
|
||||
pod := chi.URLParam(r, "pod")
|
||||
|
||||
if !canAccess(r, user) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.K8s.GetPod(r.Context(), user, pod)
|
||||
if err != nil {
|
||||
if errors.Is(err, k8s.ErrPodNotFound) {
|
||||
writeError(w, http.StatusNotFound, "pod not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get pod")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePod(w http.ResponseWriter, r *http.Request) {
|
||||
user := chi.URLParam(r, "user")
|
||||
pod := chi.URLParam(r, "pod")
|
||||
|
||||
if !canAccess(r, user) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.UpdatePodRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := s.K8s.GetPod(r.Context(), user, pod)
|
||||
if err != nil {
|
||||
if errors.Is(err, k8s.ErrPodNotFound) {
|
||||
writeError(w, http.StatusNotFound, "pod not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get pod")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser, err := s.Users.GetUser(r.Context(), user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
tools := existing.Tools
|
||||
if req.Tools != nil {
|
||||
tools = normalizeTools(*req.Tools)
|
||||
}
|
||||
cpuReq := existing.CPUReq
|
||||
if req.CPUReq != nil {
|
||||
cpuReq = *req.CPUReq
|
||||
}
|
||||
cpuLimit := existing.CPULimit
|
||||
if req.CPULimit != nil {
|
||||
cpuLimit = *req.CPULimit
|
||||
}
|
||||
memReq := existing.MemReq
|
||||
if req.MemReq != nil {
|
||||
memReq = *req.MemReq
|
||||
}
|
||||
memLimit := existing.MemLimit
|
||||
if req.MemLimit != nil {
|
||||
memLimit = *req.MemLimit
|
||||
}
|
||||
task := existing.Task
|
||||
if req.Task != nil {
|
||||
task = *req.Task
|
||||
}
|
||||
|
||||
// Validate per-pod resource quota before deleting the existing pod to avoid
|
||||
// destroying it when the new values would be rejected.
|
||||
if err := k8s.ValidatePodQuota(cpuLimit, targetUser.Quota.MaxCPUPerPod, memLimit, targetUser.Quota.MaxRAMGBPerPod); err != nil {
|
||||
if errors.Is(err, k8s.ErrQuotaExceeded) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to validate quota")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.K8s.DeletePod(r.Context(), user, pod); err != nil && !errors.Is(err, k8s.ErrPodNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete existing pod")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Usage != nil {
|
||||
if recErr := s.Usage.RecordPodStop(r.Context(), user, pod); recErr != nil {
|
||||
s.Logger.Error("failed to record pod stop", "user", user, "pod", pod, "error", recErr)
|
||||
}
|
||||
}
|
||||
|
||||
var updateForgejoToken string
|
||||
if tok, tokErr := s.Users.GetForgejoToken(r.Context(), user); tokErr == nil {
|
||||
updateForgejoToken = tok
|
||||
}
|
||||
|
||||
var updateTailscaleKey string
|
||||
if key, keyErr := s.Users.GetTailscaleKey(r.Context(), user); keyErr == nil {
|
||||
updateTailscaleKey = key
|
||||
}
|
||||
|
||||
newPod, err := s.K8s.CreatePod(r.Context(), k8s.CreatePodOpts{
|
||||
User: user,
|
||||
Pod: pod,
|
||||
Tools: tools,
|
||||
Task: task,
|
||||
CPUReq: cpuReq,
|
||||
CPULimit: cpuLimit,
|
||||
MemReq: memReq,
|
||||
MemLimit: memLimit,
|
||||
MaxConcurrentPods: targetUser.Quota.MaxConcurrentPods,
|
||||
MaxCPUPerPod: targetUser.Quota.MaxCPUPerPod,
|
||||
MaxRAMGBPerPod: targetUser.Quota.MaxRAMGBPerPod,
|
||||
ForgejoToken: updateForgejoToken,
|
||||
TailscaleKey: updateTailscaleKey,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to recreate pod")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Usage != nil {
|
||||
if recErr := s.Usage.RecordPodStart(r.Context(), user, pod); recErr != nil {
|
||||
s.Logger.Error("failed to record pod start", "user", user, "pod", pod, "error", recErr)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, newPod)
|
||||
}
|
||||
|
||||
// canAccess checks if the authenticated user can operate on the target user's resources.
|
||||
func canAccess(r *http.Request, targetUser string) bool {
|
||||
role := RoleFromContext(r.Context())
|
||||
if role == model.RoleAdmin {
|
||||
return true
|
||||
}
|
||||
authUser := UserFromContext(r.Context())
|
||||
return authUser != nil && authUser.ID == targetUser
|
||||
}
|
||||
900
internal/api/pods_test.go
Normal file
900
internal/api/pods_test.go
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
// mockPodManager implements PodManager for testing.
|
||||
type mockPodManager struct {
|
||||
mu sync.Mutex
|
||||
pods map[string]map[string]*model.Pod // user -> pod name -> Pod
|
||||
deleteAllPodsErr error // if set, DeleteAllPods returns this
|
||||
}
|
||||
|
||||
func newMockPodManager() *mockPodManager {
|
||||
return &mockPodManager{
|
||||
pods: make(map[string]map[string]*model.Pod),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPodManager) addPod(p *model.Pod) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.pods[p.User] == nil {
|
||||
m.pods[p.User] = make(map[string]*model.Pod)
|
||||
}
|
||||
m.pods[p.User][p.Name] = p
|
||||
}
|
||||
|
||||
func (m *mockPodManager) CreatePod(_ context.Context, opts k8s.CreatePodOpts) (*model.Pod, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if opts.MaxConcurrentPods > 0 && len(m.pods[opts.User]) >= opts.MaxConcurrentPods {
|
||||
return nil, k8s.ErrQuotaExceeded
|
||||
}
|
||||
if m.pods[opts.User] != nil {
|
||||
if _, exists := m.pods[opts.User][opts.Pod]; exists {
|
||||
return nil, k8s.ErrPodAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
pod := &model.Pod{
|
||||
User: opts.User,
|
||||
Name: opts.Pod,
|
||||
Tools: opts.Tools,
|
||||
CPUReq: opts.CPUReq,
|
||||
CPULimit: opts.CPULimit,
|
||||
MemReq: opts.MemReq,
|
||||
MemLimit: opts.MemLimit,
|
||||
Task: opts.Task,
|
||||
Status: "Pending",
|
||||
URL: fmt.Sprintf("https://test.dev/@%s/%s/", opts.User, opts.Pod),
|
||||
}
|
||||
if m.pods[opts.User] == nil {
|
||||
m.pods[opts.User] = make(map[string]*model.Pod)
|
||||
}
|
||||
m.pods[opts.User][opts.Pod] = pod
|
||||
return pod, nil
|
||||
}
|
||||
|
||||
func (m *mockPodManager) DeletePod(_ context.Context, user, pod string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.pods[user] == nil {
|
||||
return k8s.ErrPodNotFound
|
||||
}
|
||||
if _, exists := m.pods[user][pod]; !exists {
|
||||
return k8s.ErrPodNotFound
|
||||
}
|
||||
delete(m.pods[user], pod)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPodManager) DeleteAllPods(_ context.Context, user string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.deleteAllPodsErr != nil {
|
||||
return m.deleteAllPodsErr
|
||||
}
|
||||
delete(m.pods, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPodManager) ListPods(_ context.Context, user string) ([]model.Pod, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var result []model.Pod
|
||||
for _, p := range m.pods[user] {
|
||||
result = append(result, *p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockPodManager) GetPod(_ context.Context, user, pod string) (*model.Pod, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.pods[user] == nil {
|
||||
return nil, k8s.ErrPodNotFound
|
||||
}
|
||||
p, exists := m.pods[user][pod]
|
||||
if !exists {
|
||||
return nil, k8s.ErrPodNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// mockUserStore implements UserStore for testing.
|
||||
type mockUserStore struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*model.User
|
||||
apiKeys map[string]string // keyHash -> userID
|
||||
forgejoTokens map[string]string // userID -> token
|
||||
tailscaleKeys map[string]string // userID -> key
|
||||
createAPIKeyErr error // if set, CreateAPIKey returns this
|
||||
}
|
||||
|
||||
func newMockUserStore() *mockUserStore {
|
||||
return &mockUserStore{
|
||||
users: make(map[string]*model.User),
|
||||
apiKeys: make(map[string]string),
|
||||
forgejoTokens: make(map[string]string),
|
||||
tailscaleKeys: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserStore) addUser(u *model.User) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.users[u.ID] = u
|
||||
}
|
||||
|
||||
func (m *mockUserStore) GetUser(_ context.Context, id string) (*model.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("user %q: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) ListUsers(_ context.Context) ([]model.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var result []model.User
|
||||
for _, u := range m.users {
|
||||
result = append(result, *u)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) CreateUser(_ context.Context, id string, quota model.Quota) (*model.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.users[id]; exists {
|
||||
return nil, fmt.Errorf("user %q: %w", id, store.ErrDuplicate)
|
||||
}
|
||||
u := &model.User{ID: id, Quota: quota}
|
||||
m.users[id] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) DeleteUser(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.users[id]; !exists {
|
||||
return fmt.Errorf("user %q: %w", id, store.ErrNotFound)
|
||||
}
|
||||
delete(m.users, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) UpdateQuotas(_ context.Context, id string, req model.UpdateQuotasRequest) (*model.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, exists := m.users[id]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("user %q: %w", id, store.ErrNotFound)
|
||||
}
|
||||
if req.MaxConcurrentPods != nil {
|
||||
u.Quota.MaxConcurrentPods = *req.MaxConcurrentPods
|
||||
}
|
||||
if req.MaxCPUPerPod != nil {
|
||||
u.Quota.MaxCPUPerPod = *req.MaxCPUPerPod
|
||||
}
|
||||
if req.MaxRAMGBPerPod != nil {
|
||||
u.Quota.MaxRAMGBPerPod = *req.MaxRAMGBPerPod
|
||||
}
|
||||
if req.MonthlyPodHours != nil {
|
||||
u.Quota.MonthlyPodHours = *req.MonthlyPodHours
|
||||
}
|
||||
if req.MonthlyAIRequests != nil {
|
||||
u.Quota.MonthlyAIRequests = *req.MonthlyAIRequests
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) CreateAPIKey(_ context.Context, userID, _ string, keyHash string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.createAPIKeyErr != nil {
|
||||
return m.createAPIKeyErr
|
||||
}
|
||||
m.apiKeys[keyHash] = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) SaveForgejoToken(_ context.Context, userID, token string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.users[userID]; !exists {
|
||||
return fmt.Errorf("user %q: %w", userID, store.ErrNotFound)
|
||||
}
|
||||
m.forgejoTokens[userID] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) GetForgejoToken(_ context.Context, userID string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
token, exists := m.forgejoTokens[userID]
|
||||
if !exists {
|
||||
return "", nil
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) SaveTailscaleKey(_ context.Context, userID, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.users[userID]; !exists {
|
||||
return fmt.Errorf("user %q: %w", userID, store.ErrNotFound)
|
||||
}
|
||||
m.tailscaleKeys[userID] = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserStore) GetTailscaleKey(_ context.Context, userID string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key, exists := m.tailscaleKeys[userID]
|
||||
if !exists {
|
||||
return "", nil
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// mockGenerateKey returns a predictable API key for testing.
|
||||
func mockGenerateKey() (string, string, error) {
|
||||
return "dpk_testgeneratedkey123456", "hash_testgeneratedkey123456", nil
|
||||
}
|
||||
|
||||
// newPodTestRouter creates a fully-wired test router with all mock dependencies.
|
||||
func newPodTestRouter() (*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(),
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
}
|
||||
return kv, pm, us, NewRouter(srv)
|
||||
}
|
||||
|
||||
func doRequest(router http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
|
||||
var bodyReader io.Reader
|
||||
if body != "" {
|
||||
bodyReader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bodyReader)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, rr *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(rr.Body).Decode(v); err != nil {
|
||||
t.Fatalf("decode response: %v, body: %s", err, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST /api/v1/pods ---
|
||||
|
||||
func TestCreatePod_Success(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","pod":"main","tools":"go,rust"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pod model.Pod
|
||||
decodeJSON(t, rr, &pod)
|
||||
if pod.Name != "main" {
|
||||
t.Fatalf("expected pod name 'main', got %q", pod.Name)
|
||||
}
|
||||
if pod.Tools != "speckit,go,rust" {
|
||||
t.Fatalf("expected tools 'speckit,go,rust', got %q", pod.Tools)
|
||||
}
|
||||
if pod.CPUReq != "2" {
|
||||
t.Fatalf("expected default cpu_req '2', got %q", pod.CPUReq)
|
||||
}
|
||||
if pod.MemReq != "4Gi" {
|
||||
t.Fatalf("expected default mem_req '4Gi', got %q", pod.MemReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_WithResources(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","pod":"heavy","tools":"go","cpu_req":"4","cpu_limit":"8","mem_req":"8Gi","mem_limit":"16Gi"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pod model.Pod
|
||||
decodeJSON(t, rr, &pod)
|
||||
if pod.CPUReq != "4" || pod.CPULimit != "8" {
|
||||
t.Fatalf("expected cpu 4/8, got %s/%s", pod.CPUReq, pod.CPULimit)
|
||||
}
|
||||
if pod.MemReq != "8Gi" || pod.MemLimit != "16Gi" {
|
||||
t.Fatalf("expected mem 8Gi/16Gi, got %s/%s", pod.MemReq, pod.MemLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_InvalidBody(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", "not json", "dpk_test")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_ValidationError(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
// Missing required field
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", `{"user":"alice"}`, "dpk_test")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_ForbiddenOtherUser(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"bob","pod":"main","tools":"go"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_AdminCanCreateForOthers(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
bob := &model.User{ID: "bob", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
us.addUser(bob)
|
||||
|
||||
body := `{"user":"bob","pod":"main","tools":"go"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_admin")
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_UserNotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
// Don't add alice to user store
|
||||
|
||||
body := `{"user":"alice","pod":"main","tools":"go"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_QuotaExceeded(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
quota := model.DefaultQuota()
|
||||
quota.MaxConcurrentPods = 1
|
||||
user := &model.User{ID: "alice", Quota: quota}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
// Pre-add an existing pod
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "existing"})
|
||||
|
||||
body := `{"user":"alice","pod":"second","tools":"go"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_Duplicate(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
|
||||
body := `{"user":"alice","pod":"main","tools":"go"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_BudgetExceeded(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
pm := newMockPodManager()
|
||||
us := newMockUserStore()
|
||||
usage := newMockUsageStore()
|
||||
usage.usageResult = &model.UsageSummary{
|
||||
PodHours: 500,
|
||||
BudgetUsedPct: 100, // 100% = budget exhausted
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
K8s: pm,
|
||||
Users: us,
|
||||
Usage: usage,
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
}
|
||||
router := NewRouter(srv)
|
||||
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","pod":"main","tools":"go"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/v1/pods/{user}/{pod} ---
|
||||
|
||||
func TestDeletePod_Success(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/pods/alice/main", "", "dpk_test")
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePod_NotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/pods/alice/nonexistent", "", "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePod_ForbiddenOtherUser(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "bob", Name: "main"})
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/pods/bob/main", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePod_AdminCanDeleteOthers(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
pm.addPod(&model.Pod{User: "bob", Name: "main"})
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/pods/bob/main", "", "dpk_admin")
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/v1/pods/{user} ---
|
||||
|
||||
func TestDeleteAllPods_Success(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "test"})
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/pods/alice", "", "dpk_test")
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify pods are gone
|
||||
listRR := doRequest(router, http.MethodGet, "/api/v1/pods/alice", "", "dpk_test")
|
||||
var pods []model.Pod
|
||||
decodeJSON(t, listRR, &pods)
|
||||
if len(pods) != 0 {
|
||||
t.Fatalf("expected 0 pods after delete all, got %d", len(pods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAllPods_Forbidden(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/pods/bob", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/pods ---
|
||||
|
||||
func TestListAllPods_UserSeesOwnOnly(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
pm.addPod(&model.Pod{User: "bob", Name: "work"})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods", "", "dpk_alice")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pods []model.Pod
|
||||
decodeJSON(t, rr, &pods)
|
||||
if len(pods) != 1 {
|
||||
t.Fatalf("expected 1 pod, got %d", len(pods))
|
||||
}
|
||||
if pods[0].Name != "main" {
|
||||
t.Fatalf("expected pod 'main', got %q", pods[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllPods_AdminSeesAll(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
bob := &model.User{ID: "bob", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
us.addUser(alice)
|
||||
us.addUser(bob)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
pm.addPod(&model.Pod{User: "bob", Name: "work"})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pods []model.Pod
|
||||
decodeJSON(t, rr, &pods)
|
||||
if len(pods) != 2 {
|
||||
t.Fatalf("expected 2 pods, got %d", len(pods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllPods_Empty(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods", "", "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var pods []model.Pod
|
||||
decodeJSON(t, rr, &pods)
|
||||
if len(pods) != 0 {
|
||||
t.Fatalf("expected 0 pods, got %d", len(pods))
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/pods/{user} ---
|
||||
|
||||
func TestListUserPods_Success(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go"})
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "test", Tools: "rust"})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods/alice", "", "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pods []model.Pod
|
||||
decodeJSON(t, rr, &pods)
|
||||
if len(pods) != 2 {
|
||||
t.Fatalf("expected 2 pods, got %d", len(pods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUserPods_Forbidden(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods/bob", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/pods/{user}/{pod} ---
|
||||
|
||||
func TestGetPod_Success(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go,rust", CPUReq: "4", MemReq: "8Gi"})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods/alice/main", "", "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pod model.Pod
|
||||
decodeJSON(t, rr, &pod)
|
||||
if pod.Name != "main" {
|
||||
t.Fatalf("expected pod 'main', got %q", pod.Name)
|
||||
}
|
||||
if pod.Tools != "go,rust" {
|
||||
t.Fatalf("expected tools 'go,rust', got %q", pod.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPod_NotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods/alice/nonexistent", "", "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPod_Forbidden(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "bob", Name: "main"})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/pods/bob/main", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- PATCH /api/v1/pods/{user}/{pod} ---
|
||||
|
||||
func TestUpdatePod_Success(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go", CPUReq: "2", CPULimit: "4", MemReq: "4Gi", MemLimit: "8Gi"})
|
||||
|
||||
body := `{"cpu_limit":"8","mem_limit":"16Gi"}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", body, "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pod model.Pod
|
||||
decodeJSON(t, rr, &pod)
|
||||
if pod.CPULimit != "8" {
|
||||
t.Fatalf("expected cpu_limit '8', got %q", pod.CPULimit)
|
||||
}
|
||||
if pod.MemLimit != "16Gi" {
|
||||
t.Fatalf("expected mem_limit '16Gi', got %q", pod.MemLimit)
|
||||
}
|
||||
// Unchanged fields should be preserved
|
||||
if pod.Tools != "go" {
|
||||
t.Fatalf("expected tools 'go' preserved, got %q", pod.Tools)
|
||||
}
|
||||
if pod.CPUReq != "2" {
|
||||
t.Fatalf("expected cpu_req '2' preserved, got %q", pod.CPUReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePod_NotFound(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"cpu_limit":"8"}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/nonexistent", body, "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePod_Forbidden(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "bob", Name: "main"})
|
||||
|
||||
body := `{"cpu_limit":"8"}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/pods/bob/main", body, "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePod_InvalidBody(t *testing.T) {
|
||||
kv, pm, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", "not json", "dpk_test")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePod_ChangeTools(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go", CPUReq: "2", CPULimit: "4", MemReq: "4Gi", MemLimit: "8Gi"})
|
||||
|
||||
body := `{"tools":"go,rust,node"}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", body, "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var pod model.Pod
|
||||
decodeJSON(t, rr, &pod)
|
||||
if pod.Tools != "speckit,go,rust,node" {
|
||||
t.Fatalf("expected tools 'speckit,go,rust,node', got %q", pod.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth integration ---
|
||||
|
||||
func TestPodRoutes_NoAuth(t *testing.T) {
|
||||
_, _, _, router := newPodTestRouter()
|
||||
|
||||
routes := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/pods"},
|
||||
{http.MethodGet, "/api/v1/pods"},
|
||||
{http.MethodGet, "/api/v1/pods/alice"},
|
||||
{http.MethodGet, "/api/v1/pods/alice/main"},
|
||||
{http.MethodDelete, "/api/v1/pods/alice"},
|
||||
{http.MethodDelete, "/api/v1/pods/alice/main"},
|
||||
{http.MethodPatch, "/api/v1/pods/alice/main"},
|
||||
}
|
||||
|
||||
for _, rt := range routes {
|
||||
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
|
||||
rr := doRequest(router, rt.method, rt.path, "", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePod_WithTailscaleKey(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","pod":"main","tools":"go","tailscale_key":"tskey-auth-abc123"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify tailscale key was saved to user store
|
||||
us.mu.Lock()
|
||||
savedKey := us.tailscaleKeys["alice"]
|
||||
us.mu.Unlock()
|
||||
if savedKey != "tskey-auth-abc123" {
|
||||
t.Fatalf("expected tailscale key 'tskey-auth-abc123' saved, got %q", savedKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePod_PreservesTailscaleKey(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
us.tailscaleKeys["alice"] = "tskey-auth-existing"
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go", CPUReq: "2", CPULimit: "4", MemReq: "4Gi", MemLimit: "8Gi"})
|
||||
|
||||
body := `{"cpu_limit":"8"}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", body, "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify tailscale key is still saved
|
||||
us.mu.Lock()
|
||||
savedKey := us.tailscaleKeys["alice"]
|
||||
us.mu.Unlock()
|
||||
if savedKey != "tskey-auth-existing" {
|
||||
t.Fatalf("expected tailscale key preserved as 'tskey-auth-existing', got %q", savedKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "speckit"},
|
||||
{"none", ""},
|
||||
{"go,rust", "speckit,go,rust"},
|
||||
{"speckit,go", "speckit,go"},
|
||||
{"go,speckit,rust", "go,speckit,rust"},
|
||||
{"go,speckit@1.2", "go,speckit@1.2"},
|
||||
{"speckit", "speckit"},
|
||||
{"bun", "speckit,bun"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeTools(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeTools(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
204
internal/api/router.go
Normal file
204
internal/api/router.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
// PodManager defines Kubernetes pod operations needed by the API handlers.
|
||||
type PodManager interface {
|
||||
CreatePod(ctx context.Context, opts k8s.CreatePodOpts) (*model.Pod, error)
|
||||
DeletePod(ctx context.Context, user, pod string) error
|
||||
DeleteAllPods(ctx context.Context, user string) error
|
||||
ListPods(ctx context.Context, user string) ([]model.Pod, error)
|
||||
GetPod(ctx context.Context, user, pod string) (*model.Pod, error)
|
||||
}
|
||||
|
||||
// UserStore defines user operations needed by the API handlers.
|
||||
type UserStore interface {
|
||||
GetUser(ctx context.Context, id string) (*model.User, error)
|
||||
ListUsers(ctx context.Context) ([]model.User, error)
|
||||
CreateUser(ctx context.Context, id string, quota model.Quota) (*model.User, error)
|
||||
DeleteUser(ctx context.Context, id string) error
|
||||
UpdateQuotas(ctx context.Context, id string, req model.UpdateQuotasRequest) (*model.User, error)
|
||||
CreateAPIKey(ctx context.Context, userID, role, keyHash string) error
|
||||
SaveForgejoToken(ctx context.Context, userID, token string) error
|
||||
GetForgejoToken(ctx context.Context, userID string) (string, error)
|
||||
SaveTailscaleKey(ctx context.Context, userID, key string) error
|
||||
GetTailscaleKey(ctx context.Context, userID string) (string, error)
|
||||
}
|
||||
|
||||
// UsageStore defines usage tracking operations needed by the API handlers.
|
||||
type UsageStore interface {
|
||||
RecordPodStart(ctx context.Context, userID, podName string) error
|
||||
RecordPodStop(ctx context.Context, userID, podName string) error
|
||||
RecordResourceSample(ctx context.Context, userID, podName string, cpuMillicores, memBytes float64) error
|
||||
RecordAIRequest(ctx context.Context, userID string) error
|
||||
GetUsage(ctx context.Context, userID string, year int, month time.Month, monthlyBudget int) (*model.UsageSummary, error)
|
||||
GetDailyUsage(ctx context.Context, userID string, year int, month time.Month) ([]model.DailyUsage, error)
|
||||
}
|
||||
|
||||
// ClusterInfoProvider defines operations for cluster status and cache stats.
|
||||
type ClusterInfoProvider interface {
|
||||
GetClusterStatus(ctx context.Context) (*model.ClusterStatus, error)
|
||||
GetCacheStats(ctx context.Context) ([]model.CacheStat, error)
|
||||
}
|
||||
|
||||
// ForgejoManager handles user lifecycle in the central Forgejo instance.
|
||||
// Optional — if nil, Forgejo integration is skipped.
|
||||
type ForgejoManager interface {
|
||||
CreateForgejoUser(ctx context.Context, username string) (token string, err error)
|
||||
DeleteForgejoUser(ctx context.Context, username string) error
|
||||
GetRepoFileContent(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error)
|
||||
CreateIssueComment(ctx context.Context, owner, repo string, issueNumber int, body string) error
|
||||
}
|
||||
|
||||
// RunnerStore defines runner database operations needed by the API handlers.
|
||||
type RunnerStore interface {
|
||||
CreateRunner(ctx context.Context, r *model.Runner) error
|
||||
GetRunner(ctx context.Context, id string) (*model.Runner, error)
|
||||
ListRunners(ctx context.Context, userFilter string, statusFilter string) ([]model.Runner, error)
|
||||
UpdateRunnerStatus(ctx context.Context, id string, newStatus model.RunnerStatus, forgejoRunnerID string) error
|
||||
DeleteRunner(ctx context.Context, id string) error
|
||||
IsDeliveryProcessed(ctx context.Context, deliveryID string) (bool, error)
|
||||
GetStaleRunners(ctx context.Context, ttl time.Duration) ([]model.Runner, error)
|
||||
}
|
||||
|
||||
// RunnerPodManager defines k8s operations for runner pod lifecycle.
|
||||
type RunnerPodManager interface {
|
||||
CreateRunnerPod(ctx context.Context, opts k8s.CreateRunnerPodOpts) (string, error)
|
||||
DeleteRunnerPod(ctx context.Context, user, podName string) error
|
||||
}
|
||||
|
||||
// KeyGenerator generates a new API key returning (plaintext, hash, error).
|
||||
type KeyGenerator func() (string, string, error)
|
||||
|
||||
// Server holds dependencies for HTTP handlers.
|
||||
type Server struct {
|
||||
Store KeyValidator
|
||||
K8s PodManager
|
||||
Cluster ClusterInfoProvider
|
||||
Users UserStore
|
||||
Usage UsageStore
|
||||
Forgejo ForgejoManager
|
||||
Logger *slog.Logger
|
||||
GenerateKey KeyGenerator
|
||||
Runners RunnerStore
|
||||
RunnerPods RunnerPodManager
|
||||
// JWTSigner, when non-nil, enables the web-tui auth-exchange endpoint
|
||||
// (/api/v1/auth/exchange) and the public JWKS at /.well-known/jwks.json.
|
||||
// Both are optional — leave nil to keep the endpoints returning 503.
|
||||
JWTSigner JWTSigner
|
||||
// SessionHost, when non-nil, enables the web-tui session-host endpoints
|
||||
// (POST /api/v1/pods/session-host, GET /api/v1/pods/session-host/{user}/status).
|
||||
SessionHost SessionHostManager
|
||||
// WebhookSecret is the HMAC-SHA256 secret for validating Forgejo webhook signatures.
|
||||
WebhookSecret string
|
||||
// ForgejoRunnerToken is a pre-generated Forgejo Actions runner registration token.
|
||||
// Generated via `forgejo forgejo-cli actions generate-runner-token` and passed
|
||||
// to all runner pods. The Forgejo REST API does not expose this endpoint.
|
||||
ForgejoRunnerToken string
|
||||
}
|
||||
|
||||
// NewRouter creates a chi router with all middleware and routes.
|
||||
func NewRouter(srv *Server) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(chiMiddleware.RequestID)
|
||||
r.Use(chiMiddleware.RealIP)
|
||||
r.Use(chiMiddleware.Recoverer)
|
||||
r.Use(RequestLogger(srv.Logger))
|
||||
|
||||
// Health check — no auth required
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// JWKS — public, no auth. web-tui-gateway fetches this to verify
|
||||
// the JWTs it receives on WS attachments. Responds 503 when no
|
||||
// JWTSigner is configured so operators notice a misconfiguration.
|
||||
r.Get("/.well-known/jwks.json", srv.handleJWKS)
|
||||
|
||||
// Internal endpoints — cluster-internal only, no auth required.
|
||||
// Runner pods call back to report status transitions.
|
||||
r.Post("/internal/runners/{id}/callback", srv.handleRunnerCallback)
|
||||
|
||||
// Forgejo webhook — HMAC-SHA256 auth, not bearer token.
|
||||
// Registered outside /api/v1 auth group because Forgejo uses HMAC signatures.
|
||||
r.Post("/api/v1/webhooks/forgejo", srv.handleForgejoWebhook)
|
||||
|
||||
// Rate limiter: 60 req/min = 1 req/s, burst of 60
|
||||
limiter := NewRateLimiter(1.0, 60)
|
||||
|
||||
// API routes — auth required
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(MaxBodySize(1 << 20)) // 1 MB
|
||||
r.Use(AuthMiddleware(srv.Store))
|
||||
r.Use(limiter.Middleware)
|
||||
|
||||
// Pod management
|
||||
r.Post("/pods", srv.handleCreatePod)
|
||||
r.Get("/pods", srv.handleListAllPods)
|
||||
r.Get("/pods/{user}", srv.handleListUserPods)
|
||||
r.Get("/pods/{user}/{pod}", srv.handleGetPod)
|
||||
r.Delete("/pods/{user}", srv.handleDeleteAllPods)
|
||||
r.Delete("/pods/{user}/{pod}", srv.handleDeletePod)
|
||||
r.Patch("/pods/{user}/{pod}", srv.handleUpdatePod)
|
||||
|
||||
// User management
|
||||
r.Post("/users", srv.handleCreateUser)
|
||||
r.Get("/users", srv.handleListUsers)
|
||||
r.Get("/users/{user}", srv.handleGetUser)
|
||||
r.Delete("/users/{user}", srv.handleDeleteUser)
|
||||
r.Patch("/users/{user}/quotas", srv.handleUpdateQuotas)
|
||||
|
||||
// Billing & usage
|
||||
r.Get("/users/{user}/usage", srv.handleGetUserUsage)
|
||||
r.Get("/billing/summary", srv.handleBillingSummary)
|
||||
r.Get("/billing/{user}/history", srv.handleBillingHistory)
|
||||
|
||||
// Cluster info
|
||||
r.Get("/cluster/status", srv.handleClusterStatus)
|
||||
r.Get("/cache/stats", srv.handleCacheStats)
|
||||
|
||||
// Runner management
|
||||
r.Post("/runners", srv.handleCreateRunner)
|
||||
r.Get("/runners", srv.handleListRunners)
|
||||
r.Delete("/runners/{id}", srv.handleDeleteRunner)
|
||||
r.Post("/runners/{id}/status", srv.handleUpdateRunnerStatus)
|
||||
|
||||
// web-tui — JWT exchange (auth) + session-host pod lifecycle.
|
||||
// Enabled when the Server has JWTSigner / SessionHost configured.
|
||||
r.Post("/auth/exchange", srv.handleAuthExchange)
|
||||
r.Post("/pods/session-host", srv.handleCreateSessionHost)
|
||||
r.Get("/pods/session-host/{user}/status", srv.handleSessionHostStatus)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// writeError writes a JSON error response.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON response with the given status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
122
internal/api/router_test.go
Normal file
122
internal/api/router_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
func newTestRouter() (*mockKeyValidator, http.Handler) {
|
||||
kv := newMockValidator()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
K8s: newMockPodManager(),
|
||||
Users: newMockUserStore(),
|
||||
Usage: newMockUsageStore(),
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
}
|
||||
return kv, NewRouter(srv)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
_, router := newTestRouter()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
if rr.Body.String() != "ok" {
|
||||
t.Fatalf("expected body 'ok', got %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz_NoAuthRequired(t *testing.T) {
|
||||
_, router := newTestRouter()
|
||||
|
||||
// No Authorization header — should still work
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 without auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRoutes_RequireAuth(t *testing.T) {
|
||||
_, router := newTestRouter()
|
||||
|
||||
routes := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/pods"},
|
||||
{http.MethodPost, "/api/v1/pods"},
|
||||
{http.MethodGet, "/api/v1/pods/alice"},
|
||||
{http.MethodDelete, "/api/v1/pods/alice"},
|
||||
{http.MethodDelete, "/api/v1/pods/alice/main"},
|
||||
{http.MethodPatch, "/api/v1/pods/alice/main"},
|
||||
{http.MethodGet, "/api/v1/users"},
|
||||
{http.MethodPost, "/api/v1/users"},
|
||||
{http.MethodGet, "/api/v1/users/alice"},
|
||||
{http.MethodDelete, "/api/v1/users/alice"},
|
||||
{http.MethodPatch, "/api/v1/users/alice/quotas"},
|
||||
{http.MethodGet, "/api/v1/users/alice/usage"},
|
||||
{http.MethodGet, "/api/v1/billing/summary"},
|
||||
{http.MethodGet, "/api/v1/billing/alice/history"},
|
||||
{http.MethodGet, "/api/v1/cluster/status"},
|
||||
{http.MethodGet, "/api/v1/cache/stats"},
|
||||
}
|
||||
|
||||
for _, rt := range routes {
|
||||
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(rt.method, rt.path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRoutes_ClusterStatus_RequiresAdmin(t *testing.T) {
|
||||
kv, router := newTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test123", user, model.RoleUser)
|
||||
|
||||
// Non-admin user should get 403
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/cluster/status", nil)
|
||||
req.Header.Set("Authorization", "Bearer dpk_test123")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRoutes_NotFound(t *testing.T) {
|
||||
kv, router := newTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test123", user, model.RoleUser)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/nonexistent", nil)
|
||||
req.Header.Set("Authorization", "Bearer dpk_test123")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
55
internal/api/runner_callback.go
Normal file
55
internal/api/runner_callback.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleRunnerCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Runners == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "runner management not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req model.UpdateRunnerStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.Runners.GetRunner(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "runner not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get runner")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Runners.UpdateRunnerStatus(r.Context(), id, req.Status, req.ForgejoRunnerID); err != nil {
|
||||
s.Logger.Error("runner callback status update", "id", id, "status", req.Status, "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.Runners.GetRunner(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get updated runner")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
132
internal/api/runner_callback_test.go
Normal file
132
internal/api/runner_callback_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
func TestRunnerCallback_Success(t *testing.T) {
|
||||
_, _, rs, _, router := newRunnerTestRouter()
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusPodCreating}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"runner_registered","forgejo_runner_id":"fj-42"}`
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/r1/callback", body, "")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.Status != model.RunnerStatusRunnerRegistered {
|
||||
t.Errorf("expected status runner_registered, got %s", runner.Status)
|
||||
}
|
||||
if runner.ForgejoRunnerID != "fj-42" {
|
||||
t.Errorf("expected forgejo_runner_id fj-42, got %s", runner.ForgejoRunnerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerCallback_NoAuth(t *testing.T) {
|
||||
_, _, rs, _, router := newRunnerTestRouter()
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusPodCreating}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"runner_registered"}`
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/r1/callback", body, "")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("internal callback should work without auth, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerCallback_NotFound(t *testing.T) {
|
||||
_, _, _, _, router := newRunnerTestRouter()
|
||||
|
||||
body := `{"status":"runner_registered"}`
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/nonexistent/callback", body, "")
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerCallback_InvalidTransition(t *testing.T) {
|
||||
_, _, rs, _, router := newRunnerTestRouter()
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"completed"}`
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/r1/callback", body, "")
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid transition, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerCallback_InvalidBody(t *testing.T) {
|
||||
_, _, rs, _, router := newRunnerTestRouter()
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusPodCreating}
|
||||
rs.mu.Unlock()
|
||||
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/r1/callback", "not json", "")
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerCallback_CompletedStatus(t *testing.T) {
|
||||
_, _, rs, _, router := newRunnerTestRouter()
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusJobClaimed}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"completed"}`
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/r1/callback", body, "")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.Status != model.RunnerStatusCompleted {
|
||||
t.Errorf("expected status completed, got %s", runner.Status)
|
||||
}
|
||||
if runner.CompletedAt == nil {
|
||||
t.Error("expected completed_at to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerCallback_FailedStatus(t *testing.T) {
|
||||
_, _, rs, _, router := newRunnerTestRouter()
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusRunnerRegistered}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"failed"}`
|
||||
rr := doRequest(router, http.MethodPost, "/internal/runners/r1/callback", body, "")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.Status != model.RunnerStatusFailed {
|
||||
t.Errorf("expected status failed, got %s", runner.Status)
|
||||
}
|
||||
}
|
||||
246
internal/api/runners.go
Normal file
246
internal/api/runners.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleCreateRunner(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Runners == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "runner management not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateRunnerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authUser := UserFromContext(r.Context())
|
||||
role := RoleFromContext(r.Context())
|
||||
if role != model.RoleAdmin && authUser.ID != req.User {
|
||||
writeError(w, http.StatusForbidden, "cannot create runners for other users")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.Users.GetUser(r.Context(), req.User); err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if req.WebhookDeliveryID != "" {
|
||||
isDupe, err := s.Runners.IsDeliveryProcessed(r.Context(), req.WebhookDeliveryID)
|
||||
if err != nil {
|
||||
s.Logger.Error("check delivery dedupe", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to check delivery")
|
||||
return
|
||||
}
|
||||
if isDupe {
|
||||
writeError(w, http.StatusConflict, "webhook delivery already processed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runnerID, err := store.GenerateRunnerID()
|
||||
if err != nil {
|
||||
s.Logger.Error("generate runner id", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate runner id")
|
||||
return
|
||||
}
|
||||
|
||||
branch := req.Branch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
cpuReq := req.CPUReq
|
||||
if cpuReq == "" {
|
||||
cpuReq = "2"
|
||||
}
|
||||
memReq := req.MemReq
|
||||
if memReq == "" {
|
||||
memReq = "4Gi"
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
runner := &model.Runner{
|
||||
ID: runnerID,
|
||||
User: req.User,
|
||||
RepoURL: req.Repo,
|
||||
Branch: branch,
|
||||
Tools: req.Tools,
|
||||
Task: req.Task,
|
||||
Status: model.RunnerStatusReceived,
|
||||
WebhookDeliveryID: req.WebhookDeliveryID,
|
||||
CPUReq: cpuReq,
|
||||
MemReq: memReq,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.Runners.CreateRunner(r.Context(), runner); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeError(w, http.StatusConflict, "runner already exists")
|
||||
return
|
||||
}
|
||||
s.Logger.Error("create runner record", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create runner")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusPodCreating, ""); err != nil {
|
||||
s.Logger.Error("update runner status to pod_creating", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runner status")
|
||||
return
|
||||
}
|
||||
|
||||
if s.RunnerPods != nil {
|
||||
podName, podErr := s.RunnerPods.CreateRunnerPod(r.Context(), k8s.CreateRunnerPodOpts{
|
||||
User: req.User,
|
||||
RunnerID: runnerID,
|
||||
Tools: req.Tools,
|
||||
Task: req.Task,
|
||||
RepoURL: req.Repo,
|
||||
Branch: branch,
|
||||
CPUReq: cpuReq,
|
||||
MemReq: memReq,
|
||||
ForgejoRunnerToken: s.ForgejoRunnerToken,
|
||||
})
|
||||
if podErr != nil {
|
||||
s.Logger.Error("create runner pod", "runner", runnerID, "error", podErr)
|
||||
_ = s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusFailed, "")
|
||||
writeError(w, http.StatusInternalServerError, "failed to create runner pod")
|
||||
return
|
||||
}
|
||||
runner.PodName = podName
|
||||
}
|
||||
|
||||
runner.Status = model.RunnerStatusPodCreating
|
||||
writeJSON(w, http.StatusCreated, runner)
|
||||
}
|
||||
|
||||
func (s *Server) handleListRunners(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Runners == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "runner management not configured")
|
||||
return
|
||||
}
|
||||
|
||||
role := RoleFromContext(r.Context())
|
||||
authUser := UserFromContext(r.Context())
|
||||
|
||||
userFilter := r.URL.Query().Get("user")
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
|
||||
if role != model.RoleAdmin {
|
||||
userFilter = authUser.ID
|
||||
}
|
||||
|
||||
runners, err := s.Runners.ListRunners(r.Context(), userFilter, statusFilter)
|
||||
if err != nil {
|
||||
s.Logger.Error("list runners", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to list runners")
|
||||
return
|
||||
}
|
||||
if runners == nil {
|
||||
runners = []model.Runner{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runners)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteRunner(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Runners == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "runner management not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
runner, err := s.Runners.GetRunner(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "runner not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get runner")
|
||||
return
|
||||
}
|
||||
|
||||
if !canAccess(r, runner.User) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
if runner.PodName != "" && s.RunnerPods != nil {
|
||||
if podErr := s.RunnerPods.DeleteRunnerPod(r.Context(), runner.User, runner.PodName); podErr != nil {
|
||||
s.Logger.Error("delete runner pod", "runner", id, "pod", runner.PodName, "error", podErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Runners.DeleteRunner(r.Context(), id); err != nil {
|
||||
s.Logger.Error("delete runner record", "id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete runner")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateRunnerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Runners == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "runner management not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req model.UpdateRunnerStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
runner, err := s.Runners.GetRunner(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "runner not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get runner")
|
||||
return
|
||||
}
|
||||
|
||||
if !canAccess(r, runner.User) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Runners.UpdateRunnerStatus(r.Context(), id, req.Status, req.ForgejoRunnerID); err != nil {
|
||||
s.Logger.Error("update runner status", "id", id, "status", req.Status, "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.Runners.GetRunner(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get updated runner")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
548
internal/api/runners_test.go
Normal file
548
internal/api/runners_test.go
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
type mockRunnerStore struct {
|
||||
mu sync.Mutex
|
||||
runners map[string]*model.Runner
|
||||
}
|
||||
|
||||
func newMockRunnerStore() *mockRunnerStore {
|
||||
return &mockRunnerStore{runners: make(map[string]*model.Runner)}
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) CreateRunner(_ context.Context, r *model.Runner) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.runners[r.ID]; exists {
|
||||
return fmt.Errorf("runner %q: %w", r.ID, store.ErrDuplicate)
|
||||
}
|
||||
for _, existing := range m.runners {
|
||||
if r.WebhookDeliveryID != "" && existing.WebhookDeliveryID == r.WebhookDeliveryID {
|
||||
return fmt.Errorf("runner %q: %w", r.ID, store.ErrDuplicate)
|
||||
}
|
||||
}
|
||||
copy := *r
|
||||
m.runners[r.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) GetRunner(_ context.Context, id string) (*model.Runner, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.runners[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runner %q: %w", id, store.ErrNotFound)
|
||||
}
|
||||
copy := *r
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) ListRunners(_ context.Context, userFilter, statusFilter string) ([]model.Runner, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var result []model.Runner
|
||||
for _, r := range m.runners {
|
||||
if userFilter != "" && r.User != userFilter {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && string(r.Status) != statusFilter {
|
||||
continue
|
||||
}
|
||||
result = append(result, *r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) UpdateRunnerStatus(_ context.Context, id string, newStatus model.RunnerStatus, forgejoRunnerID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.runners[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("runner %q: %w", id, store.ErrNotFound)
|
||||
}
|
||||
if !r.Status.CanTransitionTo(newStatus) {
|
||||
return fmt.Errorf("invalid transition from %s to %s", r.Status, newStatus)
|
||||
}
|
||||
r.Status = newStatus
|
||||
if forgejoRunnerID != "" {
|
||||
r.ForgejoRunnerID = forgejoRunnerID
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if newStatus == model.RunnerStatusJobClaimed {
|
||||
r.ClaimedAt = &now
|
||||
}
|
||||
if newStatus.IsTerminal() {
|
||||
r.CompletedAt = &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) DeleteRunner(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.runners[id]; !ok {
|
||||
return fmt.Errorf("runner %q: %w", id, store.ErrNotFound)
|
||||
}
|
||||
delete(m.runners, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) IsDeliveryProcessed(_ context.Context, deliveryID string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if deliveryID == "" {
|
||||
return false, nil
|
||||
}
|
||||
for _, r := range m.runners {
|
||||
if r.WebhookDeliveryID == deliveryID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerStore) GetStaleRunners(_ context.Context, ttl time.Duration) ([]model.Runner, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cutoff := time.Now().UTC().Add(-ttl)
|
||||
var result []model.Runner
|
||||
for _, r := range m.runners {
|
||||
if r.CreatedAt.Before(cutoff) && !r.Status.IsTerminal() && r.Status != model.RunnerStatusCleanupPending {
|
||||
result = append(result, *r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type mockRunnerPodManager struct {
|
||||
mu sync.Mutex
|
||||
pods map[string]string // runnerID -> podName
|
||||
createErr error
|
||||
}
|
||||
|
||||
func newMockRunnerPodManager() *mockRunnerPodManager {
|
||||
return &mockRunnerPodManager{pods: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockRunnerPodManager) CreateRunnerPod(_ context.Context, opts k8s.CreateRunnerPodOpts) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.createErr != nil {
|
||||
return "", m.createErr
|
||||
}
|
||||
podName := model.RunnerPodName(opts.RunnerID)
|
||||
m.pods[opts.RunnerID] = podName
|
||||
return podName, nil
|
||||
}
|
||||
|
||||
func (m *mockRunnerPodManager) DeleteRunnerPod(_ context.Context, _, podName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for k, v := range m.pods {
|
||||
if v == podName {
|
||||
delete(m.pods, k)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRunnerTestRouter() (*mockKeyValidator, *mockUserStore, *mockRunnerStore, *mockRunnerPodManager, http.Handler) {
|
||||
kv := newMockValidator()
|
||||
us := newMockUserStore()
|
||||
rs := newMockRunnerStore()
|
||||
rp := newMockRunnerPodManager()
|
||||
fm := newMockForgejoManager()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
K8s: newMockPodManager(),
|
||||
Users: us,
|
||||
Usage: newMockUsageStore(),
|
||||
Runners: rs,
|
||||
RunnerPods: rp,
|
||||
Forgejo: fm,
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
}
|
||||
return kv, us, rs, rp, NewRouter(srv)
|
||||
}
|
||||
|
||||
func TestCreateRunner_Success(t *testing.T) {
|
||||
kv, us, rs, rp, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","repo":"alice/myrepo","branch":"main","tools":"go","task":"build it"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.User != "alice" {
|
||||
t.Errorf("expected user alice, got %s", runner.User)
|
||||
}
|
||||
if runner.RepoURL != "alice/myrepo" {
|
||||
t.Errorf("expected repo alice/myrepo, got %s", runner.RepoURL)
|
||||
}
|
||||
if runner.Status != model.RunnerStatusPodCreating {
|
||||
t.Errorf("expected status pod_creating, got %s", runner.Status)
|
||||
}
|
||||
|
||||
rs.mu.Lock()
|
||||
count := len(rs.runners)
|
||||
rs.mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 runner in store, got %d", count)
|
||||
}
|
||||
|
||||
rp.mu.Lock()
|
||||
podCount := len(rp.pods)
|
||||
rp.mu.Unlock()
|
||||
if podCount != 1 {
|
||||
t.Errorf("expected 1 pod created, got %d", podCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_DedupeWebhook(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","repo":"alice/myrepo","webhook_delivery_id":"delivery-xyz"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("first create: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr2 := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
|
||||
if rr2.Code != http.StatusConflict {
|
||||
t.Fatalf("dupe: expected 409, got %d: %s", rr2.Code, rr2.Body.String())
|
||||
}
|
||||
|
||||
rs.mu.Lock()
|
||||
count := len(rs.runners)
|
||||
rs.mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 runner after dedupe, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_ForbiddenOtherUser(t *testing.T) {
|
||||
kv, us, _, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
|
||||
|
||||
body := `{"user":"bob","repo":"bob/repo"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_AdminCanCreateForOthers(t *testing.T) {
|
||||
kv, us, _, _, router := newRunnerTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
bob := &model.User{ID: "bob", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
us.addUser(bob)
|
||||
|
||||
body := `{"user":"bob","repo":"bob/repo"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_admin")
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_UserNotFound(t *testing.T) {
|
||||
kv, _, _, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
body := `{"user":"alice","repo":"alice/repo"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_InvalidBody(t *testing.T) {
|
||||
kv, _, _, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", "not json", "dpk_test")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_ValidationError(t *testing.T) {
|
||||
kv, _, _, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", `{"user":"alice"}`, "dpk_test")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunner_PodCreationFails(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
us := newMockUserStore()
|
||||
rs := newMockRunnerStore()
|
||||
rp := newMockRunnerPodManager()
|
||||
rp.createErr = fmt.Errorf("k8s error")
|
||||
fm := newMockForgejoManager()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
K8s: newMockPodManager(),
|
||||
Users: us,
|
||||
Usage: newMockUsageStore(),
|
||||
Runners: rs,
|
||||
RunnerPods: rp,
|
||||
Forgejo: fm,
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
}
|
||||
router := NewRouter(srv)
|
||||
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
body := `{"user":"alice","repo":"alice/repo"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rs.mu.Lock()
|
||||
for _, r := range rs.runners {
|
||||
if r.Status != model.RunnerStatusFailed {
|
||||
t.Errorf("expected runner status failed after pod creation failure, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestListRunners_UserSeesOwn(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
us.addUser(alice)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
|
||||
rs.runners["r2"] = &model.Runner{ID: "r2", User: "bob", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/runners", "", "dpk_alice")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runners []model.Runner
|
||||
decodeJSON(t, rr, &runners)
|
||||
if len(runners) != 1 {
|
||||
t.Fatalf("expected 1 runner, got %d", len(runners))
|
||||
}
|
||||
if runners[0].User != "alice" {
|
||||
t.Errorf("expected alice's runner, got %s", runners[0].User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRunners_AdminSeesAll(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
us.addUser(admin)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
|
||||
rs.runners["r2"] = &model.Runner{ID: "r2", User: "bob", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/runners", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runners []model.Runner
|
||||
decodeJSON(t, rr, &runners)
|
||||
if len(runners) != 2 {
|
||||
t.Fatalf("expected 2 runners, got %d", len(runners))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRunner_Success(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", PodName: "dev-pod-r1", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/runners/r1", "", "dpk_test")
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rs.mu.Lock()
|
||||
count := len(rs.runners)
|
||||
rs.mu.Unlock()
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 runners after delete, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRunner_NotFound(t *testing.T) {
|
||||
kv, _, _, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/runners/nonexistent", "", "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRunner_ForbiddenOtherUser(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "bob", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/runners/r1", "", "dpk_test")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRunnerStatus_Success(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"pod_creating"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners/r1/status", body, "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.Status != model.RunnerStatusPodCreating {
|
||||
t.Errorf("expected status pod_creating, got %s", runner.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRunnerStatus_InvalidTransition(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"completed"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners/r1/status", body, "dpk_test")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRunnerStatus_NotFound(t *testing.T) {
|
||||
kv, _, _, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
body := `{"status":"pod_creating"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners/nonexistent/status", body, "dpk_test")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRunnerStatus_WithForgejoRunnerID(t *testing.T) {
|
||||
kv, us, rs, _, router := newRunnerTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
us.addUser(user)
|
||||
|
||||
rs.mu.Lock()
|
||||
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusPodCreating}
|
||||
rs.mu.Unlock()
|
||||
|
||||
body := `{"status":"runner_registered","forgejo_runner_id":"forgejo-99"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/runners/r1/status", body, "dpk_test")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.ForgejoRunnerID != "forgejo-99" {
|
||||
t.Errorf("expected forgejo_runner_id forgejo-99, got %s", runner.ForgejoRunnerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRoutes_NoAuth(t *testing.T) {
|
||||
_, _, _, _, router := newRunnerTestRouter()
|
||||
|
||||
routes := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/runners"},
|
||||
{http.MethodGet, "/api/v1/runners"},
|
||||
{http.MethodDelete, "/api/v1/runners/r1"},
|
||||
{http.MethodPost, "/api/v1/runners/r1/status"},
|
||||
}
|
||||
|
||||
for _, rt := range routes {
|
||||
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
|
||||
rr := doRequest(router, rt.method, rt.path, "", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
89
internal/api/sessionhost.go
Normal file
89
internal/api/sessionhost.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Package api — session-host endpoints for web-tui (T019).
|
||||
//
|
||||
// Provides two endpoints under /api/v1:
|
||||
//
|
||||
// POST /api/v1/pods/session-host
|
||||
// Idempotent create-or-get of the authenticated user's durable
|
||||
// session-host pod. Also ensures per-user Certificate, Ingress,
|
||||
// PVCs, and namespace exist. Safe to call on every page load.
|
||||
//
|
||||
// GET /api/v1/pods/session-host/{user}/status
|
||||
// Reports readiness of the user's session-host pod and the cert.
|
||||
//
|
||||
// Both sit under the existing AuthMiddleware (API-key Bearer auth).
|
||||
// The {user} path parameter is validated against the authenticated
|
||||
// user's ID — cross-tenant access is refused with 403.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// SessionHostStatus is the canonical response for both endpoints. Mirrors
|
||||
// specs/001-mobile-tui/contracts/dev-pod-api-extension.openapi.yaml in
|
||||
// ~/Work/web-tui.
|
||||
type SessionHostStatus struct {
|
||||
Ready bool `json:"ready"`
|
||||
PodName string `json:"podName"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
CertReady bool `json:"certReady"`
|
||||
CertPendingReason string `json:"certPendingReason,omitempty"`
|
||||
AtchSessionCount int `json:"atchSessionCount,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// SessionHostManager owns the lifecycle of per-user durable session-host
|
||||
// pods, PVCs, services, ingress, and cert-manager Certificate. The
|
||||
// concrete implementation lives in internal/k8s/sessionhost.go.
|
||||
type SessionHostManager interface {
|
||||
// EnsureForUser is idempotent: returns the current status for user,
|
||||
// creating namespace/PVCs/Pod/Service/Ingress/Certificate if absent.
|
||||
EnsureForUser(ctx context.Context, user string) (SessionHostStatus, error)
|
||||
// StatusForUser returns the current status without creating anything.
|
||||
StatusForUser(ctx context.Context, user string) (SessionHostStatus, error)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSessionHost(w http.ResponseWriter, r *http.Request) {
|
||||
u := UserFromContext(r.Context())
|
||||
if u == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if s.SessionHost == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "session-host manager not configured")
|
||||
return
|
||||
}
|
||||
st, err := s.SessionHost.EnsureForUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionHostStatus(w http.ResponseWriter, r *http.Request) {
|
||||
u := UserFromContext(r.Context())
|
||||
if u == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
urlUser := chi.URLParam(r, "user")
|
||||
if u.ID != urlUser {
|
||||
writeError(w, http.StatusForbidden, "subject mismatch")
|
||||
return
|
||||
}
|
||||
if s.SessionHost == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "session-host manager not configured")
|
||||
return
|
||||
}
|
||||
st, err := s.SessionHost.StatusForUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
225
internal/api/users.go
Normal file
225
internal/api/users.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
// createUserResponse is returned when a new user is created.
|
||||
type createUserResponse struct {
|
||||
User *model.User `json:"user"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if RoleFromContext(r.Context()) != model.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
quota := model.DefaultQuota()
|
||||
if req.Quotas != nil {
|
||||
if err := req.Quotas.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
quota.ApplyOverrides(*req.Quotas)
|
||||
}
|
||||
|
||||
user, err := s.Users.CreateUser(r.Context(), req.User, quota)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeError(w, http.StatusConflict, "user already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
|
||||
plainKey, keyHash, err := s.GenerateKey()
|
||||
if err != nil {
|
||||
cleanupCtx := context.WithoutCancel(r.Context())
|
||||
if delErr := s.Users.DeleteUser(cleanupCtx, req.User); delErr != nil {
|
||||
s.Logger.Error("rollback: failed to delete orphaned user", "user", req.User, "error", delErr)
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate api key")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Users.CreateAPIKey(r.Context(), req.User, model.RoleUser, keyHash); err != nil {
|
||||
cleanupCtx := context.WithoutCancel(r.Context())
|
||||
if delErr := s.Users.DeleteUser(cleanupCtx, req.User); delErr != nil {
|
||||
s.Logger.Error("rollback: failed to delete orphaned user", "user", req.User, "error", delErr)
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to store api key")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Forgejo != nil {
|
||||
forgejoToken, forgejoErr := s.Forgejo.CreateForgejoUser(r.Context(), req.User)
|
||||
if forgejoErr != nil {
|
||||
s.Logger.Error("failed to create forgejo user", "user", req.User, "error", forgejoErr)
|
||||
cleanupCtx := context.WithoutCancel(r.Context())
|
||||
if delErr := s.Users.DeleteUser(cleanupCtx, req.User); delErr != nil {
|
||||
s.Logger.Error("rollback: failed to delete orphaned user", "user", req.User, "error", delErr)
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create forgejo user")
|
||||
return
|
||||
}
|
||||
if err := s.Users.SaveForgejoToken(r.Context(), req.User, forgejoToken); err != nil {
|
||||
s.Logger.Error("failed to store forgejo token", "user", req.User, "error", err)
|
||||
cleanupCtx := context.WithoutCancel(r.Context())
|
||||
_ = s.Forgejo.DeleteForgejoUser(cleanupCtx, req.User)
|
||||
if delErr := s.Users.DeleteUser(cleanupCtx, req.User); delErr != nil {
|
||||
s.Logger.Error("rollback: failed to delete orphaned user", "user", req.User, "error", delErr)
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to store forgejo token")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, createUserResponse{
|
||||
User: user,
|
||||
APIKey: plainKey,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
role := RoleFromContext(r.Context())
|
||||
authUser := UserFromContext(r.Context())
|
||||
|
||||
if role == model.RoleAdmin {
|
||||
users, err := s.Users.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
if users == nil {
|
||||
users = []model.User{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
return
|
||||
}
|
||||
|
||||
// Regular user: return only self
|
||||
user, err := s.Users.GetUser(r.Context(), authUser.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []model.User{*user})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
targetUser := chi.URLParam(r, "user")
|
||||
|
||||
if !canAccess(r, targetUser) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.Users.GetUser(r.Context(), targetUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if RoleFromContext(r.Context()) != model.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser := chi.URLParam(r, "user")
|
||||
|
||||
// Verify user exists before deleting resources
|
||||
if _, err := s.Users.GetUser(r.Context(), targetUser); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to look up user")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all pods first — fail if this doesn't succeed to avoid orphaned pods
|
||||
if err := s.K8s.DeleteAllPods(r.Context(), targetUser); err != nil {
|
||||
s.Logger.Error("failed to delete user pods", "user", targetUser, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete user pods")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Forgejo != nil {
|
||||
if forgejoErr := s.Forgejo.DeleteForgejoUser(r.Context(), targetUser); forgejoErr != nil {
|
||||
s.Logger.Error("failed to delete forgejo user", "user", targetUser, "error", forgejoErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Users.DeleteUser(r.Context(), targetUser); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete user")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
if RoleFromContext(r.Context()) != model.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser := chi.URLParam(r, "user")
|
||||
|
||||
var req model.UpdateQuotasRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.Users.UpdateQuotas(r.Context(), targetUser, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to update quotas")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
486
internal/api/users_test.go
Normal file
486
internal/api/users_test.go
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
// --- POST /api/v1/users ---
|
||||
|
||||
func TestCreateUser_Success(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, 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)
|
||||
}
|
||||
if resp.APIKey == "" {
|
||||
t.Fatal("expected api_key to be set")
|
||||
}
|
||||
if resp.APIKey != "dpk_testgeneratedkey123456" {
|
||||
t.Fatalf("expected mock api key, got %q", resp.APIKey)
|
||||
}
|
||||
// Default quotas should be applied
|
||||
if resp.User.Quota.MaxConcurrentPods != 3 {
|
||||
t.Fatalf("expected default max_concurrent_pods 3, got %d", resp.User.Quota.MaxConcurrentPods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_WithCustomQuotas(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
body := `{"user":"bob","quotas":{"max_concurrent_pods":5,"max_cpu_per_pod":16,"max_ram_gb_per_pod":32,"monthly_pod_hours":1000,"monthly_ai_requests":50000}}`
|
||||
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.Quota.MaxConcurrentPods != 5 {
|
||||
t.Fatalf("expected max_concurrent_pods 5, got %d", resp.User.Quota.MaxConcurrentPods)
|
||||
}
|
||||
if resp.User.Quota.MaxCPUPerPod != 16 {
|
||||
t.Fatalf("expected max_cpu_per_pod 16, got %d", resp.User.Quota.MaxCPUPerPod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_ForbiddenNonAdmin(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_test", user, model.RoleUser)
|
||||
|
||||
body := `{"user":"newuser"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_test")
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_InvalidBody(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/users", "not json", "dpk_admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_ValidationError(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
// Missing required field
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/users", `{}`, "dpk_admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_InvalidName(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/users", `{"user":"INVALID_NAME"}`, "dpk_admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_Duplicate(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
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()})
|
||||
|
||||
body := `{"user":"alice"}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin")
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_PartialQuotasMergedWithDefaults(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
// Only specify max_cpu_per_pod; other fields should get defaults
|
||||
body := `{"user":"dave","quotas":{"max_cpu_per_pod":16}}`
|
||||
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.Quota.MaxCPUPerPod != 16 {
|
||||
t.Fatalf("expected max_cpu_per_pod 16, got %d", resp.User.Quota.MaxCPUPerPod)
|
||||
}
|
||||
// Unset fields should be defaults, not zero
|
||||
if resp.User.Quota.MaxConcurrentPods != 3 {
|
||||
t.Fatalf("expected default max_concurrent_pods 3, got %d", resp.User.Quota.MaxConcurrentPods)
|
||||
}
|
||||
if resp.User.Quota.MaxRAMGBPerPod != 16 {
|
||||
t.Fatalf("expected default max_ram_gb_per_pod 16, got %d", resp.User.Quota.MaxRAMGBPerPod)
|
||||
}
|
||||
if resp.User.Quota.MonthlyPodHours != 500 {
|
||||
t.Fatalf("expected default monthly_pod_hours 500, got %d", resp.User.Quota.MonthlyPodHours)
|
||||
}
|
||||
if resp.User.Quota.MonthlyAIRequests != 10000 {
|
||||
t.Fatalf("expected default monthly_ai_requests 10000, got %d", resp.User.Quota.MonthlyAIRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_NegativeQuotaRejected(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
body := `{"user":"eve","quotas":{"max_cpu_per_pod":-1}}`
|
||||
rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_APIKeyFailureRollsBackUser(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
// Make CreateAPIKey fail
|
||||
us.createAPIKeyErr = fmt.Errorf("db connection lost")
|
||||
|
||||
body := `{"user":"frank"}`
|
||||
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 have been cleaned up — not left orphaned
|
||||
if _, exists := us.users["frank"]; exists {
|
||||
t.Fatal("expected user 'frank' to be cleaned up after API key failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_FailsWhenPodDeletionFails(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
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()})
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
|
||||
// Make pod deletion fail
|
||||
pm.deleteAllPodsErr = fmt.Errorf("k8s API unreachable")
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_admin")
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500 when pod deletion fails, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// User record should still exist (not deleted)
|
||||
if _, exists := us.users["alice"]; !exists {
|
||||
t.Fatal("expected user 'alice' to still exist after pod deletion failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/users ---
|
||||
|
||||
func TestListUsers_AdminSeesAll(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
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()})
|
||||
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
decodeJSON(t, rr, &users)
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers_UserSeesSelf(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
us.addUser(alice)
|
||||
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users", "", "dpk_alice")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
decodeJSON(t, rr, &users)
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("expected 1 user (self), got %d", len(users))
|
||||
}
|
||||
if users[0].ID != "alice" {
|
||||
t.Fatalf("expected user 'alice', got %q", users[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/v1/users/{user} ---
|
||||
|
||||
func TestGetUser_Self(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
us.addUser(alice)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/alice", "", "dpk_alice")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var user model.User
|
||||
decodeJSON(t, rr, &user)
|
||||
if user.ID != "alice" {
|
||||
t.Fatalf("expected user 'alice', got %q", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_AdminCanViewOthers(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
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.MethodGet, "/api/v1/users/alice", "", "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_ForbiddenOtherUser(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/bob", "", "dpk_alice")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_NotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodGet, "/api/v1/users/nonexistent", "", "dpk_admin")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/v1/users/{user} ---
|
||||
|
||||
func TestDeleteUser_Success(t *testing.T) {
|
||||
kv, pm, us, router := newPodTestRouter()
|
||||
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()})
|
||||
pm.addPod(&model.Pod{User: "alice", Name: "main"})
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// Verify user is gone
|
||||
getRR := doRequest(router, http.MethodGet, "/api/v1/users/alice", "", "dpk_admin")
|
||||
if getRR.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected user to be deleted, got status %d", getRR.Code)
|
||||
}
|
||||
|
||||
// Verify pods are gone
|
||||
pods, _ := pm.ListPods(nil, "alice")
|
||||
if len(pods) != 0 {
|
||||
t.Fatalf("expected pods to be deleted, got %d", len(pods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_ForbiddenNonAdmin(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
us.addUser(alice)
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_alice")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_NotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodDelete, "/api/v1/users/nonexistent", "", "dpk_admin")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- PATCH /api/v1/users/{user}/quotas ---
|
||||
|
||||
func TestUpdateQuotas_Success(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
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()})
|
||||
|
||||
body := `{"max_concurrent_pods":10,"max_cpu_per_pod":16}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", body, "dpk_admin")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var user model.User
|
||||
decodeJSON(t, rr, &user)
|
||||
if user.Quota.MaxConcurrentPods != 10 {
|
||||
t.Fatalf("expected max_concurrent_pods 10, got %d", user.Quota.MaxConcurrentPods)
|
||||
}
|
||||
if user.Quota.MaxCPUPerPod != 16 {
|
||||
t.Fatalf("expected max_cpu_per_pod 16, got %d", user.Quota.MaxCPUPerPod)
|
||||
}
|
||||
// Unchanged fields should be preserved
|
||||
if user.Quota.MonthlyPodHours != 500 {
|
||||
t.Fatalf("expected monthly_pod_hours 500, got %d", user.Quota.MonthlyPodHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateQuotas_ForbiddenNonAdmin(t *testing.T) {
|
||||
kv, _, us, router := newPodTestRouter()
|
||||
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_alice", alice, model.RoleUser)
|
||||
us.addUser(alice)
|
||||
|
||||
body := `{"max_concurrent_pods":10}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", body, "dpk_alice")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateQuotas_InvalidBody(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", "not json", "dpk_admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateQuotas_ValidationError(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
// max_concurrent_pods must be at least 1
|
||||
body := `{"max_concurrent_pods":0}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", body, "dpk_admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateQuotas_UserNotFound(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
body := `{"max_concurrent_pods":10}`
|
||||
rr := doRequest(router, http.MethodPatch, "/api/v1/users/nonexistent/quotas", body, "dpk_admin")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth integration for user routes ---
|
||||
|
||||
func TestUserRoutes_NoAuth(t *testing.T) {
|
||||
_, _, _, router := newPodTestRouter()
|
||||
|
||||
routes := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/users"},
|
||||
{http.MethodGet, "/api/v1/users"},
|
||||
{http.MethodGet, "/api/v1/users/alice"},
|
||||
{http.MethodDelete, "/api/v1/users/alice"},
|
||||
{http.MethodPatch, "/api/v1/users/alice/quotas"},
|
||||
}
|
||||
|
||||
for _, rt := range routes {
|
||||
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
|
||||
rr := doRequest(router, rt.method, rt.path, "", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- verify response structure ---
|
||||
|
||||
func TestCreateUser_ResponseStructure(t *testing.T) {
|
||||
kv, _, _, router := newPodTestRouter()
|
||||
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
|
||||
kv.addKey("dpk_admin", admin, model.RoleAdmin)
|
||||
|
||||
body := `{"user":"charlie"}`
|
||||
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())
|
||||
}
|
||||
|
||||
// Verify response has both user and api_key fields
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(rr.Body).Decode(&raw); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if _, ok := raw["user"]; !ok {
|
||||
t.Fatal("response missing 'user' field")
|
||||
}
|
||||
if _, ok := raw["api_key"]; !ok {
|
||||
t.Fatal("response missing 'api_key' field")
|
||||
}
|
||||
}
|
||||
316
internal/api/webhooks.go
Normal file
316
internal/api/webhooks.go
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
type forgejoWebhookPayload struct {
|
||||
Action string `json:"action"`
|
||||
Comment *forgejoComment `json:"comment,omitempty"`
|
||||
Issue *forgejoIssue `json:"issue,omitempty"`
|
||||
PullRequest *forgejoPullRequest `json:"pull_request,omitempty"`
|
||||
Repository forgejoRepo `json:"repository"`
|
||||
}
|
||||
|
||||
type forgejoComment struct {
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type forgejoIssue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type forgejoPullRequest struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Head struct {
|
||||
Ref string `json:"ref"`
|
||||
} `json:"head"`
|
||||
}
|
||||
|
||||
type forgejoRepo struct {
|
||||
FullName string `json:"full_name"`
|
||||
Name string `json:"name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
type claudeCommand struct {
|
||||
Action string
|
||||
Text string
|
||||
}
|
||||
|
||||
type spinoffConfig struct {
|
||||
Tools []string `yaml:"tools"`
|
||||
CPU string `yaml:"cpu"`
|
||||
Mem string `yaml:"mem"`
|
||||
}
|
||||
|
||||
const webhookReplayWindow = 5 * time.Minute
|
||||
|
||||
var claudeCommandRegex = regexp.MustCompile(`(?i)@claude\s+(implement|review|fix)(?:\s+(.*))?`)
|
||||
|
||||
func parseClaudeCommand(body string) *claudeCommand {
|
||||
matches := claudeCommandRegex.FindStringSubmatch(body)
|
||||
if matches == nil {
|
||||
return nil
|
||||
}
|
||||
return &claudeCommand{
|
||||
Action: strings.ToLower(matches[1]),
|
||||
Text: strings.TrimSpace(matches[2]),
|
||||
}
|
||||
}
|
||||
|
||||
func verifyHMACSignature(body []byte, signature, secret string) bool {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
func parseSpinoffConfig(data []byte) (*spinoffConfig, error) {
|
||||
var cfg spinoffConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse spinoff config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func buildTaskDescription(cmd *claudeCommand, payload forgejoWebhookPayload) string {
|
||||
var parts []string
|
||||
parts = append(parts, cmd.Action)
|
||||
|
||||
if payload.Issue != nil {
|
||||
parts = append(parts, fmt.Sprintf("issue #%d: %s", payload.Issue.Number, payload.Issue.Title))
|
||||
}
|
||||
if cmd.Text != "" {
|
||||
parts = append(parts, cmd.Text)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " - ")
|
||||
}
|
||||
|
||||
func (s *Server) handleForgejoWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if s.WebhookSecret == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "webhook not configured")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read body")
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get("X-Forgejo-Signature")
|
||||
if signature == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing signature")
|
||||
return
|
||||
}
|
||||
if !verifyHMACSignature(body, signature, s.WebhookSecret) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
deliveryID := r.Header.Get("X-Forgejo-Delivery")
|
||||
if deliveryID == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing delivery id")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Runners != nil {
|
||||
isDupe, dupErr := s.Runners.IsDeliveryProcessed(r.Context(), deliveryID)
|
||||
if dupErr != nil {
|
||||
s.Logger.Error("check delivery dedupe", "error", dupErr)
|
||||
writeError(w, http.StatusInternalServerError, "failed to check delivery")
|
||||
return
|
||||
}
|
||||
if isDupe {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "already_processed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
eventType := r.Header.Get("X-Forgejo-Event")
|
||||
if eventType != "issue_comment" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "unsupported event type"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload forgejoWebhookPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Action != "created" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "not a new comment"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Comment == nil {
|
||||
writeError(w, http.StatusBadRequest, "missing comment in payload")
|
||||
return
|
||||
}
|
||||
|
||||
if time.Since(payload.Comment.CreatedAt) > webhookReplayWindow {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "event too old"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd := parseClaudeCommand(payload.Comment.Body)
|
||||
if cmd == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "no @claude command"})
|
||||
return
|
||||
}
|
||||
|
||||
repoOwner := payload.Repository.Owner.Login
|
||||
if _, err := s.Users.GetUser(r.Context(), repoOwner); err != nil {
|
||||
s.Logger.Error("webhook user not found", "user", repoOwner, "error", err)
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
branch := payload.Repository.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
|
||||
tools, cpuReq, memReq := s.detectToolsFromRepo(
|
||||
r, repoOwner, payload.Repository.Name, branch,
|
||||
)
|
||||
|
||||
task := buildTaskDescription(cmd, payload)
|
||||
|
||||
runnerID, err := store.GenerateRunnerID()
|
||||
if err != nil {
|
||||
s.Logger.Error("generate runner id", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate runner id")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
runner := &model.Runner{
|
||||
ID: runnerID,
|
||||
User: repoOwner,
|
||||
RepoURL: payload.Repository.FullName,
|
||||
Branch: branch,
|
||||
Tools: tools,
|
||||
Task: task,
|
||||
Status: model.RunnerStatusReceived,
|
||||
WebhookDeliveryID: deliveryID,
|
||||
CPUReq: cpuReq,
|
||||
MemReq: memReq,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.Runners.CreateRunner(r.Context(), runner); err != nil {
|
||||
s.Logger.Error("create runner from webhook", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create runner")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusPodCreating, ""); err != nil {
|
||||
s.Logger.Error("update runner status to pod_creating", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runner status")
|
||||
return
|
||||
}
|
||||
|
||||
if s.RunnerPods != nil {
|
||||
podName, podErr := s.RunnerPods.CreateRunnerPod(r.Context(), k8s.CreateRunnerPodOpts{
|
||||
User: repoOwner,
|
||||
RunnerID: runnerID,
|
||||
Tools: tools,
|
||||
Task: task,
|
||||
RepoURL: payload.Repository.FullName,
|
||||
Branch: branch,
|
||||
CPUReq: cpuReq,
|
||||
MemReq: memReq,
|
||||
ForgejoRunnerToken: s.ForgejoRunnerToken,
|
||||
})
|
||||
if podErr != nil {
|
||||
s.Logger.Error("create runner pod from webhook", "runner", runnerID, "error", podErr)
|
||||
_ = s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusFailed, "")
|
||||
writeError(w, http.StatusInternalServerError, "failed to create runner pod")
|
||||
return
|
||||
}
|
||||
runner.PodName = podName
|
||||
}
|
||||
|
||||
if s.Forgejo != nil && payload.Issue != nil {
|
||||
comment := fmt.Sprintf(
|
||||
"Builder pod `%s` created, working on: %s",
|
||||
runnerID, cmd.Action,
|
||||
)
|
||||
if commentErr := s.Forgejo.CreateIssueComment(
|
||||
r.Context(), repoOwner, payload.Repository.Name,
|
||||
payload.Issue.Number, comment,
|
||||
); commentErr != nil {
|
||||
s.Logger.Error("comment on issue", "issue", payload.Issue.Number, "error", commentErr)
|
||||
}
|
||||
}
|
||||
|
||||
runner.Status = model.RunnerStatusPodCreating
|
||||
writeJSON(w, http.StatusCreated, runner)
|
||||
}
|
||||
|
||||
func (s *Server) detectToolsFromRepo(r *http.Request, owner, repo, ref string) (tools, cpu, mem string) {
|
||||
cpu = "2"
|
||||
mem = "4Gi"
|
||||
|
||||
if s.Forgejo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, path := range []string{".forgejo/spinoff.yml", ".spinoff.yml"} {
|
||||
content, err := s.Forgejo.GetRepoFileContent(r.Context(), owner, repo, path, ref)
|
||||
if err != nil {
|
||||
s.Logger.Error("read spinoff config", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if content == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg, parseErr := parseSpinoffConfig(content)
|
||||
if parseErr != nil {
|
||||
s.Logger.Error("parse spinoff config", "path", path, "error", parseErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(cfg.Tools) > 0 {
|
||||
tools = strings.Join(cfg.Tools, ",")
|
||||
}
|
||||
if cfg.CPU != "" {
|
||||
cpu = cfg.CPU
|
||||
}
|
||||
if cfg.Mem != "" {
|
||||
mem = cfg.Mem
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
583
internal/api/webhooks_test.go
Normal file
583
internal/api/webhooks_test.go
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
)
|
||||
|
||||
const testWebhookSecret = "test-webhook-secret-123"
|
||||
|
||||
func computeHMAC(body, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(body))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func newWebhookTestRouter() (*mockUserStore, *mockRunnerStore, *mockRunnerPodManager, *mockForgejoManager, http.Handler) {
|
||||
kv := newMockValidator()
|
||||
us := newMockUserStore()
|
||||
rs := newMockRunnerStore()
|
||||
rp := newMockRunnerPodManager()
|
||||
fm := newMockForgejoManager()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
K8s: newMockPodManager(),
|
||||
Users: us,
|
||||
Usage: newMockUsageStore(),
|
||||
Runners: rs,
|
||||
RunnerPods: rp,
|
||||
Forgejo: fm,
|
||||
Logger: logger,
|
||||
GenerateKey: mockGenerateKey,
|
||||
WebhookSecret: testWebhookSecret,
|
||||
}
|
||||
return us, rs, rp, fm, NewRouter(srv)
|
||||
}
|
||||
|
||||
func makeWebhookPayload(t *testing.T, comment, owner, repo string, issueNumber int) string {
|
||||
t.Helper()
|
||||
payload := map[string]interface{}{
|
||||
"action": "created",
|
||||
"comment": map[string]interface{}{
|
||||
"body": comment,
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"user": map[string]string{"login": "someone"},
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"number": issueNumber,
|
||||
"title": "Test Issue",
|
||||
},
|
||||
"repository": map[string]interface{}{
|
||||
"full_name": owner + "/" + repo,
|
||||
"name": repo,
|
||||
"default_branch": "main",
|
||||
"owner": map[string]string{"login": owner},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func doWebhookRequest(router http.Handler, body, secret, deliveryID, eventType string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body))
|
||||
req.Header.Set("X-Forgejo-Signature", computeHMAC(body, secret))
|
||||
req.Header.Set("X-Forgejo-Delivery", deliveryID)
|
||||
req.Header.Set("X-Forgejo-Event", eventType)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestWebhook_Success(t *testing.T) {
|
||||
us, rs, rp, fm, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement the auth system", "alice", "myrepo", 42)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-001", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.User != "alice" {
|
||||
t.Errorf("expected user alice, got %s", runner.User)
|
||||
}
|
||||
if runner.RepoURL != "alice/myrepo" {
|
||||
t.Errorf("expected repo alice/myrepo, got %s", runner.RepoURL)
|
||||
}
|
||||
if runner.Status != model.RunnerStatusPodCreating {
|
||||
t.Errorf("expected status pod_creating, got %s", runner.Status)
|
||||
}
|
||||
if runner.WebhookDeliveryID != "delivery-001" {
|
||||
t.Errorf("expected delivery id delivery-001, got %s", runner.WebhookDeliveryID)
|
||||
}
|
||||
if !strings.Contains(runner.Task, "implement") {
|
||||
t.Errorf("expected task to contain 'implement', got %s", runner.Task)
|
||||
}
|
||||
|
||||
rs.mu.Lock()
|
||||
count := len(rs.runners)
|
||||
rs.mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 runner in store, got %d", count)
|
||||
}
|
||||
|
||||
rp.mu.Lock()
|
||||
podCount := len(rp.pods)
|
||||
rp.mu.Unlock()
|
||||
if podCount != 1 {
|
||||
t.Errorf("expected 1 pod created, got %d", podCount)
|
||||
}
|
||||
|
||||
fm.mu.Lock()
|
||||
commentCount := len(fm.issueComments)
|
||||
fm.mu.Unlock()
|
||||
if commentCount != 1 {
|
||||
t.Errorf("expected 1 issue comment, got %d", commentCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_InvalidSignature(t *testing.T) {
|
||||
us, _, _, _, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
||||
rr := doWebhookRequest(router, body, "wrong-secret", "delivery-002", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_MissingSignature(t *testing.T) {
|
||||
_, _, _, _, router := newWebhookTestRouter()
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body))
|
||||
req.Header.Set("X-Forgejo-Delivery", "delivery-003")
|
||||
req.Header.Set("X-Forgejo-Event", "issue_comment")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_MissingDeliveryID(t *testing.T) {
|
||||
_, _, _, _, router := newWebhookTestRouter()
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body))
|
||||
req.Header.Set("X-Forgejo-Signature", computeHMAC(body, testWebhookSecret))
|
||||
req.Header.Set("X-Forgejo-Event", "issue_comment")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_DuplicateDelivery(t *testing.T) {
|
||||
us, _, _, _, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
||||
|
||||
rr1 := doWebhookRequest(router, body, testWebhookSecret, "delivery-dupe", "issue_comment")
|
||||
if rr1.Code != http.StatusCreated {
|
||||
t.Fatalf("first request: expected 201, got %d: %s", rr1.Code, rr1.Body.String())
|
||||
}
|
||||
|
||||
rr2 := doWebhookRequest(router, body, testWebhookSecret, "delivery-dupe", "issue_comment")
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Fatalf("dupe: expected 200, got %d: %s", rr2.Code, rr2.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
decodeJSON(t, rr2, &resp)
|
||||
if resp["status"] != "already_processed" {
|
||||
t.Errorf("expected status already_processed, got %s", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_UnsupportedEventType(t *testing.T) {
|
||||
_, _, _, _, router := newWebhookTestRouter()
|
||||
|
||||
body := `{"action":"created"}`
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-004", "push")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
decodeJSON(t, rr, &resp)
|
||||
if resp["reason"] != "unsupported event type" {
|
||||
t.Errorf("expected reason 'unsupported event type', got %s", resp["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_NoClaudeCommand(t *testing.T) {
|
||||
us, _, _, _, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "just a regular comment", "alice", "myrepo", 1)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-005", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
decodeJSON(t, rr, &resp)
|
||||
if resp["reason"] != "no @claude command" {
|
||||
t.Errorf("expected reason 'no @claude command', got %s", resp["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_ReplayWindow(t *testing.T) {
|
||||
us, _, _, _, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
oldTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
|
||||
payload := map[string]interface{}{
|
||||
"action": "created",
|
||||
"comment": map[string]interface{}{
|
||||
"body": "@claude implement it",
|
||||
"created_at": oldTime,
|
||||
"user": map[string]string{"login": "alice"},
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"number": 1,
|
||||
"title": "Old Issue",
|
||||
},
|
||||
"repository": map[string]interface{}{
|
||||
"full_name": "alice/myrepo",
|
||||
"name": "myrepo",
|
||||
"default_branch": "main",
|
||||
"owner": map[string]string{"login": "alice"},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
body := string(b)
|
||||
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-006", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
decodeJSON(t, rr, &resp)
|
||||
if resp["reason"] != "event too old" {
|
||||
t.Errorf("expected reason 'event too old', got %s", resp["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_UserNotFound(t *testing.T) {
|
||||
_, _, _, _, router := newWebhookTestRouter()
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "nonexistent", "myrepo", 1)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-007", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_WithSpinoffConfig(t *testing.T) {
|
||||
us, rs, _, fm, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
fm.mu.Lock()
|
||||
fm.repoFiles["alice/myrepo/.spinoff.yml"] = []byte("tools: [rust, go]\ncpu: \"4\"\nmem: 8Gi\n")
|
||||
fm.mu.Unlock()
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-008", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.Tools != "rust,go" {
|
||||
t.Errorf("expected tools 'rust,go', got %s", runner.Tools)
|
||||
}
|
||||
if runner.CPUReq != "4" {
|
||||
t.Errorf("expected cpu 4, got %s", runner.CPUReq)
|
||||
}
|
||||
if runner.MemReq != "8Gi" {
|
||||
t.Errorf("expected mem 8Gi, got %s", runner.MemReq)
|
||||
}
|
||||
|
||||
rs.mu.Lock()
|
||||
for _, r := range rs.runners {
|
||||
if r.Tools != "rust,go" {
|
||||
t.Errorf("stored runner tools expected 'rust,go', got %s", r.Tools)
|
||||
}
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestWebhook_ForgejoSpinoffConfig(t *testing.T) {
|
||||
us, _, _, fm, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
|
||||
|
||||
fm.mu.Lock()
|
||||
fm.repoFiles["bob/project/.forgejo/spinoff.yml"] = []byte("tools: [node]\ncpu: \"8\"\nmem: 16Gi\n")
|
||||
fm.mu.Unlock()
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement it", "bob", "project", 5)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-009", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if runner.Tools != "node" {
|
||||
t.Errorf("expected tools 'node', got %s", runner.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_ReviewCommand(t *testing.T) {
|
||||
us, _, _, _, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "@claude review the PR changes", "alice", "myrepo", 10)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-010", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if !strings.Contains(runner.Task, "review") {
|
||||
t.Errorf("expected task to contain 'review', got %s", runner.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_FixCommand(t *testing.T) {
|
||||
us, _, _, _, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "@claude fix the failing tests", "alice", "myrepo", 15)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-011", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var runner model.Runner
|
||||
decodeJSON(t, rr, &runner)
|
||||
if !strings.Contains(runner.Task, "fix") {
|
||||
t.Errorf("expected task to contain 'fix', got %s", runner.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_NotConfigured(t *testing.T) {
|
||||
kv := newMockValidator()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := &Server{
|
||||
Store: kv,
|
||||
Logger: logger,
|
||||
}
|
||||
router := NewRouter(srv)
|
||||
|
||||
body := `{}`
|
||||
rr := doWebhookRequest(router, body, "", "delivery-012", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_CommentPostedOnIssue(t *testing.T) {
|
||||
us, _, _, fm, router := newWebhookTestRouter()
|
||||
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
||||
|
||||
body := makeWebhookPayload(t, "@claude implement the login flow", "alice", "myrepo", 42)
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-013", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
if len(fm.issueComments) != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", len(fm.issueComments))
|
||||
}
|
||||
c := fm.issueComments[0]
|
||||
if c.Owner != "alice" || c.Repo != "myrepo" || c.IssueNumber != 42 {
|
||||
t.Errorf("comment on wrong target: %+v", c)
|
||||
}
|
||||
if !strings.Contains(c.Body, "Builder pod") {
|
||||
t.Errorf("expected comment to contain 'Builder pod', got %s", c.Body)
|
||||
}
|
||||
if strings.Contains(c.Body, "@claude") {
|
||||
t.Errorf("bot comment must NOT contain '@claude' (causes webhook loop), got %s", c.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_ActionNotCreated(t *testing.T) {
|
||||
_, _, _, _, router := newWebhookTestRouter()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"action": "edited",
|
||||
"comment": map[string]interface{}{
|
||||
"body": "@claude implement it",
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"user": map[string]string{"login": "alice"},
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"number": 1,
|
||||
"title": "Test",
|
||||
},
|
||||
"repository": map[string]interface{}{
|
||||
"full_name": "alice/myrepo",
|
||||
"name": "myrepo",
|
||||
"default_branch": "main",
|
||||
"owner": map[string]string{"login": "alice"},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
body := string(b)
|
||||
|
||||
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-014", "issue_comment")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
decodeJSON(t, rr, &resp)
|
||||
if resp["reason"] != "not a new comment" {
|
||||
t.Errorf("expected reason 'not a new comment', got %s", resp["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantNil bool
|
||||
action string
|
||||
text string
|
||||
}{
|
||||
{"@claude implement the auth system", false, "implement", "the auth system"},
|
||||
{"@claude review the PR changes", false, "review", "the PR changes"},
|
||||
{"@claude fix the bug", false, "fix", "the bug"},
|
||||
{"@Claude IMPLEMENT it", false, "implement", "it"},
|
||||
{"@claude implement", false, "implement", ""},
|
||||
{"just a comment", true, "", ""},
|
||||
{"mention @claude but no command", true, "", ""},
|
||||
{"@claude deploy it", true, "", ""},
|
||||
{"please @claude implement feature X", false, "implement", "feature X"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
cmd := parseClaudeCommand(tt.input)
|
||||
if tt.wantNil {
|
||||
if cmd != nil {
|
||||
t.Errorf("expected nil, got %+v", cmd)
|
||||
}
|
||||
return
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected command, got nil")
|
||||
}
|
||||
if cmd.Action != tt.action {
|
||||
t.Errorf("expected action %q, got %q", tt.action, cmd.Action)
|
||||
}
|
||||
if cmd.Text != tt.text {
|
||||
t.Errorf("expected text %q, got %q", tt.text, cmd.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHMACSignature(t *testing.T) {
|
||||
body := []byte(`{"test": true}`)
|
||||
secret := "my-secret"
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
validSig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if !verifyHMACSignature(body, validSig, secret) {
|
||||
t.Error("expected valid signature to pass")
|
||||
}
|
||||
if verifyHMACSignature(body, "invalid", secret) {
|
||||
t.Error("expected invalid signature to fail")
|
||||
}
|
||||
if verifyHMACSignature(body, validSig, "wrong-secret") {
|
||||
t.Error("expected wrong secret to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpinoffConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
tools []string
|
||||
cpu string
|
||||
mem string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "full config",
|
||||
input: "tools: [rust, go]\ncpu: \"4\"\nmem: 8Gi\n",
|
||||
tools: []string{"rust", "go"},
|
||||
cpu: "4",
|
||||
mem: "8Gi",
|
||||
},
|
||||
{
|
||||
name: "tools only",
|
||||
input: "tools: [node]\n",
|
||||
tools: []string{"node"},
|
||||
},
|
||||
{
|
||||
name: "empty config",
|
||||
input: "",
|
||||
tools: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid yaml",
|
||||
input: "{{invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg, err := parseSpinoffConfig([]byte(tt.input))
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Tools) != len(tt.tools) {
|
||||
t.Fatalf("expected %d tools, got %d", len(tt.tools), len(cfg.Tools))
|
||||
}
|
||||
for i, tool := range tt.tools {
|
||||
if cfg.Tools[i] != tool {
|
||||
t.Errorf("tool %d: expected %q, got %q", i, tool, cfg.Tools[i])
|
||||
}
|
||||
}
|
||||
if tt.cpu != "" && cfg.CPU != tt.cpu {
|
||||
t.Errorf("expected cpu %q, got %q", tt.cpu, cfg.CPU)
|
||||
}
|
||||
if tt.mem != "" && cfg.Mem != tt.mem {
|
||||
t.Errorf("expected mem %q, got %q", tt.mem, cfg.Mem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue