Why:
Membership could be revoked while a tool waited for confirmation or a queued
HITL reply waited for consumption. Publication replacement also crossed an
empty project scope or another plugin's source namespace for the same Ref.
Evidence:
SELF replay of all four independent review reproductions failed before the
fix and passed unchanged afterward. Committed regressions cover direct and
queued HITL, yes/session confirmation, direct artifact writes, protected vectors,
exact empty project scopes, literal wildcard IDs and unregistered compatibility.
SELF uncached go test ./..., isolated PostgreSQL Store tests and static build passed.
Impact:
Recheck trusted live owner/project authorization when HITL resumes and at write
boundaries. Registered publishers replace only exact owner/project/ref/plugin
rows and vectors; ordinary unregistered publication semantics are preserved.
Refs:
Dev project-member-access implementation plan r1 Task 1; independent Backend
review findings against b9bea5bee0
167 lines
7.4 KiB
Go
167 lines
7.4 KiB
Go
package app
|
|
|
|
import (
|
|
"agent-system-backend/internal/rag"
|
|
"agent-system-backend/internal/settings"
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil)
|
|
auth := &projectAuthz{}
|
|
alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}}
|
|
first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}}
|
|
second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}}
|
|
for _, ds := range [][]core.KnowledgeDoc{first, second, second} {
|
|
if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"})
|
|
if err != nil || len(own) != 2 {
|
|
t.Fatalf("cross project publish replaced first: %+v %v", own, err)
|
|
}
|
|
auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil })
|
|
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
|
|
return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil
|
|
})
|
|
bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps}
|
|
hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5)
|
|
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" {
|
|
t.Fatalf("host V2=%+v %v", hits, err)
|
|
}
|
|
}
|
|
|
|
func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slot := &projectLifecycleSlot{}
|
|
provider := completeLifecycle()
|
|
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
|
|
if h.Owner() != "bob" {
|
|
t.Fatalf("wrong scope actor %q", h.Owner())
|
|
}
|
|
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil
|
|
}
|
|
caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
|
|
h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps}
|
|
hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5)
|
|
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
|
t.Fatalf("host scope: %+v %v", hits, err)
|
|
}
|
|
}
|
|
|
|
func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
slot := &projectLifecycleSlot{}
|
|
caps := hostcaps{st: st, projects: slot}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
|
for _, publish := range []bool{false, true} {
|
|
docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}}
|
|
var err error
|
|
if publish {
|
|
err = h.KnowledgePublish(context.Background(), docs, false)
|
|
} else {
|
|
err = h.KnowledgeWrite(context.Background(), docs, false)
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("foreign namespace accepted publish=%v", publish)
|
|
}
|
|
}
|
|
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 {
|
|
t.Fatalf("foreign source wrote data: %+v %v", hits, err)
|
|
}
|
|
// Existing ordinary Hosts preserve their documented caller-provided Source.
|
|
h.caps.projects = nil
|
|
if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil {
|
|
t.Fatalf("ordinary source semantics changed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
slot := &projectLifecycleSlot{}
|
|
caps := hostcaps{st: st, projects: slot}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"}
|
|
if err := st.AddKnowledge(original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
|
replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}}
|
|
for i := 0; i < 2; i++ {
|
|
if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 {
|
|
t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err)
|
|
}
|
|
if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID {
|
|
t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err)
|
|
}
|
|
h.pluginID = "rdm"
|
|
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 {
|
|
t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err)
|
|
}
|
|
}
|
|
|
|
func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}}
|
|
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 {
|
|
t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err)
|
|
}
|
|
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 {
|
|
t.Fatalf("unregistered replacement missing: %+v %v", hits, err)
|
|
}
|
|
}
|