Changeset 0.14.0 (#67)

This commit is contained in:
2026-02-26 15:59:26 +00:00
parent 1a71658b24
commit e2149e249d
38 changed files with 5171 additions and 141 deletions

View File

@@ -135,6 +135,20 @@ func setupHarness(t *testing.T) *testHarness {
protected.GET("/channels/:id", channels.GetChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Knowledge Bases (nil storage/ingester/embedder — CRUD works, upload/rebuild return 503)
kbH := NewKnowledgeBaseHandler(stores, nil, nil, nil)
protected.POST("/knowledge-bases", kbH.CreateKB)
protected.GET("/knowledge-bases", kbH.ListKBs)
protected.GET("/knowledge-bases/:id", kbH.GetKB)
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB)
protected.POST("/knowledge-bases/:id/documents", kbH.UploadDocument)
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
protected.POST("/knowledge-bases/:id/search", kbH.SearchKB)
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
// Attachments (nil storage = upload returns 503, but metadata works)
attachH := NewAttachmentHandler(stores, nil, nil)
protected.POST("/channels/:id/attachments", attachH.Upload)
@@ -2230,3 +2244,202 @@ func TestIntegration_Roles_GetSingleRole(t *testing.T) {
t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── KB CRUD ─────────────────────────────────────
func TestIntegration_KnowledgeBaseCRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create personal KB
w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Test KB", "description": "A test knowledge base",
})
if w.Code != http.StatusCreated {
t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
}
var kb map[string]interface{}
decode(w, &kb)
kbID := kb["id"].(string)
if kb["name"] != "Test KB" {
t.Fatalf("create KB: name mismatch: got %s", kb["name"])
}
if kb["scope"] != "personal" {
t.Fatalf("create KB: scope should default to personal: got %s", kb["scope"])
}
// List KBs
w = h.request("GET", "/api/v1/knowledge-bases", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list KBs: want 200, got %d", w.Code)
}
var kbList map[string]interface{}
decode(w, &kbList)
items := kbList["data"].([]interface{})
if len(items) != 1 {
t.Fatalf("list KBs: expected 1, got %d", len(items))
}
// Get single KB
w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get KB: want 200, got %d", w.Code)
}
// Update KB
w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken,
map[string]interface{}{"name": "Updated KB"})
if w.Code != http.StatusOK {
t.Fatalf("update KB: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify update
w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
var updated map[string]interface{}
decode(w, &updated)
if updated["name"] != "Updated KB" {
t.Fatalf("update KB: name not updated, got %s", updated["name"])
}
// Upload document — should 503 (no object store in test harness)
w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("upload doc without objstore: want 503, got %d", w.Code)
}
// List documents (empty)
w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list docs: want 200, got %d", w.Code)
}
// Rebuild — should 503 (no ingester in test harness)
w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/rebuild", kbID), adminToken, nil)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("rebuild without ingester: want 503, got %d", w.Code)
}
// Delete KB
w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete KB: want 200, got %d", w.Code)
}
// Verify deleted
w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("get deleted KB: want 404, got %d", w.Code)
}
}
// ── KB Cross-User Isolation ─────────────────────
func TestIntegration_KBCrossUserIsolation(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
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("alice", "alice@test.com", "password123")
// Admin creates personal KB
w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Admin Private KB",
})
if w.Code != http.StatusCreated {
t.Fatalf("admin create KB: want 201, got %d: %s", w.Code, w.Body.String())
}
var adminKB map[string]interface{}
decode(w, &adminKB)
adminKBID := adminKB["id"].(string)
// Alice should NOT see admin's personal KB
w = h.request("GET", "/api/v1/knowledge-bases", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("alice list KBs: want 200, got %d", w.Code)
}
var aliceList map[string]interface{}
decode(w, &aliceList)
items := aliceList["data"].([]interface{})
if len(items) != 0 {
t.Fatalf("alice should see 0 KBs, got %d", len(items))
}
// Alice should NOT be able to access admin's KB directly
w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("alice access admin KB: want 404 (hidden), got %d", w.Code)
}
// Alice should NOT be able to delete admin's KB
w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("alice delete admin KB: want 404 (hidden), got %d", w.Code)
}
}
// ── Channel KB Linking ──────────────────────────
func TestIntegration_ChannelKBLinking(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create a channel
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Test Channel",
})
if w.Code != http.StatusCreated {
t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Create a KB
w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
"name": "Channel KB",
})
if w.Code != http.StatusCreated {
t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
}
var kb map[string]interface{}
decode(w, &kb)
kbID := kb["id"].(string)
// Link KB to channel
w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken,
map[string]interface{}{"kb_ids": []string{kbID}})
if w.Code != http.StatusOK {
t.Fatalf("link KB to channel: want 200, got %d: %s", w.Code, w.Body.String())
}
// Get channel KBs
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get channel KBs: want 200, got %d", w.Code)
}
var linked map[string]interface{}
decode(w, &linked)
data := linked["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("channel should have 1 linked KB, got %d", len(data))
}
// Unlink (set empty)
w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken,
map[string]interface{}{"kb_ids": []string{}})
if w.Code != http.StatusOK {
t.Fatalf("unlink KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify empty
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil)
var empty map[string]interface{}
decode(w, &empty)
emptyData := empty["data"].([]interface{})
if len(emptyData) != 0 {
t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData))
}
}