Files
Agent-System-Backend/internal/app/delegated_project_projection_entry_test.go
T
weichen 7492feab42 Fence delegated project projection by its real operator
Why: RDM delegated rename rebuilt the creator Host and could commit a Backend project rename after the delegated operator was disabled.

Evidence: SQLite and isolated PostgreSQL targeted checks cover operator/creator disable order, two processes, sorted locks, auth-off local compatibility, and HTTP/command Host wiring; exact committed-head replay remains pending.

Impact: Adds a request-bound single-project extension capability and checks operator plus creator in the final Backend transaction. Global ScopedHost cannot derive delegated authority; RDM cross-database atomicity remains outside the promise.

Refs: Approved Documents delegated-projection amendment 5783940, U3 A1-A4 candidate adcd8fe, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 08:17:31 +00:00

109 lines
3.6 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/server"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func TestDelegatedProjectionIsBoundAtHTTPAndCommandEntries(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/entries.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
for _, owner := range []string{"alice", "bob"} {
if err := st.EnsureUser(owner, ""); err != nil {
t.Fatal(err)
}
}
httpProject, err := st.CreateProject("alice", "HTTP original")
if err != nil {
t.Fatal(err)
}
commandProject, err := st.CreateProject("alice", "Command original")
if err != nil {
t.Fatal(err)
}
check := func(owner string) error {
state, err := st.GetUserAuthState(owner)
if err != nil {
return err
}
if !state.Enabled {
return store.ErrAccountForbidden
}
return nil
}
projectFromRequest := func(h extension.Host, id, name string) (core.Project, error) {
bound, ok := h.(extension.DelegatedProjectProjectionHost)
if !ok {
return core.Project{}, errors.New("request Host lost operator-bound projection")
}
projection, err := bound.ProjectProjectionForCreator("alice", id)
if err != nil {
return core.Project{}, err
}
return projection.RenameOwnedProject(id, name)
}
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, set: settings.NewService(st), projects: slot, accountGuard: check}
provider := completeLifecycle()
provider.Rename = func(_ context.Context, h extension.Host, id, name string) (core.Project, error) {
return projectFromRequest(h, id, name)
}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
srv := server.New(st, nil, nil, nil, caps.set, nil)
srv.Auth = server.NewAuthenticator(st, true)
slot.wire(srv, caps)
key, err := st.CreateAPIKey("bob", "delegate-entry")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPatch, "/api/projects/"+string(httpProject.ID), strings.NewReader(`{"name":"via HTTP"}`))
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP lifecycle rename: status=%d body=%s", rec.Code, rec.Body.String())
}
bus := cmdbus.New(st, nil, nil)
bus.SetEffectiveAccountGuard(func(_ context.Context, owner string) error { return check(owner) })
bus.RequireAccountCheckedWrites(false)
reg := pluginRegistry{pluginID: "rdm", caps: caps, compiledRegistry: compiledRegistry{bus: bus}}
err = reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.fixture.rename","title":"Rename"}`),
func(_ context.Context, h extension.Host, req extension.Request) (any, error) {
id, _ := req.Params["id"].(string)
name, _ := req.Params["name"].(string)
return projectFromRequest(h, id, name)
})
if err != nil {
t.Fatal(err)
}
result := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.fixture.rename", Owner: "bob", Actor: "bob",
Params: map[string]any{"id": string(commandProject.ID), "name": "via command"}})
if result.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("command rename: %+v", result.Receipt)
}
for _, tc := range []struct{ id, want string }{{string(httpProject.ID), "via HTTP"}, {string(commandProject.ID), "via command"}} {
project, err := st.ProjectMetadata(tc.id)
if err != nil || project.Name != tc.want {
t.Fatalf("entry %s did not reach bound projection: %+v %v", tc.id, project, err)
}
}
}