- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
441 lines
12 KiB
Go
441 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// mockCredentialStore implements port.CredentialStore for testing.
|
|
type mockCredentialStore struct {
|
|
creds map[string]domain.Credential
|
|
err error
|
|
}
|
|
|
|
func newMockCredentialStore() *mockCredentialStore {
|
|
return &mockCredentialStore{
|
|
creds: make(map[string]domain.Credential),
|
|
}
|
|
}
|
|
|
|
func (m *mockCredentialStore) Get(_ context.Context, key string) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
c, ok := m.creds[key]
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
return c.Value, nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) GetRequired(_ context.Context, key string) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
c, ok := m.creds[key]
|
|
if !ok {
|
|
return "", fmt.Errorf("credential not found: %s", key)
|
|
}
|
|
return c.Value, nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) Set(_ context.Context, cred domain.Credential) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
cred.CreatedAt = time.Now()
|
|
cred.UpdatedAt = time.Now()
|
|
m.creds[cred.Key] = cred
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) Delete(_ context.Context, key string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
if _, ok := m.creds[key]; !ok {
|
|
return domain.ErrCredentialNotFound
|
|
}
|
|
delete(m.creds, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) List(_ context.Context) ([]domain.Credential, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
var result []domain.Credential
|
|
for _, c := range m.creds {
|
|
result = append(result, c)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) ListByCategory(_ context.Context, category string) ([]domain.Credential, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
var result []domain.Credential
|
|
for _, c := range m.creds {
|
|
if c.Category == category {
|
|
result = append(result, c)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
result := make(map[string]string)
|
|
for _, k := range keys {
|
|
if c, ok := m.creds[k]; ok {
|
|
result[k] = c.Value
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockCredentialStore) SetMultiple(_ context.Context, creds []domain.Credential) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
for _, c := range creds {
|
|
c.CreatedAt = time.Now()
|
|
c.UpdatedAt = time.Now()
|
|
m.creds[c.Key] = c
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setupCredentialsHandler() (*CredentialsHandler, *mockCredentialStore, chi.Router) {
|
|
store := newMockCredentialStore()
|
|
h := NewCredentialsHandler(store)
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
return h, store, r
|
|
}
|
|
|
|
func TestCredentialsHandler_List(t *testing.T) {
|
|
t.Run("empty list", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
req := httptest.NewRequest("GET", "/credentials", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
data, ok := resp["data"].([]any)
|
|
if !ok {
|
|
t.Fatalf("response missing data array")
|
|
}
|
|
if len(data) != 0 {
|
|
t.Errorf("data length = %d, want 0", len(data))
|
|
}
|
|
})
|
|
|
|
t.Run("with credentials", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
store.creds["MY_TOKEN"] = domain.Credential{
|
|
Key: "MY_TOKEN",
|
|
Value: "****",
|
|
Category: "gitea",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/credentials", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
data, ok := resp["data"].([]any)
|
|
if !ok {
|
|
t.Fatalf("response missing data array")
|
|
}
|
|
if len(data) != 1 {
|
|
t.Errorf("data length = %d, want 1", len(data))
|
|
}
|
|
})
|
|
|
|
t.Run("filter by category", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
store.creds["GITEA_TOKEN"] = domain.Credential{
|
|
Key: "GITEA_TOKEN", Value: "****", Category: "gitea",
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
store.creds["CF_TOKEN"] = domain.Credential{
|
|
Key: "CF_TOKEN", Value: "****", Category: "cloudflare",
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/credentials?category=gitea", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
data := resp["data"].([]any)
|
|
if len(data) != 1 {
|
|
t.Errorf("data length = %d, want 1", len(data))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCredentialsHandler_Get(t *testing.T) {
|
|
t.Run("existing credential", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
store.creds["MY_TOKEN"] = domain.Credential{
|
|
Key: "MY_TOKEN", Value: "secret123",
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/credentials/MY_TOKEN", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
data := resp["data"].(map[string]any)
|
|
if data["key"] != "MY_TOKEN" {
|
|
t.Errorf("key = %q, want %q", data["key"], "MY_TOKEN")
|
|
}
|
|
if data["value"] != "secret123" {
|
|
t.Errorf("value = %q, want %q", data["value"], "secret123")
|
|
}
|
|
})
|
|
|
|
t.Run("not found", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
req := httptest.NewRequest("GET", "/credentials/MISSING", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCredentialsHandler_Set(t *testing.T) {
|
|
t.Run("valid credential", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetCredentialRequest{
|
|
Key: "NEW_TOKEN",
|
|
Value: "secret",
|
|
Description: "A test token",
|
|
Category: "gitea",
|
|
})
|
|
req := httptest.NewRequest("POST", "/credentials", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
|
|
if _, ok := store.creds["NEW_TOKEN"]; !ok {
|
|
t.Error("credential not stored")
|
|
}
|
|
})
|
|
|
|
t.Run("missing key", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetCredentialRequest{Value: "secret"})
|
|
req := httptest.NewRequest("POST", "/credentials", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
t.Run("missing value", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetCredentialRequest{Key: "TOKEN"})
|
|
req := httptest.NewRequest("POST", "/credentials", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid json", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
req := httptest.NewRequest("POST", "/credentials", bytes.NewReader([]byte("not json")))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCredentialsHandler_SetBatch(t *testing.T) {
|
|
t.Run("valid batch", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetBatchRequest{
|
|
Credentials: []SetCredentialRequest{
|
|
{Key: "TOKEN1", Value: "val1"},
|
|
{Key: "TOKEN2", Value: "val2"},
|
|
},
|
|
})
|
|
req := httptest.NewRequest("POST", "/credentials/batch", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
|
|
if len(store.creds) != 2 {
|
|
t.Errorf("stored credentials = %d, want 2", len(store.creds))
|
|
}
|
|
})
|
|
|
|
t.Run("empty array", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetBatchRequest{Credentials: []SetCredentialRequest{}})
|
|
req := httptest.NewRequest("POST", "/credentials/batch", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
t.Run("missing key in batch", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetBatchRequest{
|
|
Credentials: []SetCredentialRequest{
|
|
{Key: "TOKEN1", Value: "val1"},
|
|
{Key: "", Value: "val2"},
|
|
},
|
|
})
|
|
req := httptest.NewRequest("POST", "/credentials/batch", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
t.Run("missing value in batch", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
body, _ := json.Marshal(SetBatchRequest{
|
|
Credentials: []SetCredentialRequest{
|
|
{Key: "TOKEN1", Value: ""},
|
|
},
|
|
})
|
|
req := httptest.NewRequest("POST", "/credentials/batch", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCredentialsHandler_Delete(t *testing.T) {
|
|
t.Run("existing credential", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
store.creds["TO_DELETE"] = domain.Credential{
|
|
Key: "TO_DELETE", Value: "val",
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
|
|
req := httptest.NewRequest("DELETE", "/credentials/TO_DELETE", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
data := resp["data"].(map[string]any)
|
|
if data["status"] != "deleted" {
|
|
t.Errorf("status = %q, want %q", data["status"], "deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("not found returns 404", func(t *testing.T) {
|
|
_, _, router := setupCredentialsHandler()
|
|
|
|
req := httptest.NewRequest("DELETE", "/credentials/NONEXISTENT", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
|
|
t.Run("store error returns 500", func(t *testing.T) {
|
|
_, store, router := setupCredentialsHandler()
|
|
store.err = fmt.Errorf("database connection lost")
|
|
|
|
req := httptest.NewRequest("DELETE", "/credentials/ANY_KEY", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
})
|
|
}
|