Files
Agent-System-Backend/internal/store/knowledge_account_write_test.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

253 lines
12 KiB
Go

package store
import (
"context"
"errors"
"path/filepath"
"testing"
core "agent-system-core"
)
type knowledgeAccountWriter interface {
AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error
DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error)
DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error
DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error
DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error
ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error
UpsertKnowledgeVectorExtAccountChecked(ctx context.Context, actor, docID, ref, model, contentHash string, vec []float32, allowImplicitDefault bool) error
DeleteKnowledgeVectorsAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error
}
func knowledgeWriter(t *testing.T, st *Store) knowledgeAccountWriter {
t.Helper()
w, ok := any(st).(knowledgeAccountWriter)
if !ok {
t.Fatal("Store is missing the account-checked knowledge and vector API")
}
return w
}
func knowledgeRows(t *testing.T, st *Store, table, owner, ref string) int {
t.Helper()
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, owner, ref).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestKnowledgeAccountFenceRejectsDisabledAndMixedOwnerBatch(t *testing.T) {
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
w := knowledgeWriter(t, st)
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
good := core.KnowledgeDoc{ID: "knw-good", Owner: "alice", Ref: "batch", Source: "manual", Chunk: "good"}
foreign := core.KnowledgeDoc{ID: "knw-foreign", Owner: "bob", Ref: "batch", Source: "manual", Chunk: "foreign"}
if err := w.AddKnowledgeAccountChecked("alice", false, good, foreign); !errors.Is(err, ErrNotFound) {
t.Fatalf("mixed owner batch: got %v, want ErrNotFound", err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 {
t.Fatalf("mixed owner batch wrote %d rows", n)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
if err := w.AddKnowledgeAccountChecked("alice", false, good); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled add: got %v, want ErrAccountForbidden", err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 {
t.Fatalf("disabled add wrote %d rows", n)
}
}
func TestKnowledgeAccountFenceDeletesOnlyExactSourceAndScope(t *testing.T) {
ctx := context.Background()
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
w := knowledgeWriter(t, st)
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
}
docs := []core.KnowledgeDoc{
{ID: "knw-a1", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "a1"},
{ID: "knw-a2", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b:child", Chunk: "a2"},
{ID: "knw-a3", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:axb", Chunk: "a3"},
{ID: "knw-a4", Owner: "alice", ProjectID: "p2", Ref: "r1", Source: "plugin:a_b", Chunk: "a4"},
{ID: "knw-b1", Owner: "bob", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "b1"},
}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
for _, d := range docs {
if err := st.UpsertKnowledgeVectorExt(ctx, string(d.Owner), string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil {
t.Fatal(err)
}
}
if n, err := w.DeleteKnowledgeBySourceAccountChecked(ctx, "alice", "plugin:a_b", false); err != nil || n != 3 {
t.Fatalf("exact source delete: %d, %v; want 3", n, err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 1 {
t.Fatalf("literal prefix matched unrelated source: %d alice rows remain", n)
}
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "r1"); n != 1 {
t.Fatalf("source delete left wrong vector set: %d", n)
}
if n := knowledgeRows(t, st, "knowledge", "bob", "r1"); n != 1 {
t.Fatalf("source delete crossed owner: %d", n)
}
if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "r1", "axb", false); err != nil {
t.Fatal(err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 0 {
t.Fatalf("plugin scoped delete left %d rows", n)
}
}
func TestKnowledgeAccountFenceProjectPluginAndRefDeletesKeepOtherScopes(t *testing.T) {
ctx := context.Background()
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
w := knowledgeWriter(t, st)
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
docs := []core.KnowledgeDoc{
{ID: "knw-p1-a", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "one"},
{ID: "knw-p1-b", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "two"},
{ID: "knw-p2-a", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "three"},
}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
for _, d := range docs {
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil {
t.Fatal(err)
}
}
if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", "alpha", false); err != nil {
t.Fatal(err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 2 {
t.Fatalf("plugin delete removed other scope: %d remain", n)
}
if err := w.DeleteProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", false); err != nil {
t.Fatal(err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 1 {
t.Fatalf("project delete removed other project: %d remain", n)
}
if err := w.DeleteKnowledgeByRefAccountChecked(ctx, "alice", "shared", false); err != nil {
t.Fatal(err)
}
if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 0 {
t.Fatalf("ref delete left %d knowledge rows", n)
}
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 {
t.Fatalf("ref delete left %d vectors", n)
}
}
func TestKnowledgeAccountFenceVectorRequiresOwnedMatchingKnowledge(t *testing.T) {
ctx := context.Background()
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
w := knowledgeWriter(t, st)
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
}
if err := st.AddKnowledge(
core.KnowledgeDoc{ID: "knw-alice", Owner: "alice", Ref: "a", Chunk: "alice"},
core.KnowledgeDoc{ID: "knw-bob", Owner: "bob", Ref: "b", Chunk: "bob"},
); err != nil {
t.Fatal(err)
}
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-bob", "b", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) {
t.Fatalf("foreign doc vector: got %v, want ErrNotFound", err)
}
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "wrong-ref", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) {
t.Fatalf("mismatched ref vector: got %v, want ErrNotFound", err)
}
if err := st.UpsertKnowledgeVectorExt(ctx, "bob", "knw-alice", "a", "legacy", "hash", []float32{0, 1}); err != nil {
t.Fatal(err)
}
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) {
t.Fatalf("foreign existing vector takeover: got %v, want ErrNotFound", err)
}
if err := st.DeleteKnowledgeVectors(ctx, "bob", "a"); err != nil {
t.Fatal(err)
}
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash2", []float32{0, 1}, false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled vector upsert: got %v, want ErrAccountForbidden", err)
}
if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled vector delete: got %v, want ErrAccountForbidden", err)
}
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 1 {
t.Fatalf("disabled actor changed %d vector rows", n)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=TRUE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); err != nil {
t.Fatal(err)
}
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 0 {
t.Fatalf("guarded vector delete left %d rows", n)
}
}
func TestKnowledgeAccountFenceReplaceRollsBackDeletionWhenInsertFails(t *testing.T) {
ctx := context.Background()
st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db"))
w := knowledgeWriter(t, st)
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
old := core.KnowledgeDoc{ID: "knw-old", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "old"}
other := core.KnowledgeDoc{ID: "knw-fixed", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "other plugin"}
otherProject := core.KnowledgeDoc{ID: "knw-p2", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "other project"}
if err := st.AddKnowledge(old, other, otherProject); err != nil {
t.Fatal(err)
}
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(old.ID), old.Ref, "test", "hash", []float32{1, 0}); err != nil {
t.Fatal(err)
}
first := core.KnowledgeDoc{ID: "knw-new", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "replacement"}
duplicate := core.KnowledgeDoc{ID: other.ID, Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "duplicate id"}
if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first, duplicate); err == nil {
t.Fatal("replacement with duplicate ID unexpectedly committed")
}
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 1 {
t.Fatalf("failed replacement removed original row: %d, %v", n, err)
}
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, first.ID).Scan(&n); err != nil || n != 0 {
t.Fatalf("failed replacement inserted first row: %d, %v", n, err)
}
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 1 {
t.Fatalf("failed replacement removed original vector: %d", n)
}
if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first); err != nil {
t.Fatal(err)
}
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id IN (?,?,?)`, first.ID, other.ID, otherProject.ID).Scan(&n); err != nil || n != 3 {
t.Fatalf("successful replacement lost other scopes: %d, %v", n, err)
}
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 0 {
t.Fatalf("successful replacement kept old row: %d, %v", n, err)
}
if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 {
t.Fatalf("successful replacement kept old vector: %d", n)
}
}