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

155 lines
6.0 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/url"
"testing"
"agent-system-backend/internal/store"
)
func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
alice, err := st.CreatePendingAccount("admin", "alice-search", store.AccountProfile{Name: "同名", Phone: "private-needle"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob-search", store.AccountProfile{Name: "同名", Phone: "other"})
if err != nil {
t.Fatal(err)
}
search := func(query url.Values) (int, struct {
Accounts []struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
} `json:"accounts"`
NextCursor string `json:"nextCursor"`
}) {
response := do(h, "GET", "/api/admin/users/search?"+query.Encode(), "", admin)
var page struct {
Accounts []struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
} `json:"accounts"`
NextCursor string `json:"nextCursor"`
}
_ = json.Unmarshal(response.Body.Bytes(), &page)
return response.Code, page
}
params := url.Values{"query": {"同名"}, "mode": {"fuzzy"}, "fields": {"name"}, "limit": {"1"}}
code, first := search(params)
if code != http.StatusOK || len(first.Accounts) != 1 || first.Accounts[0].UserID != alice.ID || first.NextCursor == "" {
t.Fatalf("first search page: %d %+v", code, first)
}
params.Set("cursor", first.NextCursor)
other := New(st, srv.mgr, srv.reg, srv.prof, srv.set, srv.plugins)
other.Auth = srv.Auth
h = other.Handler()
code, second := search(params)
if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("second search page on another server: %d %+v", code, second)
}
params.Del("cursor")
params.Set("query", "private-needle")
params.Set("fields", "phone")
params.Set("mode", "exact")
code, contact := search(params)
if code != http.StatusOK || len(contact.Accounts) != 1 || contact.Accounts[0].UserID != alice.ID {
t.Fatalf("authorized contact search: %d %+v", code, contact)
}
params.Set("query", "同名")
params.Set("fields", "name")
params.Set("mode", "fuzzy")
params.Set("cursor", first.NextCursor)
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "changed-private"}); err != nil {
t.Fatal(err)
}
code, _ = search(params)
if code != http.StatusConflict {
t.Fatalf("private profile edit did not expire system cursor: %d", code)
}
}
func TestSelfProfileRequiresPasswordAuthenticatedFullSession(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("self-profile-password")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("legacy-self", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("legacy-self"); err != nil {
t.Fatal(err)
}
legacyCookie, err := st.CreateSession("legacy-self")
if err != nil {
t.Fatal(err)
}
if rec := do(h, "GET", "/api/me", "", legacyCookie); rec.Code != http.StatusOK {
t.Fatalf("historical full session lost ordinary identity: %d", rec.Code)
}
if rec := do(h, "GET", "/api/me/profile", "", legacyCookie); rec.Code != http.StatusForbidden {
t.Fatalf("unknown session lineage read profile: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "PATCH", "/api/me/profile", `{"expectedProfileVersion":1,"patch":{"name":"injected"}}`, legacyCookie); rec.Code != http.StatusForbidden {
t.Fatalf("unknown session lineage edited profile: %d %s", rec.Code, rec.Body.String())
}
login := do(h, "POST", "/api/auth/login", `{"owner":"legacy-self","password":"self-profile-password"}`, "")
if login.Code != http.StatusOK {
t.Fatalf("password login: %d %s", login.Code, login.Body.String())
}
if rec := do(h, "GET", "/api/me/profile", "", cookieOf(login)); rec.Code != http.StatusOK {
t.Fatalf("verified password session could not read profile: %d %s", rec.Code, rec.Body.String())
}
}
func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
adminCookie := bootAdmin(t, h, st)
for _, body := range []string{
`{"owner":"blocked-password","password":"old-pass-9"}`,
`{"owner":"blocked-admin","isAdmin":true}`,
} {
if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest {
t.Fatalf("unsafe create accepted: %d %s", rec.Code, rec.Body.String())
}
}
if _, err := st.GetUser("blocked-password"); err != store.ErrNotFound {
t.Fatalf("unsafe create had side effect: %v", err)
}
rec := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"同名"}}`, adminCookie)
if rec.Code != http.StatusOK {
t.Fatalf("pending create: %d %s", rec.Code, rec.Body.String())
}
var created struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
ProfileVersion int64 `json:"profileVersion"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.UserID == "" || created.Owner != "alice" || created.ProfileVersion != 1 {
t.Fatalf("create projection: %+v %v", created, err)
}
rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":1,"patch":{"phone":"123"}}`, adminCookie)
if rec.Code != http.StatusOK {
t.Fatalf("profile patch: %d %s", rec.Code, rec.Body.String())
}
rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":2,"patch":{"isAdmin":"true"}}`, adminCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("privilege field accepted: %d %s", rec.Code, rec.Body.String())
}
rec = do(h, "PUT", "/api/admin/users/"+created.UserID+"/state", `{"expectedStateVersion":1,"enabled":false}`, adminCookie)
if rec.Code != http.StatusOK {
t.Fatalf("disable: %d %s", rec.Code, rec.Body.String())
}
account, err := st.GetAccount(created.UserID)
if err != nil || account.Enabled || account.Profile.Phone != "123" {
t.Fatalf("state/profile after disable: %+v %v", account, err)
}
}