package handlers import ( "context" "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/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) } }