ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
197 lines
6.3 KiB
Go
197 lines
6.3 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// TestPoolProfilesOrder pins the failover chain query: keyless profiles can't
|
|
// serve a request and excluded ones aren't fallback targets, so neither belongs
|
|
// in the chain; the rest come back by priority, highest first.
|
|
// Deliberately does NOT touch is_default — flipping the active profile would be a
|
|
// side effect on the shared dev database.
|
|
func TestPoolProfilesOrder(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
mk := func(name string, priority int, exclude bool, key string) int64 {
|
|
id, err := d.SaveProfile(&LLMProfile{
|
|
Name: name, Format: "openai", Model: "m", APIKey: key,
|
|
Priority: priority, PoolExclude: exclude,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
|
return id
|
|
}
|
|
lo := mk("t-pool-lo", 1, false, "k1")
|
|
hi := mk("t-pool-hi", 9, false, "k2")
|
|
mk("t-pool-excluded", 99, true, "k3") // excluded despite the top priority
|
|
mk("t-pool-nokey", 50, false, "") // no key → cannot serve anything
|
|
|
|
chain, err := d.PoolProfiles()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got []int64
|
|
for _, p := range chain {
|
|
switch p.Name {
|
|
case "t-pool-lo", "t-pool-hi":
|
|
got = append(got, p.ID)
|
|
case "t-pool-excluded":
|
|
t.Fatal("pool_exclude profile entered the failover chain")
|
|
case "t-pool-nokey":
|
|
t.Fatal("keyless profile entered the failover chain")
|
|
}
|
|
}
|
|
if len(got) != 2 || got[0] != hi || got[1] != lo {
|
|
t.Fatalf("chain order = %v, want [hi=%d lo=%d]", got, hi, lo)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProfileContextHonorsCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
var d DB
|
|
if err := d.DeleteProfileContext(ctx, 1); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("DeleteProfileContext error=%v, want context cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestConfigStores(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
defer d.Close()
|
|
|
|
// LLM profile: save, active, key never serialized
|
|
pid, err := d.SaveProfile(&LLMProfile{Name: "t-default", Format: "openai", Model: "gpt-x", APIKey: "secret123"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, pid)
|
|
if err := d.SetActiveProfile(pid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.DeleteProfile(pid); !errors.Is(err, ErrActiveLLMProfileDelete) {
|
|
t.Fatalf("deleting active profile error=%v, want %v", err, ErrActiveLLMProfileDelete)
|
|
}
|
|
act, err := d.ActiveProfile()
|
|
if err != nil || act == nil || act.APIKey != "secret123" {
|
|
t.Fatalf("active profile/key: %+v err=%v", act, err)
|
|
}
|
|
// list must hide the key, expose hint
|
|
list, _ := d.ListProfiles()
|
|
for _, p := range list {
|
|
if p.ID == pid {
|
|
b, _ := json.Marshal(p)
|
|
if string(b) == "" || contains(string(b), "secret123") {
|
|
t.Fatalf("api key leaked in list json: %s", b)
|
|
}
|
|
if p.APIKeyHint != "…t123" {
|
|
t.Fatalf("hint want …t123, got %q", p.APIKeyHint)
|
|
}
|
|
}
|
|
}
|
|
|
|
// agents seeded; prompt versioning
|
|
ag, err := d.GetAgentByKey("planner")
|
|
if err != nil || ag == nil {
|
|
t.Fatalf("planner agent: %v", err)
|
|
}
|
|
v1, err := d.SavePrompt(ag.ID, "你是规划者 {{.Goal}}", "init", "test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v2, _ := d.SavePrompt(ag.ID, "你是规划者 v2 {{.Goal}} {{.Scope}}", "edit", "test")
|
|
if v2 != v1+1 {
|
|
t.Fatalf("version should increment: %d -> %d", v1, v2)
|
|
}
|
|
cur, _ := d.CurrentPrompt(ag.ID)
|
|
if cur != "你是规划者 v2 {{.Goal}} {{.Scope}}" {
|
|
t.Fatalf("current prompt wrong: %q", cur)
|
|
}
|
|
vers, _ := d.ListPromptVersions(ag.ID)
|
|
if len(vers) < 2 {
|
|
t.Fatalf("want >=2 versions, got %d", len(vers))
|
|
}
|
|
pv, _ := d.PromptVars(ag.ID)
|
|
// planner must have at least the seeded catalog vars (Goal, AssetSummary)
|
|
if len(pv) < 2 {
|
|
t.Fatalf("planner catalog want >=2 vars, got %d", len(pv))
|
|
}
|
|
d.Exec(`DELETE FROM agent_prompts WHERE agent_id=$1`, ag.ID)
|
|
d.Exec(`UPDATE agents SET current_prompt_id=NULL WHERE id=$1`, ag.ID)
|
|
|
|
// mcp + skill + visibility (bidirectional via one join)
|
|
// Clean up any leftover MCP from prior runs to keep this test idempotent.
|
|
d.Exec(`DELETE FROM mcp_servers WHERE name = 't-gh'`)
|
|
mid, err := d.SaveMCP(&MCPServer{Name: "t-gh", Transport: "stdio", Command: "npx", Args: json.RawMessage(`["server-github"]`), Env: json.RawMessage(`{"GITHUB_TOKEN":"x"}`), Enabled: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// agent-side write. MCP is id-keyed (generic visibility join); skills are now
|
|
// filesystem-based, so their visibility is keyed by skill (directory) name in a
|
|
// dedicated table. Clear any pre-existing MCP visibility rows so the assertions
|
|
// below isolate on exactly what this test sets.
|
|
d.Exec(`DELETE FROM agent_visibility WHERE agent_id = $1 AND resource_kind = 'mcp'`, ag.ID)
|
|
if err := d.ToggleVisibility(ag.ID, "mcp", mid, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.SetAgentSkillVisibility(ag.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.ToggleSkillVisibility(ag.ID, "t-skill", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// agent-side read
|
|
vm, _ := d.AgentVisible(ag.ID, "mcp")
|
|
if len(vm) != 1 || vm[0] != mid {
|
|
t.Fatalf("agent visible mcp: %+v", vm)
|
|
}
|
|
// resource-side read (same join row) → bidirectional
|
|
ra, _ := d.ResourceAgents("mcp", mid)
|
|
if len(ra) != 1 || ra[0] != ag.ID {
|
|
t.Fatalf("resource agents: %+v", ra)
|
|
}
|
|
// toggle off
|
|
d.ToggleVisibility(ag.ID, "mcp", mid, false)
|
|
vm2, _ := d.AgentVisible(ag.ID, "mcp")
|
|
if len(vm2) != 0 {
|
|
t.Fatalf("after toggle off: %+v", vm2)
|
|
}
|
|
// skill visibility is name-keyed: verify the read, then deleting the skill's
|
|
// visibility rows (called when a skill is removed) clears it.
|
|
names, _ := d.AgentSkillNames(ag.ID)
|
|
if len(names) != 1 || names[0] != "t-skill" {
|
|
t.Fatalf("agent visible skills: %+v", names)
|
|
}
|
|
if err := d.DeleteSkillVisibility("t-skill"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names2, _ := d.AgentSkillNames(ag.ID)
|
|
if len(names2) != 0 {
|
|
t.Fatalf("skill visibility should be cleared on delete: %+v", names2)
|
|
}
|
|
d.DeleteMCP(mid)
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (indexOf(s, sub) >= 0)
|
|
}
|
|
func indexOf(s, sub string) int {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|