Changeset 0.28.2.2 (#185)
This commit is contained in:
@@ -157,7 +157,10 @@ func (h *GitHandler) Log(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entries)
|
||||
if entries == nil {
|
||||
entries = []models.GitLogEntry{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": entries})
|
||||
}
|
||||
|
||||
// ── Branches ─────────────────────────────────
|
||||
@@ -316,7 +319,7 @@ func (h *GitCredentialHandler) List(c *gin.Context) {
|
||||
for _, cred := range creds {
|
||||
summaries = append(summaries, cred.Summary())
|
||||
}
|
||||
c.JSON(http.StatusOK, summaries)
|
||||
c.JSON(http.StatusOK, gin.H{"data": summaries})
|
||||
}
|
||||
|
||||
// Delete removes a git credential.
|
||||
|
||||
@@ -248,6 +248,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
// Profile / Settings
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
protected.PUT("/profile", settings.UpdateProfile)
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
protected.POST("/profile/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
// Personas
|
||||
personas := NewPersonaHandler(stores)
|
||||
@@ -325,10 +331,6 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
||||
|
||||
// Avatar (uses settings handler)
|
||||
protected.PUT("/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/avatar", settings.DeleteAvatar)
|
||||
|
||||
// Tasks (v0.28.0)
|
||||
taskH := NewTaskHandler(stores)
|
||||
protected.GET("/tasks", taskH.ListMine)
|
||||
|
||||
332
server/handlers/profile_test.go
Normal file
332
server/handlers/profile_test.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
)
|
||||
|
||||
// ── Profile Test Harness ──────────────────
|
||||
|
||||
type profileHarness struct {
|
||||
*testHarness
|
||||
userToken string
|
||||
userID string
|
||||
}
|
||||
|
||||
func setupProfileHarness(t *testing.T) *profileHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
protected.PUT("/profile", settings.UpdateProfile)
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
protected.POST("/profile/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
userID := database.SeedTestUser(t, "profuser", "profuser@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "profuser@test.com", "user")
|
||||
|
||||
return &profileHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /profile ──────────────────────────
|
||||
|
||||
func TestProfile_Get_Shape(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/profile", 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)
|
||||
|
||||
// Required fields
|
||||
for _, key := range []string{"id", "username", "email", "role", "settings", "created_at"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Errorf("missing required field %q in profile response", key)
|
||||
}
|
||||
}
|
||||
|
||||
if body["username"] != "profuser" {
|
||||
t.Errorf("username: got %v, want profuser", body["username"])
|
||||
}
|
||||
if body["email"] != "profuser@test.com" {
|
||||
t.Errorf("email: got %v, want profuser@test.com", body["email"])
|
||||
}
|
||||
if body["role"] != "user" {
|
||||
t.Errorf("role: got %v, want user", body["role"])
|
||||
}
|
||||
|
||||
// settings must be an object (not null)
|
||||
settings, ok := body["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("settings should be an object, got %T", body["settings"])
|
||||
}
|
||||
_ = settings
|
||||
|
||||
// avatar_url should NOT appear (handler uses "avatar" json tag)
|
||||
if _, exists := body["avatar_url"]; exists {
|
||||
t.Error("profile should return 'avatar' not 'avatar_url'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_Get_RequiresAuth(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/profile", "", nil)
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without token, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT /profile ──────────────────────────
|
||||
|
||||
func TestProfile_Update_DisplayName(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
name := "New Name"
|
||||
resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{
|
||||
"display_name": name,
|
||||
})
|
||||
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)
|
||||
|
||||
if body["display_name"] != name {
|
||||
t.Errorf("display_name: got %v, want %s", body["display_name"], name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_Update_Email(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{
|
||||
"email": "NEW@TEST.COM",
|
||||
})
|
||||
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)
|
||||
|
||||
// Email should be lowercased
|
||||
if body["email"] != "new@test.com" {
|
||||
t.Errorf("email: got %v, want new@test.com", body["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_Update_DuplicateEmail_409(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
// Seed another user with the target email
|
||||
database.SeedTestUser(t, "other", "taken@test.com")
|
||||
|
||||
resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{
|
||||
"email": "taken@test.com",
|
||||
})
|
||||
if resp.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 for duplicate email, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /settings ─────────────────────────
|
||||
|
||||
func TestSettings_Get_Envelope(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/settings", 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)
|
||||
|
||||
// P0: Must have "settings" key — this was the surface test failure
|
||||
settings, ok := body["settings"]
|
||||
if !ok {
|
||||
t.Fatal("GET /settings must return {\"settings\": {...}}, missing \"settings\" key")
|
||||
}
|
||||
|
||||
// settings value must be an object (not null)
|
||||
settingsMap, ok := settings.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("settings value should be an object, got %T", settings)
|
||||
}
|
||||
|
||||
// New user should have empty settings
|
||||
if len(settingsMap) != 0 {
|
||||
t.Errorf("new user settings should be empty, got %v", settingsMap)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT /settings ─────────────────────────
|
||||
|
||||
func TestSettings_Update_MergeAndEnvelope(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
// Set initial
|
||||
resp := h.request("PUT", "/api/v1/settings", h.userToken, map[string]interface{}{
|
||||
"theme": "dark",
|
||||
"lang": "en",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("initial PUT: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body1 map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body1)
|
||||
settings1, _ := body1["settings"].(map[string]interface{})
|
||||
if settings1["theme"] != "dark" || settings1["lang"] != "en" {
|
||||
t.Fatalf("initial settings: got %v", settings1)
|
||||
}
|
||||
|
||||
// Merge: change theme, keep lang
|
||||
resp2 := h.request("PUT", "/api/v1/settings", h.userToken, map[string]interface{}{
|
||||
"theme": "light",
|
||||
})
|
||||
if resp2.Code != http.StatusOK {
|
||||
t.Fatalf("merge PUT: got %d, body: %s", resp2.Code, resp2.Body.String())
|
||||
}
|
||||
|
||||
var body2 map[string]interface{}
|
||||
json.Unmarshal(resp2.Body.Bytes(), &body2)
|
||||
settings2, _ := body2["settings"].(map[string]interface{})
|
||||
|
||||
if settings2["theme"] != "light" {
|
||||
t.Errorf("theme should be overwritten to 'light', got %v", settings2["theme"])
|
||||
}
|
||||
if settings2["lang"] != "en" {
|
||||
t.Errorf("lang should be preserved as 'en', got %v", settings2["lang"])
|
||||
}
|
||||
|
||||
// Verify via GET
|
||||
resp3 := h.request("GET", "/api/v1/settings", h.userToken, nil)
|
||||
var body3 map[string]interface{}
|
||||
json.Unmarshal(resp3.Body.Bytes(), &body3)
|
||||
settings3, _ := body3["settings"].(map[string]interface{})
|
||||
if settings3["theme"] != "light" || settings3["lang"] != "en" {
|
||||
t.Errorf("GET after merge: got %v", settings3)
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /profile/password ────────────────
|
||||
|
||||
func TestProfile_ChangePassword(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
// Seed user with a known bcrypt hash for "oldpassword"
|
||||
// The default SeedTestUser uses a dummy hash. We need a real one.
|
||||
hash, _ := hashPassword("oldpassword")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID)
|
||||
|
||||
// Change password
|
||||
resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{
|
||||
"current_password": "oldpassword",
|
||||
"new_password": "newpassword123",
|
||||
})
|
||||
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)
|
||||
if body["message"] != "password updated" {
|
||||
t.Errorf("expected success message, got %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_ChangePassword_WrongCurrent_401(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
hash, _ := hashPassword("correctpassword")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID)
|
||||
|
||||
resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{
|
||||
"current_password": "wrongpassword",
|
||||
"new_password": "newpassword123",
|
||||
})
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for wrong current password, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_ChangePassword_TooShort_400(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
hash, _ := hashPassword("oldpassword")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID)
|
||||
|
||||
resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{
|
||||
"current_password": "oldpassword",
|
||||
"new_password": "short",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for short password, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /profile/avatar ────────────────
|
||||
|
||||
func TestProfile_DeleteAvatar(t *testing.T) {
|
||||
h := setupProfileHarness(t)
|
||||
|
||||
// Set an avatar first
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET avatar_url = $1 WHERE id = $2"),
|
||||
"data:image/png;base64,test", h.userID)
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/profile/avatar", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Verify avatar is cleared in profile
|
||||
resp2 := h.request("GET", "/api/v1/profile", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp2.Body.Bytes(), &body)
|
||||
|
||||
// avatar should be omitted (omitempty) or null
|
||||
if av, exists := body["avatar"]; exists && av != nil {
|
||||
t.Errorf("avatar should be cleared, got %v", av)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────
|
||||
|
||||
func hashPassword(pw string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(pw), bcryptCost)
|
||||
return string(h), err
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -34,7 +35,8 @@ type profileResponse struct {
|
||||
Role string `json:"role"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsHandler manages user profile and preferences.
|
||||
@@ -55,11 +57,14 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
||||
var p profileResponse
|
||||
var settingsRaw string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT id, username, email, display_name, role, avatar_url, settings::text, created_at
|
||||
SELECT id, username, email, display_name, role, avatar_url,
|
||||
COALESCE(NULLIF(settings::text, 'null'), '{}'),
|
||||
created_at, last_login_at
|
||||
FROM users WHERE id = $1
|
||||
`), userID).Scan(
|
||||
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
||||
&p.Avatar, &settingsRaw, &p.CreatedAt,
|
||||
&p.Avatar, &settingsRaw,
|
||||
database.ST(&p.CreatedAt), database.SNT(&p.LastLoginAt),
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
@@ -185,7 +190,7 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
settings := make(map[string]interface{})
|
||||
_ = json.Unmarshal([]byte(settingsRaw), &settings)
|
||||
|
||||
c.JSON(http.StatusOK, settings)
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
// ── Update User Settings (JSONB merge) ──────
|
||||
|
||||
295
server/handlers/workspace_test.go
Normal file
295
server/handlers/workspace_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
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)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (h *WorkspaceHandler) List(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, all)
|
||||
c.JSON(http.StatusOK, gin.H{"data": all})
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
@@ -146,7 +146,13 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
// Re-fetch to return the updated object
|
||||
updated, err := h.stores.Workspaces.GetByID(c.Request.Context(), w.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload workspace"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||
|
||||
Reference in New Issue
Block a user