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.
159 lines
5.5 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|