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.
109 lines
3.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|