mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
Deleting a team failed with SQLSTATE 23514 whenever it owned a team-scoped vault document. The vault_documents.team_id FK is ON DELETE SET NULL; when team_id became NULL the vault_docs_team_null_scope_fix() trigger (migration 000043) unconditionally set scope='personal'. Team docs have agent_id IS NULL, so the resulting (personal, agent_id NULL) row violates the vault_documents_scope_consistency CHECK and aborts the whole delete. PostgreSQL: migration 000089 replaces the trigger function to pick a valid target scope by ownership, and only rewrite genuinely team-scoped rows so scope='custom' docs that merely carry a team_id are preserved: agent_id IS NOT NULL -> 'personal' (defensive: legacy dirty rows) agent_id IS NULL -> 'shared' (normal team docs) SQLite: the same class of bug exists (FK SET NULL leaves scope='team' with a NULL team_id and aborts on the CHECK), but SQLite fires no trigger during the FK action, so a DB-level fix is impossible. SQLiteTeamStore.DeleteTeam now converts team-scoped docs in the same transaction before removing the team, scoped by tenant_id in the tenant path to keep tenant isolation. Adds regression tests on both engines and bumps RequiredSchemaVersion to 89. SQLite needs no schema migration since the fix is in Go code.
143 lines
5.2 KiB
Go
143 lines
5.2 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestStoreVault_ScopeCheck verifies the vault_documents_scope_consistency CHECK constraint
|
|
// (added by migration 000055 NOT VALID) rejects invalid scope/ownership combinations on
|
|
// new inserts while accepting valid ones.
|
|
//
|
|
// NOT VALID means existing rows are not scanned but new writes are still gated — the CHECK
|
|
// fires on INSERT/UPDATE regardless of VALID status.
|
|
func TestStoreVault_ScopeCheck(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
// unique path suffix per case to avoid conflicts with other test runs.
|
|
suffix := uuid.New().String()[:8]
|
|
|
|
cases := []struct {
|
|
name string
|
|
query string
|
|
args []any
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "reject_personal_with_null_agent_id",
|
|
// scope='personal' requires agent_id NOT NULL
|
|
query: `INSERT INTO vault_documents
|
|
(id, tenant_id, scope, path, title, doc_type, content_hash)
|
|
VALUES ($1, $2, 'personal', $3, 'bad', 'note', 'h1')`,
|
|
args: []any{uuid.New().String(), tid, "scope-check/bad-personal-" + suffix + ".md"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "reject_team_with_null_team_id",
|
|
// scope='team' requires team_id NOT NULL
|
|
query: `INSERT INTO vault_documents
|
|
(id, tenant_id, scope, path, title, doc_type, content_hash)
|
|
VALUES ($1, $2, 'team', $3, 'bad', 'note', 'h2')`,
|
|
args: []any{uuid.New().String(), tid, "scope-check/bad-team-" + suffix + ".md"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "reject_shared_with_non_null_agent_id",
|
|
// scope='shared' requires agent_id IS NULL
|
|
query: `INSERT INTO vault_documents
|
|
(id, tenant_id, agent_id, scope, path, title, doc_type, content_hash)
|
|
VALUES ($1, $2, $3, 'shared', $4, 'bad', 'note', 'h3')`,
|
|
args: []any{uuid.New().String(), tid, aid, "scope-check/bad-shared-" + suffix + ".md"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "accept_custom_scope_with_null_agent_id",
|
|
// scope='custom' has no constraint — should succeed
|
|
query: `INSERT INTO vault_documents
|
|
(id, tenant_id, scope, path, title, doc_type, content_hash)
|
|
VALUES ($1, $2, 'custom', $3, 'ok', 'note', 'h4')`,
|
|
args: []any{uuid.New().String(), tid, "scope-check/ok-custom-" + suffix + ".md"},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := db.Exec(tc.query, tc.args...)
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("expected constraint violation error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("expected no error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_TeamDeletePreservesDocs is a regression test for issue #1077.
|
|
//
|
|
// Deleting a team that owns vault documents must succeed. Team-scoped docs
|
|
// (scope='team', agent_id IS NULL) are preserved as scope='shared' via the
|
|
// vault_docs_team_null_scope_fix() trigger. Before the fix the trigger forced
|
|
// scope='personal' unconditionally, which — combined with agent_id IS NULL —
|
|
// violated vault_documents_scope_consistency and aborted the delete (SQLSTATE 23514).
|
|
func TestStoreVault_TeamDeletePreservesDocs(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
teamID, _ := seedTeam(t, db, tenantID, agentID)
|
|
|
|
suffix := uuid.New().String()[:8]
|
|
teamDocID := uuid.New().String()
|
|
customDocID := uuid.New().String()
|
|
t.Cleanup(func() {
|
|
db.Exec(`DELETE FROM vault_documents WHERE id IN ($1, $2)`, teamDocID, customDocID)
|
|
})
|
|
|
|
// Team-scoped doc (agent_id NULL) — the exact shape that triggers the bug.
|
|
if _, err := db.Exec(`INSERT INTO vault_documents
|
|
(id, tenant_id, team_id, scope, path, title, doc_type, content_hash)
|
|
VALUES ($1, $2, $3, 'team', $4, 'team-doc', 'note', 'h1')`,
|
|
teamDocID, tenantID, teamID, "team-del/"+suffix+"-team.md"); err != nil {
|
|
t.Fatalf("insert team doc: %v", err)
|
|
}
|
|
// Custom-scope doc carrying team_id + agent_id — must NOT be clobbered.
|
|
if _, err := db.Exec(`INSERT INTO vault_documents
|
|
(id, tenant_id, agent_id, team_id, scope, path, title, doc_type, content_hash)
|
|
VALUES ($1, $2, $3, $4, 'custom', $5, 'custom-doc', 'note', 'h2')`,
|
|
customDocID, tenantID, agentID, teamID, "team-del/"+suffix+"-custom.md"); err != nil {
|
|
t.Fatalf("insert custom doc: %v", err)
|
|
}
|
|
|
|
// Delete the team: exercises FK ON DELETE SET NULL → scope-fix trigger → CHECK.
|
|
if _, err := db.Exec(`DELETE FROM agent_teams WHERE id = $1`, teamID); err != nil {
|
|
t.Fatalf("delete team failed (issue #1077 regression): %v", err)
|
|
}
|
|
|
|
// Team doc preserved as 'shared', team_id cleared.
|
|
assertPGVaultScope(t, db, teamDocID, "shared")
|
|
// Custom doc keeps its scope, team_id cleared by FK SET NULL.
|
|
assertPGVaultScope(t, db, customDocID, "custom")
|
|
}
|
|
|
|
func assertPGVaultScope(t *testing.T, db *sql.DB, docID, wantScope string) {
|
|
t.Helper()
|
|
var scope string
|
|
var teamID sql.NullString
|
|
if err := db.QueryRow(`SELECT scope, team_id FROM vault_documents WHERE id = $1`, docID).Scan(&scope, &teamID); err != nil {
|
|
t.Fatalf("query doc %s: %v", docID, err)
|
|
}
|
|
if scope != wantScope {
|
|
t.Errorf("doc %s scope = %q, want %q", docID, scope, wantScope)
|
|
}
|
|
if teamID.Valid {
|
|
t.Errorf("doc %s team_id should be NULL after team delete, got %q", docID, teamID.String)
|
|
}
|
|
}
|