- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
297 lines
8.2 KiB
Go
297 lines
8.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/config"
|
|
"switchboard-core/database"
|
|
"switchboard-core/middleware"
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
postgres "switchboard-core/store/postgres"
|
|
sqlite "switchboard-core/store/sqlite"
|
|
)
|
|
|
|
// ── Workspace Test Harness ────────────────
|
|
|
|
type workspaceHarness struct {
|
|
*testHarness
|
|
stores store.Stores
|
|
userToken string
|
|
userID string
|
|
}
|
|
|
|
func setupWorkspaceHarness(t *testing.T) *workspaceHarness {
|
|
t.Helper()
|
|
database.RequireTestDB(t)
|
|
database.TruncateAll(t)
|
|
|
|
cfg := &config.Config{
|
|
JWTSecret: testJWTSecret,
|
|
BasePath: "",
|
|
}
|
|
|
|
var stores store.Stores
|
|
if database.IsSQLite() {
|
|
stores = sqlite.NewStores(database.TestDB)
|
|
} else {
|
|
stores = postgres.NewStores(database.TestDB)
|
|
}
|
|
userCache := middleware.NewUserStatusCache()
|
|
|
|
r := gin.New()
|
|
api := r.Group("/api/v1")
|
|
protected := api.Group("")
|
|
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
|
|
|
// Workspace handler — nil wfs is fine for CRUD tests that don't touch filesystem
|
|
wsH := NewWorkspaceHandler(stores, nil)
|
|
protected.GET("/workspaces", wsH.List)
|
|
protected.GET("/workspaces/:id", wsH.Get)
|
|
|
|
// Git credentials — nil vault is fine for List (doesn't decrypt)
|
|
gitCredH := NewGitCredentialHandler(stores, nil)
|
|
protected.GET("/git-credentials", gitCredH.List)
|
|
|
|
userID := database.SeedTestUser(t, "wsuser", "wsuser@test.com")
|
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
|
userToken := makeToken(userID, "wsuser@test.com", "user")
|
|
|
|
return &workspaceHarness{
|
|
testHarness: &testHarness{router: r, t: t},
|
|
stores: stores,
|
|
userToken: userToken,
|
|
userID: userID,
|
|
}
|
|
}
|
|
|
|
// seedWorkspace creates a workspace via the store and returns it.
|
|
func (h *workspaceHarness) seedWorkspace(name, ownerType, ownerID string) *models.Workspace {
|
|
h.t.Helper()
|
|
w := &models.Workspace{
|
|
Name: name,
|
|
OwnerType: ownerType,
|
|
OwnerID: ownerID,
|
|
Status: models.WorkspaceStatusActive,
|
|
RootPath: "workspaces/test-" + name,
|
|
}
|
|
w.ID = store.NewID()
|
|
if err := h.stores.Workspaces.Create(context.Background(), w); err != nil {
|
|
h.t.Fatalf("seedWorkspace: %v", err)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// ── GET /workspaces — envelope ────────────
|
|
|
|
func TestWorkspaces_List_Empty_Envelope(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var body map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &body)
|
|
|
|
// Must have "data" key
|
|
data, ok := body["data"]
|
|
if !ok {
|
|
t.Fatal("GET /workspaces must return {\"data\": [...]}, missing \"data\" key")
|
|
}
|
|
|
|
// data must be an array (not null)
|
|
arr, ok := data.([]interface{})
|
|
if !ok {
|
|
t.Fatalf("data should be an array, got %T", data)
|
|
}
|
|
if len(arr) != 0 {
|
|
t.Errorf("expected empty array, got %d items", len(arr))
|
|
}
|
|
}
|
|
|
|
func TestWorkspaces_List_WithData_Envelope(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
// Seed a workspace owned by the user
|
|
h.seedWorkspace("test-ws", models.WorkspaceOwnerUser, h.userID)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var body map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &body)
|
|
|
|
data, ok := body["data"].([]interface{})
|
|
if !ok {
|
|
t.Fatal("data must be an array")
|
|
}
|
|
if len(data) != 1 {
|
|
t.Fatalf("expected 1 workspace, got %d", len(data))
|
|
}
|
|
|
|
// Verify workspace object shape
|
|
ws, ok := data[0].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("workspace entry should be an object")
|
|
}
|
|
|
|
for _, key := range []string{"id", "name", "owner_type", "owner_id", "status", "created_at", "updated_at"} {
|
|
if _, exists := ws[key]; !exists {
|
|
t.Errorf("missing field %q in workspace object", key)
|
|
}
|
|
}
|
|
|
|
if ws["name"] != "test-ws" {
|
|
t.Errorf("name: got %v, want test-ws", ws["name"])
|
|
}
|
|
if ws["owner_type"] != "user" {
|
|
t.Errorf("owner_type: got %v, want user", ws["owner_type"])
|
|
}
|
|
}
|
|
|
|
func TestWorkspaces_List_DoesNotExposeRootPath(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
h.seedWorkspace("secret-ws", models.WorkspaceOwnerUser, h.userID)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
|
|
var body map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &body)
|
|
|
|
data := body["data"].([]interface{})
|
|
ws := data[0].(map[string]interface{})
|
|
|
|
if _, exists := ws["root_path"]; exists {
|
|
t.Error("root_path must never be exposed in API responses")
|
|
}
|
|
}
|
|
|
|
func TestWorkspaces_List_IsolatedByUser(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
// Seed workspace for another user
|
|
otherID := database.SeedTestUser(t, "other", "other@test.com")
|
|
h.seedWorkspace("other-ws", models.WorkspaceOwnerUser, otherID)
|
|
|
|
// Also seed one for our user
|
|
h.seedWorkspace("my-ws", models.WorkspaceOwnerUser, h.userID)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
|
|
var body map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &body)
|
|
|
|
data := body["data"].([]interface{})
|
|
if len(data) != 1 {
|
|
t.Fatalf("expected 1 workspace (own only), got %d", len(data))
|
|
}
|
|
|
|
ws := data[0].(map[string]interface{})
|
|
if ws["name"] != "my-ws" {
|
|
t.Errorf("should only see own workspace, got %v", ws["name"])
|
|
}
|
|
}
|
|
|
|
// ── GET /workspaces/:id — single object ───
|
|
|
|
func TestWorkspaces_Get_Shape(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
w := h.seedWorkspace("detail-ws", models.WorkspaceOwnerUser, h.userID)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var body map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &body)
|
|
|
|
// Single object — no "data" wrapper
|
|
if _, exists := body["data"]; exists {
|
|
t.Error("GET /:id should return object directly, not wrapped in 'data'")
|
|
}
|
|
if body["id"] != w.ID {
|
|
t.Errorf("id: got %v, want %s", body["id"], w.ID)
|
|
}
|
|
if body["name"] != "detail-ws" {
|
|
t.Errorf("name: got %v, want detail-ws", body["name"])
|
|
}
|
|
}
|
|
|
|
func TestWorkspaces_Get_NotFound(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces/00000000-0000-0000-0000-000000000000", h.userToken, nil)
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaces_Get_ForbiddenForOtherUser(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
otherID := database.SeedTestUser(t, "stranger", "stranger@test.com")
|
|
w := h.seedWorkspace("private-ws", models.WorkspaceOwnerUser, otherID)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil)
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for other user's workspace, got %d", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ── GET /git-credentials — envelope ───────
|
|
|
|
func TestGitCredentials_List_Empty_Envelope(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var body map[string]interface{}
|
|
json.Unmarshal(resp.Body.Bytes(), &body)
|
|
|
|
// Must have "data" key
|
|
data, ok := body["data"]
|
|
if !ok {
|
|
t.Fatal("GET /git-credentials must return {\"data\": [...]}, missing \"data\" key")
|
|
}
|
|
|
|
// data must be an array (not null)
|
|
arr, ok := data.([]interface{})
|
|
if !ok {
|
|
t.Fatalf("data should be an array, got %T", data)
|
|
}
|
|
if len(arr) != 0 {
|
|
t.Errorf("expected empty array, got %d items", len(arr))
|
|
}
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────
|
|
|
|
func TestWorkspaces_RequiresAuth(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
resp := h.request("GET", "/api/v1/workspaces", "", nil)
|
|
if resp.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestGitCredentials_RequiresAuth(t *testing.T) {
|
|
h := setupWorkspaceHarness(t)
|
|
|
|
resp := h.request("GET", "/api/v1/git-credentials", "", nil)
|
|
if resp.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", resp.Code)
|
|
}
|
|
}
|