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.
155 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|