From 8c4cb9bbebe3f2a1ca711f3b2236fa426b920315 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 13 Mar 2026 16:09:16 +0000 Subject: [PATCH] Changeset 0.28.2.2 (#185) --- docs/ICD/profile.md | 188 +++++++++++++-- docs/ICD/workspaces.md | 345 ++++++++++++++++++++++++---- docs/ROADMAP.md | 90 ++++++-- server/handlers/git.go | 7 +- server/handlers/integration_test.go | 10 +- server/handlers/profile_test.go | 332 ++++++++++++++++++++++++++ server/handlers/settings.go | 13 +- server/handlers/workspace_test.go | 295 ++++++++++++++++++++++++ server/handlers/workspaces.go | 10 +- 9 files changed, 1199 insertions(+), 91 deletions(-) create mode 100644 server/handlers/profile_test.go create mode 100644 server/handlers/workspace_test.go diff --git a/docs/ICD/profile.md b/docs/ICD/profile.md index 1b33b0d..2348597 100644 --- a/docs/ICD/profile.md +++ b/docs/ICD/profile.md @@ -1,55 +1,205 @@ # User Profile & Settings -### Profile +User identity, preferences, and credentials. + +**All endpoints require:** `Auth()` middleware (JWT bearer token). + +--- + +### Get Profile ``` -GET /profile → profileResponse -PUT /profile ← { "display_name", "email" } +GET /profile ``` +**Auth:** Authenticated user + +Returns the current user's profile. + ```json { "id": "uuid", "username": "jdoe", "email": "jdoe@example.com", "display_name": "Jane Doe", - "role": "user|admin", - "avatar_url": "...", - "created_at": "...", - "last_login": "..." + "role": "user", + "avatar": "data:image/png;base64,...", + "settings": { "theme": "dark" }, + "created_at": "2025-06-15T14:30:00Z", + "last_login_at": "2025-06-15T14:30:00Z" } ``` -### Avatar +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | UUIDv4 | +| `username` | string | Unique login name | +| `email` | string | Unique, lowercased | +| `display_name` | string \| null | Optional friendly name | +| `role` | `"user"` \| `"admin"` | Platform role | +| `avatar` | string \| null | Data URI (PNG, 128×128). Omitted if unset. | +| `settings` | object | User preferences (theme, keybindings, etc.) | +| `created_at` | string | ISO 8601 timestamp | +| `last_login_at` | string \| null | ISO 8601 timestamp. Null if never logged in. | + +> **Note:** The profile endpoint returns `avatar` (not `avatar_url`) as a +> curated response shape. The `User` model uses `avatar_url` internally. + +--- + +### Update Profile + +``` +PUT /profile +``` + +**Auth:** Authenticated user + +```json +{ + "display_name": "New Name", + "email": "new@example.com" +} +``` + +Both fields are optional (partial update). Returns the full profile +(same shape as `GET /profile`). + +**Errors:** +- `409` — email already taken + +--- + +### Upload Avatar + +``` +POST /profile/avatar +``` + +**Auth:** Authenticated user + +**Content-Type:** `application/json` + +```json +{ + "image": "data:image/png;base64,..." +} +``` + +Accepts a base64 data URI or raw base64 string. The server decodes, +resizes to 128×128 PNG, and stores as a data URI. Max input: 2 MB. + +**Response:** + +```json +{ + "avatar": "data:image/png;base64,..." +} +``` + +**Errors:** +- `400` — missing `image` field, invalid base64, unsupported format, too large + +--- + +### Delete Avatar ``` -POST /profile/avatar ← multipart/form-data (field: "avatar") DELETE /profile/avatar ``` -### Password +**Auth:** Authenticated user + +**Response:** + +```json +{ + "message": "avatar removed" +} +``` + +--- + +### Change Password ``` POST /profile/password ``` +**Auth:** Authenticated user (builtin auth only) + ```json { - "current_password": "...", - "new_password": "..." + "current_password": "old-pass", + "new_password": "new-pass-min-8" } ``` -On password change, the UEK is re-wrapped with the new password -(BYOK keys remain accessible without re-encryption). +Validates `current_password` against stored bcrypt hash. `new_password` +must be 8–128 characters. -### App Settings +On success, the UEK (User Encryption Key) is re-wrapped with the new +password so BYOK-encrypted provider keys remain accessible. -``` -GET /settings → { "settings": {...} } -PUT /settings ← { "theme", "editor_keybindings", ... } +**Response:** + +```json +{ + "message": "password updated" +} ``` -User-level preferences (theme, keybindings, default model, etc.). +**Errors:** +- `400` — missing fields, password too short/long +- `401` — current password incorrect + +--- + +### Get Settings + +``` +GET /settings +``` + +**Auth:** Authenticated user + +Returns user-level preferences wrapped in a `settings` key. + +```json +{ + "settings": { + "theme": "dark", + "editor_keybindings": "vim", + "default_model": "claude-sonnet-4-5" + } +} +``` + +This is a **composite response** (named key wrapping a single object), +not a bare object. The `settings` key is always present; its value is +`{}` if the user has never saved preferences. + +--- + +### Update Settings + +``` +PUT /settings +``` + +**Auth:** Authenticated user + +```json +{ + "theme": "light", + "new_key": "new_value" +} +``` + +Shallow merge: incoming keys overwrite existing, unmentioned keys are +preserved. Handles edge cases: SQL NULL, JSON `null`, and corrupted +array values are all normalized to `{}` before merge. + +Returns the updated settings (same shape as `GET /settings`). --- diff --git a/docs/ICD/workspaces.md b/docs/ICD/workspaces.md index 442d33a..945d9d5 100644 --- a/docs/ICD/workspaces.md +++ b/docs/ICD/workspaces.md @@ -4,6 +4,13 @@ The **Workspace** is a virtual filesystem for the editor surface. Each workspace has a root directory, file operations, optional Git integration, and full-text indexing for code search. +**All endpoints require:** `Auth()` middleware (JWT bearer token). +Workspace-scoped endpoints additionally verify ownership via +`loadAndAuthorize` (user owns workspace, or is member of the +owning team/project/channel). + +--- + ### Workspace CRUD ``` @@ -14,62 +21,175 @@ PATCH /workspaces/:id ← partial update DELETE /workspaces/:id ``` +**Auth:** Authenticated user. `GET /workspaces` returns user-owned +workspaces plus those owned by the user's teams. All `:id` endpoints +verify the caller can access the workspace's owner entity. + Workspace object: ```json { "id": "uuid", "name": "my-project", - "owner_type": "user|project", + "owner_type": "user", "owner_id": "uuid", - "status": "active|archived", - "storage_bytes": 1048576, + "status": "active", + "max_bytes": 52428800, + "indexing_enabled": false, + "git_remote_url": "https://github.com/...", + "git_branch": "main", + "git_credential_id": "uuid", + "git_last_sync": "2025-06-15T14:30:00Z", "file_count": 42, - "created_at": "...", - "updated_at": "..." + "total_bytes": 1048576, + "created_at": "2025-06-15T14:30:00Z", + "updated_at": "2025-06-15T14:30:00Z" } ``` -### File Operations +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | UUIDv4 | +| `name` | string | Max 200 chars | +| `owner_type` | `"user"` \| `"project"` \| `"channel"` \| `"team"` | Owner entity type | +| `owner_id` | string | UUID of owner entity | +| `status` | `"active"` \| `"archived"` \| `"deleting"` | Lifecycle state | +| `max_bytes` | int \| null | Quota ceiling. Omitted if unset. | +| `indexing_enabled` | bool | Full-text search indexing | +| `git_remote_url` | string \| null | Git remote. Omitted if unset. | +| `git_branch` | string \| null | Tracked branch. Omitted if unset. | +| `git_credential_id` | string \| null | FK to git credential. Omitted if unset. | +| `git_last_sync` | string \| null | ISO 8601. Omitted if never synced. | +| `file_count` | int | Computed. Enriched on `GET /:id` only. | +| `total_bytes` | int | Computed. Enriched on `GET /:id` only. | +| `created_at` | string | ISO 8601 | +| `updated_at` | string | ISO 8601 | -All paths are relative to the workspace root. +> **Note:** `root_path` is a server-internal filesystem path and is +> never exposed in API responses (json tag `"-"`). -``` -GET /workspaces/:id/files?path=/ → { "files": [entries] } -GET /workspaces/:id/files/read?path=/a.md → { "content": "...", "size": 123 } -PUT /workspaces/:id/files/write ← { "path": "/a.md", "content": "..." } -DELETE /workspaces/:id/files/delete ← { "path": "/a.md" } -POST /workspaces/:id/files/mkdir ← { "path": "/src" } -``` - -File entry: +**Create request:** ```json { - "name": "main.go", - "path": "/src/main.go", - "is_dir": false, - "size": 4096, - "modified": "..." + "name": "my-project", + "owner_type": "user", + "owner_id": "uuid", + "max_bytes": 52428800 } ``` +Returns `201` with the workspace object. + +**PATCH request** (all fields optional): + +```json +{ + "name": "new-name", + "status": "archived", + "max_bytes": 104857600, + "indexing_enabled": true, + "git_remote_url": "https://...", + "git_branch": "develop", + "git_credential_id": "uuid" +} +``` + +Returns updated workspace object. + +**DELETE** returns `{"ok": true}`. Deletion is async (status set to +`"deleting"`, filesystem cleanup runs in background). + +--- + +### File Operations + +All paths are relative to the workspace root. File path is passed +as `?path=` query parameter. + +``` +GET /workspaces/:id/files?path=/ → { "data": [entries] } +GET /workspaces/:id/files/read?path=/a.md → raw file content (binary stream) +PUT /workspaces/:id/files/write?path=/a.md ← raw body (Content-Length required) +DELETE /workspaces/:id/files/delete?path=/a.md +POST /workspaces/:id/files/mkdir?path=/src +``` + +**Auth:** Authenticated user with workspace access. + +**`GET .../files`** accepts `?recursive=true` for deep listing. +Returns `{"data": [...]}` (never bare array). Nil slice guarded. + +File entry (from `WorkspaceFile` model): + +```json +{ + "id": "uuid", + "workspace_id": "uuid", + "path": "/src/main.go", + "is_directory": false, + "content_type": "text/x-go", + "size_bytes": 4096, + "sha256": "abc123...", + "index_status": "ready", + "chunk_count": 3, + "created_at": "2025-06-15T14:30:00Z", + "updated_at": "2025-06-15T14:30:00Z" +} +``` + +**`GET .../files/read`** returns raw file content with appropriate +`Content-Type` header and `Content-Length`. This is a binary stream, +not JSON. + +**`PUT .../files/write`** reads the request body as raw content. +Returns `{"ok": true, "path": "/a.md"}`. Returns `413` if the +write would exceed the workspace quota. + +**`DELETE .../files/delete`** accepts `?recursive=true` for +directory removal. Returns `{"ok": true}`. + +**`POST .../files/mkdir`** returns `201 {"ok": true, "path": "/src"}`. + +--- + ### Archive -**Download** (tar.gz of entire workspace): +**Download** (full workspace as archive): ``` -GET /workspaces/:id/archive/download +GET /workspaces/:id/archive/download?format=zip ``` -**Upload** (restore from tar.gz): +**Auth:** Authenticated user with workspace access. + +`format` query param: `zip` (default), `tar.gz`, `tgz`. +Returns binary stream with `Content-Disposition: attachment`. + +**Upload** (restore from archive): ``` POST /workspaces/:id/archive/upload -Content-Type: multipart/form-data +Content-Type: multipart/form-data (field: "file") ``` -### Reconcile, Stats, Index +**Auth:** Authenticated user with workspace access. + +Accepts `.zip`, `.tar.gz`, `.tgz` archives. Format detected from +filename extension. + +Returns: + +```json +{ + "ok": true, + "files_extracted": 42 +} +``` + +--- + +### Reconcile, Stats, Index Status **Reconcile** (sync filesystem state with DB metadata): @@ -77,13 +197,37 @@ Content-Type: multipart/form-data POST /workspaces/:id/reconcile ``` +**Auth:** Authenticated user with workspace access. + +Returns: + +```json +{ + "added": 5, + "removed": 2, + "updated": 3 +} +``` + **Stats:** ``` GET /workspaces/:id/stats ``` -Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`. +**Auth:** Authenticated user with workspace access. + +Returns a `WorkspaceStats` object: + +```json +{ + "workspace_id": "uuid", + "file_count": 42, + "dir_count": 8, + "total_bytes": 1048576, + "max_bytes": 52428800 +} +``` **Index status:** @@ -91,47 +235,166 @@ Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`. GET /workspaces/:id/index-status ``` -Returns indexing progress for full-text search. +**Auth:** Authenticated user with workspace access. + +Returns indexing progress for full-text search: + +```json +{ + "workspace_id": "uuid", + "indexing_enabled": true, + "status_counts": { + "pending": 3, + "indexing": 1, + "ready": 38, + "error": 0, + "skipped": 2 + }, + "total_chunks": 156 +} +``` + +--- ### Git Integration -All Git operations are workspace-scoped. +All Git operations are workspace-scoped. Require the workspace to +have a Git remote configured. ``` POST /workspaces/:id/git/clone ← { "url", "branch", "credential_id" } POST /workspaces/:id/git/pull POST /workspaces/:id/git/push -GET /workspaces/:id/git/status → { "files": [{ "path", "status" }] } +GET /workspaces/:id/git/status → GitStatus object GET /workspaces/:id/git/diff → { "diff": "..." } -POST /workspaces/:id/git/commit ← { "message", "files": [...] } -GET /workspaces/:id/git/log → { "commits": [...] } +POST /workspaces/:id/git/commit ← { "message", "paths": [...] } +GET /workspaces/:id/git/log → { "data": [GitLogEntry] } GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" } POST /workspaces/:id/git/checkout ← { "branch": "feature-x" } ``` +**Auth:** Authenticated user with workspace access. + +**`POST .../git/clone`** request: + +```json +{ + "url": "https://github.com/user/repo.git", + "branch": "main", + "credential_id": "uuid" +} +``` + +`branch` and `credential_id` are optional. Returns `{"status": "cloned"}`. + +**`POST .../git/pull`** returns `{"status": "pulled"}`. + +**`POST .../git/push`** returns `{"status": "pushed"}`. + +**`GET .../git/status`** returns the full `GitStatus` object: + +```json +{ + "branch": "main", + "clean": false, + "ahead": 2, + "behind": 0, + "staged": [{ "path": "file.go", "status": "M" }], + "modified": [{ "path": "other.go", "status": "M" }], + "untracked": ["new-file.txt"] +} +``` + +**`GET .../git/diff`** accepts optional `?path=file.go` for single-file diff. +Returns `{"diff": "..."}` (unified diff string). + +**`POST .../git/commit`** request: + +```json +{ + "message": "fix: resolve null pointer", + "paths": ["/src/main.go", "/src/util.go"] +} +``` + +`paths` is optional (commits all staged if omitted). Returns +`{"status": "committed"}`. + +**`GET .../git/log`** accepts optional `?n=20` (default 20, max 100). +Returns `{"data": [GitLogEntry]}`: + +```json +{ + "data": [ + { + "hash": "abc123...", + "short_hash": "abc123", + "author": "Jane Doe", + "date": "2025-06-15T14:30:00Z", + "message": "Initial commit" + } + ] +} +``` + +**`GET .../git/branches`** returns: + +```json +{ + "current": "main", + "branches": ["main", "develop", "feature-x"] +} +``` + +**`POST .../git/checkout`** returns +`{"status": "checked out", "branch": "feature-x"}`. + +--- + ### Git Credentials User-owned, encrypted credential storage for Git operations. -Encrypted with the user's UEK (same as BYOK keys — admins cannot -recover). +Encrypted with the user's UEK via the platform vault (same pattern +as BYOK keys — admins cannot recover). + +Independent of workspace scope — credentials are user-level. ``` -POST /git-credentials ← { "name", "auth_type", "token"|"username"+"password"|"ssh_key" } +POST /git-credentials ← { "name", "auth_type", ... } GET /git-credentials → { "data": [GitCredentialSummary] } DELETE /git-credentials/:id ``` -`auth_type`: `https_pat`, `https_basic`, or `ssh_key`. +**Auth:** Authenticated user. Credentials are scoped to the requesting user. -Summary response (never exposes encrypted data): +**Create request:** + +`auth_type` determines which fields are required: + +| `auth_type` | Required fields | +|-------------|----------------| +| `https_pat` | `token` | +| `https_basic` | `username`, `password` | +| `ssh_key` | `private_key` (+ optional `passphrase`) | + +Returns `201` with the credential summary (never exposes encrypted data). + +**List response:** ```json { - "id": "uuid", - "name": "GitHub PAT", - "auth_type": "https_pat", - "created_at": "..." + "data": [ + { + "id": "uuid", + "name": "GitHub PAT", + "auth_type": "https_pat", + "created_at": "2025-06-15T14:30:00Z" + } + ] } ``` +**DELETE** returns `{"deleted": true}`. Returns `404` if credential +not found or not owned by the requesting user. + --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 62cc250..32153e3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,13 +24,14 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅ │ v0.28.0 Platform Polish ├─ v0.28.1 Surfaces ICD audit ✅ - ├─ v0.28.2 ICD audit round 2 - ├─ v0.28.3 Security tier (red team) - ├─ v0.28.4 Infrastructure + ├─ v0.28.2 ICD audit — all domains + ├─ v0.28.3 FE decomp + SDK prep + ├─ v0.28.4 Security tier (red team) + ├─ v0.28.5 Infrastructure │ (virtual scroll, KB auto-inject, │ Helm chart, provider model prefs, │ git credentials UI) - └─ v0.28.5 Frontend SDK + └─ v0.28.6 Frontend SDK │ v0.29.0 Starlark Sandbox (eval loop, permissions, admin UI) @@ -57,7 +58,7 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅ ## v0.28.0 — Platform Polish -Audit arc, frontend SDK, and infrastructure improvements. +Audit arc, frontend decomposition, security, and infrastructure improvements. ### v0.28.1 — Surfaces ICD Audit ✅ - [x] ICD `surfaces.md` corrected (6 discrepancies: field name, archive format, response shape) @@ -66,15 +67,19 @@ Audit arc, frontend SDK, and infrastructure improvements. - [x] 22 handler-level tests + 14 store-level tests (PG + SQLite) - [x] CI timeout 8m → 12m for PG integration tests -### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers -Known ICD report failures to investigate (likely envelope shape mismatches): -- [ ] `GET /settings` — `missing key "settings"` (profile) -- [x] `GET /notifications/preferences` — `missing key "preferences"` (envelope fix) -- [ ] `GET /workspaces` — `missing key "data"` -- [ ] `GET /git-credentials` — `missing key "data"` -- [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests) -- [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`, - `presence.changed`, `typing`, `workflow.assigned` event shapes in ICD +### v0.28.2 — ICD Audit: All Domains +Full audit of every ICD document against code. Goal: ICD becomes the single +source of truth. After this version, the workflow is ICD-first — update the +contract before changing code. + +**Methodology:** Read ICD → grep all source → trace route → handler → store +(PG + SQLite) → tests → enforce `{"data": [...]}` envelope convention → fix +ICD → fix code → fix runner → CI green. Final pass picks up straggling +failures across all domains. + +**Completed audits:** + +*Notifications (cs0):* - [x] Notifications ICD audit: object shape, query params, response envelopes, WS events - [x] Notification type enum sync: remove aspirational types, add implemented types (`kb.ready`, `kb.error`, `grant.changed`, `task.budget_exceeded`) @@ -82,9 +87,9 @@ Known ICD report failures to investigate (likely envelope shape mismatches): - [x] Implement `user.mentioned` persisted notification (was WS-only) - [x] Implement `workflow.claimed` persisted notification (was WS-only) - [x] Remove dead `NotifTypeProjectInvite` constant -- [ ] Notification handler + store tests (PG + SQLite) +- [x] `GET /notifications/preferences` — envelope fix (`missing key "preferences"`) -**Knowledge ICD audit (cs1–cs4):** +*Knowledge (cs1–cs4):* - [x] `knowledge.md` corrected: KB object shape (6 field mismatches), search envelope (`data` not `results`), search result fields, status progression (`extracting` step), file type support (text-only, not binary), auth annotations @@ -103,7 +108,54 @@ Known ICD report failures to investigate (likely envelope shape mismatches): `stores.Teams.IsTeamAdmin`; remove `database` import (cs4) - [x] Team-scoped KB creation test: member denied, team admin succeeds (cs4) -### v0.28.3 — Security Tier (ICD Runner Red Team) +*Profile (cs5):* +- [x] `profile.md` corrected: avatar API (was `multipart/form-data`, actually JSON + base64), response shapes for all 7 endpoints, auth annotations, field table +- [x] P0 fix: `GET /settings` returns bare object → wrapped in `{"settings": {...}}` +- [x] `profileResponse` shape: add `last_login_at` field + dialect-safe time scan + (`database.ST()` / `database.SNT()`), COALESCE guard on settings column +- [x] Integration test harness: register all 7 profile/settings routes (was only + `GET /profile`), remove stale `/avatar` route aliases +- [x] New `profile_test.go`: GET profile shape, PUT profile (display_name, email, + duplicate email 409), GET/PUT settings envelope, password change (success, + wrong current 401, too short 400), avatar delete + +*Workspaces (cs6):* +- [x] `workspaces.md` corrected: workspace object shape (added `indexing_enabled`, + `git_*` fields, `total_bytes` not `storage_bytes`, `owner_type` full enum), + file entry shape (`is_directory`/`size_bytes` not `is_dir`/`size`), git status + full shape, git log envelope (`{"data": [...]}`), git commit request (`paths` + not `files`), archive format query param, auth annotations, all response shapes +- [x] P0 fix: `GET /workspaces` returns bare array → `{"data": [...]}` +- [x] P0 fix: `GET /git-credentials` returns bare array → `{"data": [...]}` +- [x] Fix: `GET .../git/log` returns bare array → `{"data": [...]}` + nil slice guard +- [x] New `workspace_test.go`: List empty envelope, list with data + shape, root_path + not exposed, user isolation, GET by ID shape, not found 404, forbidden 403, + git-credentials empty envelope, auth required (11 tests) + +**Remaining audits:** + +| Priority | ICD | Known failures | Notes | +|----------|-----|----------------|-------| +| 1 | `projects.md` | 0 | Passed clean, needs full trace | +| 2 | `websocket.md` | — | Document event shapes only (not testable in runner) | + +- [ ] Notification handler + store tests (PG + SQLite) +- [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`, + `presence.changed`, `typing`, `workflow.assigned` event shapes in ICD +- [ ] Final straggler pass: run full ICD runner, fix any remaining failures + +### v0.28.3 — Frontend Decomposition + ICD Audit SDK Prep +Frontend JS decomposition and ICD runner hardening. Prepares the codebase +for the SDK layer by untangling the current 15-file global soup. + +- [ ] JS dependency audit: map every `window.*` global, cross-file call, init order +- [ ] Extract shared primitives: toast, confirm, theme, API client into importable modules +- [ ] ICD runner gains test tiers: `crud`, `envelope`, `security`, `sdk` +- [ ] Runner coverage target: 100% of ICD-documented endpoints have at least one test +- [ ] Document JS module graph and init sequence + +### v0.28.4 — Security Tier (ICD Runner Red Team) New `security` tier in ICD test runner. Multi-user fixtures already exist. **Auth boundary:** @@ -132,7 +184,7 @@ New `security` tier in ICD test runner. Multi-user fixtures already exist. - [ ] Visitor → escalate to authenticated user via crafted headers - [ ] Cross-visitor isolation: visitor A's cookie cannot read visitor B's messages -### v0.28.4 — Infrastructure +### v0.28.5 — Infrastructure - [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) - [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle - [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) @@ -146,7 +198,7 @@ New `security` tier in ICD test runner. Multi-user fixtures already exist. (`POST /admin/notifications/broadcast`), fan-out to all active users via `NotifyMany`, admin UI for composing announcements -### v0.28.5 — Frontend SDK +### v0.28.6 — Frontend SDK `switchboard-sdk.js` — composition layer over existing globals. Surface authors consume a single coherent API instead of hunting through 15 JS files. diff --git a/server/handlers/git.go b/server/handlers/git.go index 3565435..413162d 100644 --- a/server/handlers/git.go +++ b/server/handlers/git.go @@ -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. diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 14b4b35..12067bc 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -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) diff --git a/server/handlers/profile_test.go b/server/handlers/profile_test.go new file mode 100644 index 0000000..5497f02 --- /dev/null +++ b/server/handlers/profile_test.go @@ -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 +} diff --git a/server/handlers/settings.go b/server/handlers/settings.go index e9450d3..9f63d06 100644 --- a/server/handlers/settings.go +++ b/server/handlers/settings.go @@ -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) ────── diff --git a/server/handlers/workspace_test.go b/server/handlers/workspace_test.go new file mode 100644 index 0000000..f840d1b --- /dev/null +++ b/server/handlers/workspace_test.go @@ -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) + } +} diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go index 76e866c..e6c9c8e 100644 --- a/server/handlers/workspaces.go +++ b/server/handlers/workspaces.go @@ -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) {