Changeset 0.28.2.2 (#185)
This commit is contained in:
@@ -1,55 +1,205 @@
|
|||||||
# User Profile & Settings
|
# User Profile & Settings
|
||||||
|
|
||||||
### Profile
|
User identity, preferences, and credentials.
|
||||||
|
|
||||||
|
**All endpoints require:** `Auth()` middleware (JWT bearer token).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Profile
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /profile → profileResponse
|
GET /profile
|
||||||
PUT /profile ← { "display_name", "email" }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** Authenticated user
|
||||||
|
|
||||||
|
Returns the current user's profile.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"username": "jdoe",
|
"username": "jdoe",
|
||||||
"email": "jdoe@example.com",
|
"email": "jdoe@example.com",
|
||||||
"display_name": "Jane Doe",
|
"display_name": "Jane Doe",
|
||||||
"role": "user|admin",
|
"role": "user",
|
||||||
"avatar_url": "...",
|
"avatar": "data:image/png;base64,...",
|
||||||
"created_at": "...",
|
"settings": { "theme": "dark" },
|
||||||
"last_login": "..."
|
"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
|
DELETE /profile/avatar
|
||||||
```
|
```
|
||||||
|
|
||||||
### Password
|
**Auth:** Authenticated user
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "avatar removed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Change Password
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /profile/password
|
POST /profile/password
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** Authenticated user (builtin auth only)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"current_password": "...",
|
"current_password": "old-pass",
|
||||||
"new_password": "..."
|
"new_password": "new-pass-min-8"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
On password change, the UEK is re-wrapped with the new password
|
Validates `current_password` against stored bcrypt hash. `new_password`
|
||||||
(BYOK keys remain accessible without re-encryption).
|
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.
|
||||||
|
|
||||||
```
|
**Response:**
|
||||||
GET /settings → { "settings": {...} }
|
|
||||||
PUT /settings ← { "theme", "editor_keybindings", ... }
|
```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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ The **Workspace** is a virtual filesystem for the editor surface.
|
|||||||
Each workspace has a root directory, file operations, optional Git
|
Each workspace has a root directory, file operations, optional Git
|
||||||
integration, and full-text indexing for code search.
|
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
|
### Workspace CRUD
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -14,62 +21,175 @@ PATCH /workspaces/:id ← partial update
|
|||||||
DELETE /workspaces/:id
|
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:
|
Workspace object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"name": "my-project",
|
"name": "my-project",
|
||||||
"owner_type": "user|project",
|
"owner_type": "user",
|
||||||
"owner_id": "uuid",
|
"owner_id": "uuid",
|
||||||
"status": "active|archived",
|
"status": "active",
|
||||||
"storage_bytes": 1048576,
|
"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,
|
"file_count": 42,
|
||||||
"created_at": "...",
|
"total_bytes": 1048576,
|
||||||
"updated_at": "..."
|
"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 `"-"`).
|
||||||
|
|
||||||
```
|
**Create request:**
|
||||||
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:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "main.go",
|
"name": "my-project",
|
||||||
"path": "/src/main.go",
|
"owner_type": "user",
|
||||||
"is_dir": false,
|
"owner_id": "uuid",
|
||||||
"size": 4096,
|
"max_bytes": 52428800
|
||||||
"modified": "..."
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### 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
|
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):
|
**Reconcile** (sync filesystem state with DB metadata):
|
||||||
|
|
||||||
@@ -77,13 +197,37 @@ Content-Type: multipart/form-data
|
|||||||
POST /workspaces/:id/reconcile
|
POST /workspaces/:id/reconcile
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** Authenticated user with workspace access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"added": 5,
|
||||||
|
"removed": 2,
|
||||||
|
"updated": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Stats:**
|
**Stats:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /workspaces/:id/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:**
|
**Index status:**
|
||||||
|
|
||||||
@@ -91,47 +235,166 @@ Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`.
|
|||||||
GET /workspaces/:id/index-status
|
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
|
### 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/clone ← { "url", "branch", "credential_id" }
|
||||||
POST /workspaces/:id/git/pull
|
POST /workspaces/:id/git/pull
|
||||||
POST /workspaces/:id/git/push
|
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": "..." }
|
GET /workspaces/:id/git/diff → { "diff": "..." }
|
||||||
POST /workspaces/:id/git/commit ← { "message", "files": [...] }
|
POST /workspaces/:id/git/commit ← { "message", "paths": [...] }
|
||||||
GET /workspaces/:id/git/log → { "commits": [...] }
|
GET /workspaces/:id/git/log → { "data": [GitLogEntry] }
|
||||||
GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" }
|
GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" }
|
||||||
POST /workspaces/:id/git/checkout ← { "branch": "feature-x" }
|
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
|
### Git Credentials
|
||||||
|
|
||||||
User-owned, encrypted credential storage for Git operations.
|
User-owned, encrypted credential storage for Git operations.
|
||||||
Encrypted with the user's UEK (same as BYOK keys — admins cannot
|
Encrypted with the user's UEK via the platform vault (same pattern
|
||||||
recover).
|
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] }
|
GET /git-credentials → { "data": [GitCredentialSummary] }
|
||||||
DELETE /git-credentials/:id
|
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
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"name": "GitHub PAT",
|
"name": "GitHub PAT",
|
||||||
"auth_type": "https_pat",
|
"auth_type": "https_pat",
|
||||||
"created_at": "..."
|
"created_at": "2025-06-15T14:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**DELETE** returns `{"deleted": true}`. Returns `404` if credential
|
||||||
|
not found or not owned by the requesting user.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
|
|||||||
│
|
│
|
||||||
v0.28.0 Platform Polish
|
v0.28.0 Platform Polish
|
||||||
├─ v0.28.1 Surfaces ICD audit ✅
|
├─ v0.28.1 Surfaces ICD audit ✅
|
||||||
├─ v0.28.2 ICD audit round 2
|
├─ v0.28.2 ICD audit — all domains
|
||||||
├─ v0.28.3 Security tier (red team)
|
├─ v0.28.3 FE decomp + SDK prep
|
||||||
├─ v0.28.4 Infrastructure
|
├─ v0.28.4 Security tier (red team)
|
||||||
|
├─ v0.28.5 Infrastructure
|
||||||
│ (virtual scroll, KB auto-inject,
|
│ (virtual scroll, KB auto-inject,
|
||||||
│ Helm chart, provider model prefs,
|
│ Helm chart, provider model prefs,
|
||||||
│ git credentials UI)
|
│ git credentials UI)
|
||||||
└─ v0.28.5 Frontend SDK
|
└─ v0.28.6 Frontend SDK
|
||||||
│
|
│
|
||||||
v0.29.0 Starlark Sandbox
|
v0.29.0 Starlark Sandbox
|
||||||
(eval loop, permissions, admin UI)
|
(eval loop, permissions, admin UI)
|
||||||
@@ -57,7 +58,7 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
|
|||||||
|
|
||||||
## v0.28.0 — Platform Polish
|
## 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 ✅
|
### v0.28.1 — Surfaces ICD Audit ✅
|
||||||
- [x] ICD `surfaces.md` corrected (6 discrepancies: field name, archive format, response shape)
|
- [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] 22 handler-level tests + 14 store-level tests (PG + SQLite)
|
||||||
- [x] CI timeout 8m → 12m for PG integration tests
|
- [x] CI timeout 8m → 12m for PG integration tests
|
||||||
|
|
||||||
### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers
|
### v0.28.2 — ICD Audit: All Domains
|
||||||
Known ICD report failures to investigate (likely envelope shape mismatches):
|
Full audit of every ICD document against code. Goal: ICD becomes the single
|
||||||
- [ ] `GET /settings` — `missing key "settings"` (profile)
|
source of truth. After this version, the workflow is ICD-first — update the
|
||||||
- [x] `GET /notifications/preferences` — `missing key "preferences"` (envelope fix)
|
contract before changing code.
|
||||||
- [ ] `GET /workspaces` — `missing key "data"`
|
|
||||||
- [ ] `GET /git-credentials` — `missing key "data"`
|
**Methodology:** Read ICD → grep all source → trace route → handler → store
|
||||||
- [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests)
|
(PG + SQLite) → tests → enforce `{"data": [...]}` envelope convention → fix
|
||||||
- [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`,
|
ICD → fix code → fix runner → CI green. Final pass picks up straggling
|
||||||
`presence.changed`, `typing`, `workflow.assigned` event shapes in ICD
|
failures across all domains.
|
||||||
|
|
||||||
|
**Completed audits:**
|
||||||
|
|
||||||
|
*Notifications (cs0):*
|
||||||
- [x] Notifications ICD audit: object shape, query params, response envelopes, WS events
|
- [x] Notifications ICD audit: object shape, query params, response envelopes, WS events
|
||||||
- [x] Notification type enum sync: remove aspirational types, add implemented types
|
- [x] Notification type enum sync: remove aspirational types, add implemented types
|
||||||
(`kb.ready`, `kb.error`, `grant.changed`, `task.budget_exceeded`)
|
(`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 `user.mentioned` persisted notification (was WS-only)
|
||||||
- [x] Implement `workflow.claimed` persisted notification (was WS-only)
|
- [x] Implement `workflow.claimed` persisted notification (was WS-only)
|
||||||
- [x] Remove dead `NotifTypeProjectInvite` constant
|
- [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
|
- [x] `knowledge.md` corrected: KB object shape (6 field mismatches), search envelope
|
||||||
(`data` not `results`), search result fields, status progression (`extracting`
|
(`data` not `results`), search result fields, status progression (`extracting`
|
||||||
step), file type support (text-only, not binary), auth annotations
|
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)
|
`stores.Teams.IsTeamAdmin`; remove `database` import (cs4)
|
||||||
- [x] Team-scoped KB creation test: member denied, team admin succeeds (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.
|
New `security` tier in ICD test runner. Multi-user fixtures already exist.
|
||||||
|
|
||||||
**Auth boundary:**
|
**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
|
- [ ] Visitor → escalate to authenticated user via crafted headers
|
||||||
- [ ] Cross-visitor isolation: visitor A's cookie cannot read visitor B's messages
|
- [ ] 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)
|
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
|
||||||
- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
|
- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
|
||||||
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
|
- [ ] 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
|
(`POST /admin/notifications/broadcast`), fan-out to all active users via
|
||||||
`NotifyMany`, admin UI for composing announcements
|
`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
|
`switchboard-sdk.js` — composition layer over existing globals. Surface
|
||||||
authors consume a single coherent API instead of hunting through 15 JS files.
|
authors consume a single coherent API instead of hunting through 15 JS files.
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ func (h *GitHandler) Log(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, entries)
|
if entries == nil {
|
||||||
|
entries = []models.GitLogEntry{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": entries})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Branches ─────────────────────────────────
|
// ── Branches ─────────────────────────────────
|
||||||
@@ -316,7 +319,7 @@ func (h *GitCredentialHandler) List(c *gin.Context) {
|
|||||||
for _, cred := range creds {
|
for _, cred := range creds {
|
||||||
summaries = append(summaries, cred.Summary())
|
summaries = append(summaries, cred.Summary())
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, summaries)
|
c.JSON(http.StatusOK, gin.H{"data": summaries})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a git credential.
|
// Delete removes a git credential.
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
// Profile / Settings
|
// Profile / Settings
|
||||||
settings := NewSettingsHandler(nil)
|
settings := NewSettingsHandler(nil)
|
||||||
protected.GET("/profile", settings.GetProfile)
|
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
|
||||||
personas := NewPersonaHandler(stores)
|
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/messages/:msgId/siblings", msgs.ListSiblings)
|
||||||
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
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)
|
// Tasks (v0.28.0)
|
||||||
taskH := NewTaskHandler(stores)
|
taskH := NewTaskHandler(stores)
|
||||||
protected.GET("/tasks", taskH.ListMine)
|
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"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -34,7 +35,8 @@ type profileResponse struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Avatar *string `json:"avatar,omitempty"`
|
Avatar *string `json:"avatar,omitempty"`
|
||||||
Settings map[string]interface{} `json:"settings"`
|
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.
|
// SettingsHandler manages user profile and preferences.
|
||||||
@@ -55,11 +57,14 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
|||||||
var p profileResponse
|
var p profileResponse
|
||||||
var settingsRaw string
|
var settingsRaw string
|
||||||
err := database.DB.QueryRow(database.Q(`
|
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
|
FROM users WHERE id = $1
|
||||||
`), userID).Scan(
|
`), userID).Scan(
|
||||||
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
&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 {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
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{})
|
settings := make(map[string]interface{})
|
||||||
_ = json.Unmarshal([]byte(settingsRaw), &settings)
|
_ = json.Unmarshal([]byte(settingsRaw), &settings)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, settings)
|
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update User Settings (JSONB merge) ──────
|
// ── 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) {
|
func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||||
@@ -146,7 +146,13 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
|||||||
return
|
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) {
|
func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||||
|
|||||||
Reference in New Issue
Block a user