Changeset 0.17.0 (#75)

This commit is contained in:
2026-02-27 16:25:39 +00:00
parent 8bb77710b9
commit c9141a6896
37 changed files with 2778 additions and 968 deletions

View File

@@ -120,6 +120,8 @@ func setupHarness(t *testing.T) *testHarness {
presets := NewPersonaHandler(stores)
protected.GET("/presets", presets.ListUserPersonas)
protected.POST("/presets", presets.CreateUserPersona)
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
// Notes
notes := NewNoteHandler()
@@ -149,6 +151,8 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
// Attachments (nil storage = upload returns 503, but metadata works)
attachH := NewAttachmentHandler(stores, nil, nil)
@@ -185,6 +189,8 @@ func setupHarness(t *testing.T) *testHarness {
admin.POST("/teams/:id/members", teams.AddMember)
admin.GET("/presets", presets.ListAdminPersonas)
admin.POST("/presets", presets.CreateAdminPersona)
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
// Admin groups (v0.16.0)
groupAdm := NewGroupHandler(stores)
@@ -2785,45 +2791,6 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
}
// Diagnostic: verify grant row exists
var grantCount int
database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount)
if grantCount == 0 {
t.Fatal("DIAG: resource_grant row missing after SET")
}
// Diagnostic: verify group membership
var memberCount int
database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount)
if memberCount == 0 {
t.Fatal("DIAG: group_members row missing")
}
// Diagnostic: verify persona exists
var personaExists bool
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists)
if !personaExists {
t.Fatal("DIAG: persona not found or not active")
}
// Diagnostic: run the subquery directly
var subqCount int
database.DB.QueryRow(`
SELECT COUNT(*) FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $1
AND gm.group_id = ANY(rg.granted_groups)
))
)
`, userID).Scan(&subqCount)
t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s",
grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID)
// ── User should NOW see team persona via group grant ──
w = h.request("GET", "/api/v1/presets", userToken, nil)
if w.Code != http.StatusOK {
@@ -2857,3 +2824,214 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
}
}
// ═══════════════════════════════════════════════════════════════
// v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes
// ═══════════════════════════════════════════════════════════════
// ── Persona-KB Binding CRUD ──
func TestIntegration_PersonaKB_Binding(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// 1. Create a global persona via admin API
w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
"name": "KB Persona",
"base_model_id": "test-model",
"system_prompt": "You are a test persona.",
})
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String())
}
var personaResp map[string]interface{}
decode(w, &personaResp)
personaID := personaResp["id"].(string)
// 2. Create a global KB
w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Test KB",
"scope": "global",
})
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
}
var kbResp map[string]interface{}
decode(w, &kbResp)
kbID := kbResp["id"].(string)
// 3. Bind KB to persona
w = h.request("PUT",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, map[string]interface{}{
"kb_ids": []string{kbID},
"auto_search": map[string]bool{kbID: true},
})
if w.Code != http.StatusOK {
t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// 4. Read back bindings
w = h.request("GET",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
data := listResp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 binding, got %d", len(data))
}
binding := data[0].(map[string]interface{})
if binding["kb_id"] != kbID {
t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID)
}
if binding["auto_search"] != true {
t.Fatal("auto_search should be true")
}
// 5. Unbind — send empty list
w = h.request("PUT",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, map[string]interface{}{
"kb_ids": []string{},
})
if w.Code != http.StatusOK {
t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String())
}
// 6. Verify empty
w = h.request("GET",
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
var emptyResp map[string]interface{}
decode(w, &emptyResp)
emptyData := emptyResp["data"].([]interface{})
if len(emptyData) != 0 {
t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData))
}
}
// ── KB Create Auth (was TODO — now enforced) ──
func TestIntegration_KB_CreateAuth(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
_ = adminToken
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("kbuser", "kb@test.com", "password123")
// Regular user should NOT be able to create global KBs
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "Sneaky Global KB",
"scope": "global",
})
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String())
}
// Regular user should NOT be able to create team KBs without team admin role
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "Sneaky Team KB",
"scope": "team",
"team_id": "00000000-0000-0000-0000-000000000001",
})
if w.Code != http.StatusForbidden {
t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String())
}
// Regular user CAN create personal KBs
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "My Personal KB",
"scope": "personal",
})
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String())
}
}
// ── KB Discoverable Flag ──
func TestIntegration_KB_Discoverable(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("discuser", "disc@test.com", "password123")
// 1. Admin creates a global KB (discoverable=true by default)
w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Visible KB",
"scope": "global",
})
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
}
var kbResp map[string]interface{}
decode(w, &kbResp)
kbID := kbResp["id"].(string)
// 2. User can see it in discoverable list
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String())
}
var discovResp map[string]interface{}
decode(w, &discovResp)
kbs, _ := discovResp["data"].([]interface{})
if kbs == nil {
t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String())
}
found := false
for _, kb := range kbs {
if kb.(map[string]interface{})["id"] == kbID {
found = true
break
}
}
if !found {
t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String())
}
// 3. Admin hides the KB
w = h.request("PUT",
fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID),
adminToken, map[string]interface{}{"discoverable": false})
if w.Code != http.StatusOK {
t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String())
}
// 4. User can no longer see it
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String())
}
var discovResp2 map[string]interface{}
decode(w, &discovResp2)
kbs2, _ := discovResp2["data"].([]interface{})
for _, kb := range kbs2 {
if kb.(map[string]interface{})["id"] == kbID {
t.Fatal("hidden KB should not appear in discoverable list")
}
}
}
// ── Platform Policy: kb_direct_access seeded ──
func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Verify the migration seeded the policy by reading it through admin settings
w := h.request("GET", "/api/v1/settings/public", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get public settings: want 200, got %d", w.Code)
}
// Policy existence is verified by the migration running without error
// (if kb_direct_access INSERT fails, the migration fails and no tests run)
_ = w
}