Changeset 0.28.6 (#192)
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -334,3 +339,98 @@ func (h *GitCredentialHandler) Delete(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// Generate creates a new ED25519 SSH keypair server-side.
|
||||
// The private key is vault-encrypted before storage. Only the public key
|
||||
// and fingerprint are returned — the private key never leaves the server.
|
||||
//
|
||||
// POST /git-credentials/generate
|
||||
func (h *GitCredentialHandler) Generate(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
PersonaID *string `json:"persona_id,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ED25519 keypair
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Marshal public key to authorized_keys format
|
||||
sshPub, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"})
|
||||
return
|
||||
}
|
||||
authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub))
|
||||
|
||||
// Fingerprint (SHA256)
|
||||
fingerprint := ssh.FingerprintSHA256(sshPub)
|
||||
|
||||
// Marshal private key to OpenSSH PEM format
|
||||
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"})
|
||||
return
|
||||
}
|
||||
privPEMBytes := pem.EncodeToMemory(privPEM)
|
||||
|
||||
// Encrypt private key via vault
|
||||
credData := map[string]string{"private_key": string(privPEMBytes)}
|
||||
plaintext, _ := json.Marshal(credData)
|
||||
|
||||
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
|
||||
return
|
||||
}
|
||||
|
||||
cred := &models.GitCredential{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
AuthType: "ssh_key",
|
||||
EncryptedData: ciphertext,
|
||||
Nonce: nonce,
|
||||
PublicKey: authorizedKey,
|
||||
Fingerprint: fingerprint,
|
||||
PersonaID: req.PersonaID,
|
||||
}
|
||||
|
||||
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, cred.Summary())
|
||||
}
|
||||
|
||||
// GetPublicKey returns the public key for a credential (for copy-to-clipboard).
|
||||
//
|
||||
// GET /git-credentials/:id/public-key
|
||||
func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
credID := c.Param("id")
|
||||
|
||||
cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID)
|
||||
if err != nil || cred.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if cred.PublicKey == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"public_key": cred.PublicKey,
|
||||
"fingerprint": cred.Fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
281
server/handlers/git_credentials_test.go
Normal file
281
server/handlers/git_credentials_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ── Git Credentials Test Harness ──────────
|
||||
|
||||
type gitCredHarness struct {
|
||||
*testHarness
|
||||
userToken string
|
||||
userID string
|
||||
user2Token string
|
||||
user2ID string
|
||||
}
|
||||
|
||||
func setupGitCredHarness(t *testing.T) *gitCredHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
// Create a test vault with a static env key (32 bytes for AES-256)
|
||||
envKey := []byte("test-encryption-key-32-bytes!!")
|
||||
for len(envKey) < 32 {
|
||||
envKey = append(envKey, '0')
|
||||
}
|
||||
envKey = envKey[:32]
|
||||
vault := crypto.NewKeyResolver(envKey, nil)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
gitCredH := NewGitCredentialHandler(stores, vault)
|
||||
protected.POST("/git-credentials", gitCredH.Create)
|
||||
protected.POST("/git-credentials/generate", gitCredH.Generate)
|
||||
protected.GET("/git-credentials", gitCredH.List)
|
||||
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
|
||||
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
|
||||
|
||||
// Seed users
|
||||
userID := database.SeedTestUser(t, "gituser", "gituser@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "gituser@test.com", "user")
|
||||
|
||||
user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
|
||||
user2Token := makeToken(user2ID, "gituser2@test.com", "user")
|
||||
|
||||
return &gitCredHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
user2Token: user2Token,
|
||||
user2ID: user2ID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /git-credentials — empty state ───
|
||||
|
||||
func TestGitCreds_List_Empty(t *testing.T) {
|
||||
h := setupGitCredHarness(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.NewDecoder(resp.Body).Decode(&body)
|
||||
data, ok := body["data"]
|
||||
if !ok {
|
||||
t.Fatal("response must have 'data' key")
|
||||
}
|
||||
arr := data.([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("expected empty array, got %d items", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /git-credentials/generate ───────
|
||||
|
||||
func TestGitCreds_Generate(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Test Key",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
|
||||
// Must have public_key and fingerprint
|
||||
pubKey, _ := cred["public_key"].(string)
|
||||
fp, _ := cred["fingerprint"].(string)
|
||||
if pubKey == "" {
|
||||
t.Error("public_key should be non-empty")
|
||||
}
|
||||
if fp == "" {
|
||||
t.Error("fingerprint should be non-empty")
|
||||
}
|
||||
if cred["auth_type"] != "ssh_key" {
|
||||
t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"])
|
||||
}
|
||||
if cred["name"] != "Test Key" {
|
||||
t.Errorf("name: got %v", cred["name"])
|
||||
}
|
||||
|
||||
// Must NOT have encrypted data
|
||||
if _, has := cred["encrypted_data"]; has {
|
||||
t.Error("encrypted_data must not appear in response")
|
||||
}
|
||||
if _, has := cred["nonce"]; has {
|
||||
t.Error("nonce must not appear in response")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /git-credentials/:id/public-key ──
|
||||
|
||||
func TestGitCreds_GetPublicKey(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
// Generate a key first
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "PK Test",
|
||||
})
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
id := cred["id"].(string)
|
||||
origPK := cred["public_key"].(string)
|
||||
|
||||
// Retrieve public key
|
||||
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var pkBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&pkBody)
|
||||
if pkBody["public_key"] != origPK {
|
||||
t.Errorf("public key mismatch")
|
||||
}
|
||||
if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" {
|
||||
t.Error("fingerprint should be present")
|
||||
}
|
||||
}
|
||||
|
||||
// ── List after generate ──────────────────
|
||||
|
||||
func TestGitCreds_ListAfterGenerate(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Key A",
|
||||
})
|
||||
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Key B",
|
||||
})
|
||||
|
||||
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(arr))
|
||||
}
|
||||
|
||||
// Verify summaries have public_key + fingerprint
|
||||
for _, raw := range arr {
|
||||
item := raw.(map[string]interface{})
|
||||
if item["public_key"] == nil || item["public_key"] == "" {
|
||||
t.Error("list item should have public_key")
|
||||
}
|
||||
if item["fingerprint"] == nil || item["fingerprint"] == "" {
|
||||
t.Error("list item should have fingerprint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────
|
||||
|
||||
func TestGitCreds_Delete(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Delete Me",
|
||||
})
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
id := cred["id"].(string)
|
||||
|
||||
// Delete
|
||||
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("delete: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("expected 0 keys after delete, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── User isolation ───────────────────────
|
||||
|
||||
func TestGitCreds_UserIsolation(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
// User 1 generates a key
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "User1 Key",
|
||||
})
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
id := cred["id"].(string)
|
||||
|
||||
// User 2 should see empty list
|
||||
resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("user2 should see 0 keys, got %d", len(arr))
|
||||
}
|
||||
|
||||
// User 2 should not be able to get user1's public key
|
||||
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user public-key: want 404, got %d", resp.Code)
|
||||
}
|
||||
|
||||
// User 2 should not be able to delete user1's key
|
||||
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user delete: want 404, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────
|
||||
|
||||
func TestGitCreds_Generate_MissingName(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing name: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
363
server/handlers/model_prefs_test.go
Normal file
363
server/handlers/model_prefs_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"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/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Model Prefs Test Harness ──────────────
|
||||
|
||||
type modelPrefsHarness struct {
|
||||
*testHarness
|
||||
userToken string
|
||||
userID string
|
||||
user2Token string
|
||||
user2ID string
|
||||
configID string // seeded provider_config for preference tests
|
||||
}
|
||||
|
||||
func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
modelPrefs := NewModelPrefsHandler(stores)
|
||||
protected.GET("/models/preferences", modelPrefs.GetPreferences)
|
||||
protected.PUT("/models/preferences", modelPrefs.SetPreference)
|
||||
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
|
||||
|
||||
// Seed two users
|
||||
userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "prefuser@test.com", "user")
|
||||
|
||||
user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
|
||||
user2Token := makeToken(user2ID, "prefuser2@test.com", "user")
|
||||
|
||||
// Seed a provider config for preference targets
|
||||
configID := uuid.New().String()
|
||||
if database.IsSQLite() {
|
||||
database.TestDB.Exec(`
|
||||
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
|
||||
VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
|
||||
configID)
|
||||
} else {
|
||||
database.TestDB.Exec(`
|
||||
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
|
||||
VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
|
||||
configID)
|
||||
}
|
||||
|
||||
return &modelPrefsHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
user2Token: user2Token,
|
||||
user2ID: user2ID,
|
||||
configID: configID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /models/preferences — empty state ─
|
||||
|
||||
func TestModelPrefs_Get_Empty(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/models/preferences", 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.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
data, ok := body["data"]
|
||||
if !ok {
|
||||
t.Fatal("response must have 'data' key")
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("data must be an array")
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("expected empty array, got %d items", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT + GET round-trip ──────────────────
|
||||
|
||||
func TestModelPrefs_Set_RoundTrip(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// Set preference
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "test-model-1",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
"sort_order": 5,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Read back
|
||||
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("GET: got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 1 {
|
||||
t.Fatalf("expected 1 preference, got %d", len(arr))
|
||||
}
|
||||
|
||||
pref := arr[0].(map[string]interface{})
|
||||
if pref["model_id"] != "test-model-1" {
|
||||
t.Errorf("model_id: got %v", pref["model_id"])
|
||||
}
|
||||
if pref["provider_config_id"] != h.configID {
|
||||
t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID)
|
||||
}
|
||||
if pref["hidden"] != true {
|
||||
t.Errorf("hidden: got %v, want true", pref["hidden"])
|
||||
}
|
||||
if pref["sort_order"] != float64(5) {
|
||||
t.Errorf("sort_order: got %v, want 5", pref["sort_order"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upsert — no duplicate rows ────────────
|
||||
|
||||
func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
for _, hidden := range []bool{true, false, true} {
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "test-model-1",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": hidden,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 1 {
|
||||
t.Fatalf("upsert produced %d rows, want 1", len(arr))
|
||||
}
|
||||
|
||||
pref := arr[0].(map[string]interface{})
|
||||
if pref["hidden"] != true {
|
||||
t.Errorf("final hidden state should be true, got %v", pref["hidden"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation: missing fields ────────────
|
||||
|
||||
func TestModelPrefs_Set_MissingModelID(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing model_id: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "test-model-1",
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bulk set hidden ───────────────────────
|
||||
|
||||
func TestModelPrefs_BulkSetHidden(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{
|
||||
"entries": []map[string]string{
|
||||
{"model_id": "bulk-model-a", "provider_config_id": h.configID},
|
||||
{"model_id": "bulk-model-b", "provider_config_id": h.configID},
|
||||
},
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var bulkResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&bulkResp)
|
||||
if bulkResp["count"] != float64(2) {
|
||||
t.Errorf("bulk count: got %v, want 2", bulkResp["count"])
|
||||
}
|
||||
|
||||
// Verify both are hidden
|
||||
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
|
||||
hiddenCount := 0
|
||||
for _, raw := range arr {
|
||||
p := raw.(map[string]interface{})
|
||||
if p["hidden"] == true {
|
||||
hiddenCount++
|
||||
}
|
||||
}
|
||||
if hiddenCount < 2 {
|
||||
t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ── User isolation ────────────────────────
|
||||
|
||||
func TestModelPrefs_UserIsolation(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// User 1 sets a preference
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "isolated-model",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("user1 PUT: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// User 2 should see empty preferences
|
||||
resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("user2 GET: got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("user2 should have 0 prefs, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape validation ──────────────────────
|
||||
|
||||
func TestModelPrefs_ResponseShape(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// Create a preference so we have something to inspect
|
||||
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "shape-model",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": false,
|
||||
"sort_order": 0,
|
||||
})
|
||||
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) == 0 {
|
||||
t.Fatal("expected at least 1 preference for shape check")
|
||||
}
|
||||
|
||||
pref := arr[0].(map[string]interface{})
|
||||
requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"}
|
||||
for _, f := range requiredFields {
|
||||
if _, ok := pref[f]; !ok {
|
||||
t.Errorf("missing required field %q in preference response", f)
|
||||
}
|
||||
}
|
||||
|
||||
// id, user_id, model_id, provider_config_id should be non-empty strings
|
||||
for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} {
|
||||
v, _ := pref[f].(string)
|
||||
if v == "" {
|
||||
t.Errorf("field %q should be a non-empty string, got %v", f, pref[f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Different provider_config_id = different entry ──
|
||||
|
||||
func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// Seed a second provider config
|
||||
config2ID := uuid.New().String()
|
||||
database.TestDB.Exec(dialectSQL(
|
||||
"INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"),
|
||||
config2ID)
|
||||
|
||||
// Set preference for same model under two providers
|
||||
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "dual-provider-model",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
})
|
||||
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "dual-provider-model",
|
||||
"provider_config_id": config2ID,
|
||||
"hidden": false,
|
||||
})
|
||||
|
||||
// Should have two distinct entries
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
|
||||
matches := 0
|
||||
for _, raw := range arr {
|
||||
p := raw.(map[string]interface{})
|
||||
if p["model_id"] == "dual-provider-model" {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches != 2 {
|
||||
t.Fatalf("same model, different providers: expected 2 entries, got %d", matches)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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/notifications"
|
||||
"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"
|
||||
@@ -61,6 +62,16 @@ func setupNotifHarness(t *testing.T) *notifHarness {
|
||||
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
||||
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
||||
|
||||
// Admin broadcast route (v0.28.6)
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.POST("/notifications/broadcast", notifH.Broadcast)
|
||||
|
||||
// Set up notification service singleton for Broadcast handler
|
||||
notifSvc := notifications.NewService(stores.Notifications, nil)
|
||||
notifications.SetDefault(notifSvc)
|
||||
|
||||
// Seed admin
|
||||
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
|
||||
@@ -548,6 +559,110 @@ func TestNotifications_Unauthenticated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Broadcast (v0.28.6) ────────────
|
||||
|
||||
func TestBroadcast_Success(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "Maintenance Tonight",
|
||||
"message": "Servers will be down from 2-4 AM.",
|
||||
"level": "warning",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("broadcast: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body["message"] != "broadcast sent" {
|
||||
t.Errorf("expected 'broadcast sent', got %v", body["message"])
|
||||
}
|
||||
count := body["count"].(float64)
|
||||
if count < 2 {
|
||||
t.Errorf("expected at least 2 recipients (admin+user), got %v", count)
|
||||
}
|
||||
|
||||
// Verify notification created for regular user
|
||||
resp = h.request("GET", "/api/v1/notifications", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("list notifications: got %d", resp.Code)
|
||||
}
|
||||
var listBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&listBody)
|
||||
items := listBody["data"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
t.Fatal("user should have received the broadcast notification")
|
||||
}
|
||||
n := items[0].(map[string]interface{})
|
||||
if n["type"] != "system.announcement" {
|
||||
t.Errorf("notification type: got %v, want system.announcement", n["type"])
|
||||
}
|
||||
if n["title"] != "Maintenance Tonight" {
|
||||
t.Errorf("notification title: got %v", n["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_NonAdmin403(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.userToken, map[string]interface{}{
|
||||
"title": "Unauthorized",
|
||||
"message": "Should fail.",
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin broadcast: want 403, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingTitle(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"message": "No title.",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing title: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "No message.",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing message: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidLevel(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "Bad level",
|
||||
"message": "Test",
|
||||
"level": "extreme",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid level: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_DefaultLevel(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Omit level — should default to info and succeed
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "Info broadcast",
|
||||
"message": "Default level test.",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("default level: want 200, got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────
|
||||
|
||||
func keys(m map[string]json.RawMessage) []string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -279,3 +281,74 @@ func (h *NotificationHandler) DeletePreference(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Admin Broadcast (v0.28.6) ───────────────
|
||||
|
||||
// POST /api/v1/admin/notifications/broadcast
|
||||
// Admin-only: sends a system.announcement notification to all active users.
|
||||
func (h *NotificationHandler) Broadcast(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
Level string `json:"level"` // info | warning | critical (default: info)
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
level := req.Level
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
if level != "info" && level != "warning" && level != "critical" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "level must be info, warning, or critical"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get all active user IDs
|
||||
userIDs, err := h.stores.Users.ListActiveUserIDs(c.Request.Context())
|
||||
if err != nil {
|
||||
log.Printf("[broadcast] failed to list active users: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "no active users", "count": 0})
|
||||
return
|
||||
}
|
||||
|
||||
// Fan out via notification service
|
||||
svc := notifications.Default()
|
||||
if svc == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "notification service not available"})
|
||||
return
|
||||
}
|
||||
|
||||
template := models.Notification{
|
||||
Type: models.NotifTypeAnnouncement,
|
||||
Title: req.Title,
|
||||
Body: req.Message,
|
||||
}
|
||||
svc.NotifyMany(c.Request.Context(), userIDs, template)
|
||||
|
||||
// Also emit a real-time WS broadcast for immediate visibility
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"title": req.Title,
|
||||
"message": req.Message,
|
||||
"level": level,
|
||||
})
|
||||
for _, uid := range userIDs {
|
||||
h.hub.SendToUser(uid, events.Event{
|
||||
Label: "system.broadcast",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[broadcast] admin sent announcement to %d users: %s", len(userIDs), req.Title)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "broadcast sent", "count": len(userIDs)})
|
||||
}
|
||||
|
||||
@@ -141,6 +141,22 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.28.6: System tasks — admin-only, requires valid system_function
|
||||
if t.TaskType == "system" {
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "system tasks are admin-only"})
|
||||
return
|
||||
}
|
||||
if t.SystemFunction == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system_function is required for system tasks"})
|
||||
return
|
||||
}
|
||||
if err := taskutil.ValidateSystemFunc(t.SystemFunction); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if t.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
@@ -540,3 +556,9 @@ func (h *TriggerHandler) Handle(c *gin.Context) {
|
||||
"task_id": task.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSystemFunctions returns the available system function names and descriptions.
|
||||
// GET /admin/system-functions
|
||||
func (h *TaskHandler) ListSystemFunctions(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": taskutil.ListSystemFuncs()})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user