build source

This commit is contained in:
build 2026-04-16 04:16:36 +00:00
commit ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions

Binary file not shown.

BIN
internal/api/._billing.go Normal file

Binary file not shown.

Binary file not shown.

BIN
internal/api/._cluster.go Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
internal/api/._pods.go Normal file

Binary file not shown.

BIN
internal/api/._pods_test.go Normal file

Binary file not shown.

BIN
internal/api/._router.go Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
internal/api/._runners.go Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
internal/api/._users.go Normal file

Binary file not shown.

Binary file not shown.

BIN
internal/api/._webhooks.go Normal file

Binary file not shown.

Binary file not shown.

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

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

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

View 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
View 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(),
)
})
}
}

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

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

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

View 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] = &copy
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 &copy, 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)
}
})
}
}

View 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
View 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
View 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
View 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
}

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