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.
253 lines
12 KiB
Go
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)
|
|
}
|
|
}
|