Files
Agent-System-Backend/internal/server/borrow_account_write_test.go
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

159 lines
5.5 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
)
func TestBorrowWritesCannotCommitAfterAccountDisable(t *testing.T) {
for _, tc := range []struct {
name, path, body, table string
}{
{"secret", "/api/secrets", `{"service":"late","value":"hidden"}`, "secret_keys"},
{"context", "/api/context-entries", `{"content":"late"}`, "context_entries"},
{"command", "/api/prompt-commands", `{"name":"late","template":"late"}`, "user_commands"},
} {
t.Run(tc.name, func(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
key, err := createLegacyAPIKeyFixture(t, st, "actor", "borrow")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(tc.body), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, tc.path, body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("request did not reach its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("request did not finish")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("late write status=%d body=%s", rec.Code, rec.Body.String())
}
var count int
if err := st.DB().QueryRow("SELECT COUNT(*) FROM " + tc.table + " WHERE owner='actor'").Scan(&count); err != nil || count != 0 {
t.Fatalf("late rows=%d err=%v", count, err)
}
})
}
}
func TestBorrowImplicitLocalCannotWriteWhenExplicitlyDisabled(t *testing.T) {
srv, st := newTestServer(t)
if err := st.CreateAccount("admin", "", "", true); err != nil {
t.Fatal(err)
}
secret, err := st.AddSecret(store.OwnerDefault, "service", "original", "hidden")
if err != nil {
t.Fatal(err)
}
entry, err := st.SaveContextEntry(store.OwnerDefault, store.ContextEntry{Content: "original"})
if err != nil {
t.Fatal(err)
}
command, err := st.SaveUserCommand(store.OwnerDefault, store.UserCommand{Name: "original", Template: "original"})
if err != nil {
t.Fatal(err)
}
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner(store.OwnerDefault)
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ method, path, body string }{
{http.MethodPost, "/api/secrets", `{"service":"late","value":"hidden"}`},
{http.MethodPost, "/api/secrets/" + secret.ID + "/activate", ""},
{http.MethodPatch, "/api/secrets/" + secret.ID, `{"label":"late"}`},
{http.MethodDelete, "/api/secrets/" + secret.ID, ""},
{http.MethodPost, "/api/context-entries", `{"content":"late"}`},
{http.MethodDelete, "/api/context-entries/" + entry.ID, ""},
{http.MethodPost, "/api/prompt-commands", `{"name":"late","template":"late"}`},
{http.MethodDelete, "/api/prompt-commands/" + command.ID, ""},
} {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled local account wrote %s: %d %s", tc.path, rec.Code, rec.Body.String())
}
}
if value, ok := st.ActiveSecret(store.OwnerDefault, "service"); !ok || value != "hidden" {
t.Fatalf("rejected secret mutations changed active value: %q %v", value, ok)
}
entries, err := st.ListContextEntries(store.OwnerDefault, "", "")
if err != nil || len(entries) != 1 || entries[0].Content != "original" {
t.Fatalf("rejected context mutations changed rows: %+v %v", entries, err)
}
commands, err := st.ListUserCommands(store.OwnerDefault)
if err != nil || len(commands) != 1 || commands[0].Template != "original" {
t.Fatalf("rejected command mutations changed rows: %+v %v", commands, err)
}
srv.appendUserPromptCommands(store.OwnerDefault, nil)
for _, registered := range srv.Bus.List() {
if registered.Spec.ID == "user.original" {
t.Fatal("disabled account registered a command during listing")
}
}
}
func TestBorrowPromptCommandBusFollowsCommittedWrite(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/prompt-commands", strings.NewReader(`{"name":"local","template":"hello"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("save command: %d %s", rec.Code, rec.Body.String())
}
commands, err := st.ListUserCommands(store.OwnerDefault)
if err != nil || len(commands) != 1 {
t.Fatalf("saved command missing: %+v %v", commands, err)
}
registered := false
for _, command := range srv.Bus.List() {
registered = registered || command.Spec.ID == "user.local"
}
if !registered {
t.Fatal("committed command was not registered in the Bus")
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/prompt-commands/"+commands[0].ID, nil))
if rec.Code != http.StatusOK {
t.Fatalf("delete command: %d %s", rec.Code, rec.Body.String())
}
for _, command := range srv.Bus.List() {
if command.Spec.ID == "user.local" {
t.Fatal("deleted command remained registered in the Bus")
}
}
}