mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-05 12:23:05 +00:00
299 lines
9.4 KiB
Go
299 lines
9.4 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
httppkg "github.com/nextlevelbuilder/goclaw/internal/http"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
)
|
|
|
|
// TestIssue1034_Bug1_VerifyEmptyBody — after phase 03, empty body triggers
|
|
// ping mode. Without a real providerReg the handler returns 200 with
|
|
// {valid:false, error:"no provider registry available"} or
|
|
// {valid:false, error:"provider not registered: ..."} — the key contract
|
|
// is that empty body NO LONGER returns 400.
|
|
func TestIssue1034_Bug1_VerifyEmptyBody(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
|
|
pstore := pg.NewPGProviderStore(db, "")
|
|
suffix := uuid.NewString()[:8]
|
|
p := &store.LLMProviderData{
|
|
Name: "openai-compat-bug1-" + suffix,
|
|
DisplayName: "Bug1 test",
|
|
ProviderType: "openai-compat",
|
|
APIBase: "http://127.0.0.1:0",
|
|
APIKey: "x",
|
|
Enabled: true,
|
|
}
|
|
ctx := tenantCtx(tenantID)
|
|
if err := pstore.CreateProvider(ctx, p); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = pstore.DeleteProvider(tenantCtx(tenantID), p.ID)
|
|
})
|
|
|
|
h := httppkg.NewProvidersHandler(pstore, nil, nil, "")
|
|
|
|
req := httptest.NewRequest("POST", "/v1/providers/"+p.ID.String()+"/verify", nil).WithContext(ctx)
|
|
req.SetPathValue("id", p.ID.String())
|
|
w := httptest.NewRecorder()
|
|
h.HandleVerifyProviderForTest(w, req)
|
|
|
|
if w.Code != 200 {
|
|
t.Fatalf("ping-mode (empty body) must return 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestIssue1034_Bug1_VerifyEmptyBodyACPProvider — ping mode on an ACP provider
|
|
// returns {valid:true} without any binary check (no providerReg lookup).
|
|
func TestIssue1034_Bug1_VerifyEmptyBodyACPProvider(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
|
|
pstore := pg.NewPGProviderStore(db, "")
|
|
suffix := uuid.NewString()[:8]
|
|
p := &store.LLMProviderData{
|
|
Name: "acp-bug1-" + suffix,
|
|
ProviderType: store.ProviderACP,
|
|
APIBase: "claude",
|
|
Enabled: true,
|
|
}
|
|
ctx := tenantCtx(tenantID)
|
|
if err := pstore.CreateProvider(ctx, p); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = pstore.DeleteProvider(tenantCtx(tenantID), p.ID)
|
|
})
|
|
|
|
h := httppkg.NewProvidersHandler(pstore, nil, nil, "")
|
|
|
|
req := httptest.NewRequest("POST", "/v1/providers/"+p.ID.String()+"/verify", nil).WithContext(ctx)
|
|
req.SetPathValue("id", p.ID.String())
|
|
w := httptest.NewRecorder()
|
|
h.HandleVerifyProviderForTest(w, req)
|
|
|
|
if w.Code != 200 {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if v, _ := body["valid"].(bool); !v {
|
|
t.Fatalf("ACP ping must return valid=true, got body=%s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestIssue1034_Bug1_VerifyMalformedBody — locks in that malformed JSON
|
|
// (truncated/invalid) keeps returning 400 even after the ping-mode change.
|
|
func TestIssue1034_Bug1_VerifyMalformedBody(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
|
|
pstore := pg.NewPGProviderStore(db, "")
|
|
suffix := uuid.NewString()[:8]
|
|
p := &store.LLMProviderData{
|
|
Name: "openai-compat-bug1m-" + suffix,
|
|
ProviderType: "openai-compat",
|
|
APIBase: "http://127.0.0.1:0",
|
|
APIKey: "x",
|
|
Enabled: true,
|
|
}
|
|
ctx := tenantCtx(tenantID)
|
|
if err := pstore.CreateProvider(ctx, p); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = pstore.DeleteProvider(tenantCtx(tenantID), p.ID)
|
|
})
|
|
|
|
h := httppkg.NewProvidersHandler(pstore, nil, nil, "")
|
|
|
|
body := bytes.NewBufferString(`{"model":`) // truncated
|
|
req := httptest.NewRequest("POST", "/v1/providers/"+p.ID.String()+"/verify", body).WithContext(ctx)
|
|
req.SetPathValue("id", p.ID.String())
|
|
w := httptest.NewRecorder()
|
|
h.HandleVerifyProviderForTest(w, req)
|
|
|
|
if w.Code != 400 {
|
|
t.Fatalf("malformed body must stay 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestIssue1034_Bug2_DeleteProviderWithHeartbeat — after phase 02 the FK is
|
|
// ON DELETE SET NULL and DeleteProvider runs in a tx that also disables the
|
|
// stale heartbeat. Asserts: delete succeeds, provider_id IS NULL, enabled = false.
|
|
func TestIssue1034_Bug2_DeleteProviderWithHeartbeat(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
|
|
pstore := pg.NewPGProviderStore(db, "")
|
|
suffix := uuid.NewString()[:8]
|
|
p := &store.LLMProviderData{
|
|
Name: "openai-compat-bug2-" + suffix,
|
|
ProviderType: "openai-compat",
|
|
APIBase: "http://127.0.0.1:0",
|
|
APIKey: "x",
|
|
Enabled: true,
|
|
}
|
|
ctx := tenantCtx(tenantID)
|
|
if err := pstore.CreateProvider(ctx, p); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
|
|
hbID := uuid.New()
|
|
if _, err := db.Exec(
|
|
`INSERT INTO agent_heartbeats (id, agent_id, enabled, provider_id, model)
|
|
VALUES ($1, $2, true, $3, 'gpt-4')`,
|
|
hbID, agentID, p.ID,
|
|
); err != nil {
|
|
t.Fatalf("seed heartbeat: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.Exec(`DELETE FROM agent_heartbeats WHERE agent_id = $1`, agentID)
|
|
_, _ = db.Exec(`DELETE FROM llm_providers WHERE id = $1`, p.ID)
|
|
})
|
|
|
|
if err := pstore.DeleteProvider(ctx, p.ID); err != nil {
|
|
t.Fatalf("delete provider should succeed after FK SET NULL + tx, got err=%v", err)
|
|
}
|
|
|
|
var providerID *string
|
|
var enabled bool
|
|
if err := db.QueryRow(
|
|
`SELECT provider_id::text, enabled FROM agent_heartbeats WHERE id = $1`,
|
|
hbID,
|
|
).Scan(&providerID, &enabled); err != nil {
|
|
t.Fatalf("scan heartbeat: %v", err)
|
|
}
|
|
if providerID != nil {
|
|
t.Fatalf("expected provider_id NULL after delete, got %q", *providerID)
|
|
}
|
|
if enabled {
|
|
t.Fatalf("expected heartbeat disabled after delete, got enabled=true")
|
|
}
|
|
}
|
|
|
|
// TestIssue1034_Bug2_DeleteProviderCrossTenantIsolation — guarantees that
|
|
// even if tenant A possesses tenant B's provider UUID, calling DeleteProvider
|
|
// (without master scope) does NOT disable tenant B's heartbeats. Regression
|
|
// guard for the cross-tenant UPDATE concern raised in code review.
|
|
func TestIssue1034_Bug2_DeleteProviderCrossTenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantA, _ := seedTenantAgent(t, db)
|
|
tenantB, agentB := seedTenantAgent(t, db)
|
|
|
|
pstore := pg.NewPGProviderStore(db, "")
|
|
suffix := uuid.NewString()[:8]
|
|
|
|
// Provider lives in tenant B.
|
|
pB := &store.LLMProviderData{
|
|
Name: "openai-compat-tenantB-" + suffix,
|
|
ProviderType: "openai-compat",
|
|
APIBase: "http://127.0.0.1:0",
|
|
APIKey: "x",
|
|
Enabled: true,
|
|
}
|
|
if err := pstore.CreateProvider(tenantCtx(tenantB), pB); err != nil {
|
|
t.Fatalf("create provider B: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = pstore.DeleteProvider(tenantCtx(tenantB), pB.ID)
|
|
})
|
|
|
|
// Heartbeat in tenant B references provider B and is enabled.
|
|
hbID := uuid.New()
|
|
if _, err := db.Exec(
|
|
`INSERT INTO agent_heartbeats (id, agent_id, enabled, provider_id, model)
|
|
VALUES ($1, $2, true, $3, 'gpt-4')`,
|
|
hbID, agentB, pB.ID,
|
|
); err != nil {
|
|
t.Fatalf("seed heartbeat B: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.Exec(`DELETE FROM agent_heartbeats WHERE agent_id = $1`, agentB)
|
|
})
|
|
|
|
// Tenant A attempts delete with B's provider UUID. Must NOT touch B's heartbeat.
|
|
_ = pstore.DeleteProvider(tenantCtx(tenantA), pB.ID)
|
|
|
|
var enabled bool
|
|
var providerID *string
|
|
if err := db.QueryRow(
|
|
`SELECT enabled, provider_id::text FROM agent_heartbeats WHERE id = $1`, hbID,
|
|
).Scan(&enabled, &providerID); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if !enabled {
|
|
t.Fatalf("tenant A's DeleteProvider must NOT disable tenant B's heartbeat")
|
|
}
|
|
if providerID == nil || *providerID != pB.ID.String() {
|
|
t.Fatalf("tenant B's heartbeat provider_id must be untouched, got %v", providerID)
|
|
}
|
|
}
|
|
|
|
// TestIssue1034_Bug3_DoctorDisplayNameEmpty — after phase 05 doctor uses
|
|
// COALESCE(NULLIF(display_name, ''), name) so empty-string display_name
|
|
// falls back to the canonical name.
|
|
func TestIssue1034_Bug3_DoctorDisplayNameEmpty(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
|
|
pstore := pg.NewPGProviderStore(db, "")
|
|
suffix := uuid.NewString()[:8]
|
|
canonicalName := "openai-compat-ollama-" + suffix
|
|
p := &store.LLMProviderData{
|
|
Name: canonicalName,
|
|
DisplayName: "", // empty (not NULL) — the bug condition
|
|
ProviderType: "openai-compat",
|
|
APIBase: "http://127.0.0.1:0",
|
|
APIKey: "x",
|
|
Enabled: true,
|
|
}
|
|
if err := pstore.CreateProvider(tenantCtx(tenantID), p); err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = pstore.DeleteProvider(tenantCtx(tenantID), p.ID)
|
|
})
|
|
|
|
// Mirror the fixed query in cmd/doctor.go.
|
|
var name, displayName string
|
|
row := db.QueryRow(
|
|
`SELECT name, COALESCE(NULLIF(display_name, ''), name) FROM llm_providers WHERE id = $1`,
|
|
p.ID,
|
|
)
|
|
if err := row.Scan(&name, &displayName); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
if displayName != canonicalName {
|
|
t.Fatalf("NULLIF-fixed query must fall back to name=%q, got %q", canonicalName, displayName)
|
|
}
|
|
|
|
// Non-empty display_name still wins.
|
|
if _, err := db.Exec(
|
|
`UPDATE llm_providers SET display_name = 'My Ollama' WHERE id = $1`, p.ID,
|
|
); err != nil {
|
|
t.Fatalf("update display_name: %v", err)
|
|
}
|
|
if err := db.QueryRow(
|
|
`SELECT COALESCE(NULLIF(display_name, ''), name) FROM llm_providers WHERE id = $1`, p.ID,
|
|
).Scan(&displayName); err != nil {
|
|
t.Fatalf("scan2: %v", err)
|
|
}
|
|
if displayName != "My Ollama" {
|
|
t.Fatalf("non-empty display_name must win, got %q", displayName)
|
|
}
|
|
}
|