Files
Agent-System-Backend/internal/app/account_directory_test.go
T
agent-exec-transport 612e04402d
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m43s
agent-exec transport be0c4744ff5899017313eb3f4fbd4a3f/43fe057c8332
2026-10-20 11:57:53 +00:00

318 lines
13 KiB
Go

package app
import (
"context"
"database/sql"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func openDirectoryTestStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "directory.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
schema := "t_" + strings.ToLower(store.NewID("dir"))
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
_ = admin.Close()
})
separator := "?"
if strings.Contains(dsn, "?") {
separator = "&"
}
st, err := store.OpenPostgres(dsn+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
st := openDirectoryTestStore(t)
t.Logf("directory provider dialect=%s", st.Dialect())
if err := st.CreateAccount("admin", "", "hash", true); err != nil {
t.Fatal(err)
}
alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "同名", StaffNo: "hidden-a"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "同名", StaffNo: "hidden-b"})
if err != nil {
t.Fatal(err)
}
outsider, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"})
if err != nil {
t.Fatal(err)
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
query := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeProjectMembers, ProjectID: "project-1",
Mode: extension.AccountDirectoryModeFuzzy, Query: "同名", Limit: 1}
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
t.Fatal("domain directory ran without trusted resolver")
}
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterAccountDirectoryScopeResolver(func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
CandidateIDs: []string{alice.ID, bob.ID}, ScopeVersion: "scope-1"}, nil
}); err != nil {
t.Fatal(err)
}
page, err := h.QueryAccountDirectory(context.Background(), query)
if err != nil || len(page.Entries) != 1 || page.Entries[0].UserID != alice.ID || page.Entries[0].StaffNo != "" || page.NextCursor == "" {
t.Fatalf("first scoped page: %+v %v", page, err)
}
query.Cursor = page.NextCursor
otherSlot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
otherSlot.pluginID, otherSlot.resolver = slot.pluginID, slot.resolver
otherHost := h
otherHost.caps.directory = otherSlot
second, err := otherHost.QueryAccountDirectory(context.Background(), query)
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("second scoped page on another host: %+v %v", second, err)
}
query.Cursor = ""
query.Query = "hidden-x"
if found, err := h.QueryAccountDirectory(context.Background(), query); err != nil || len(found.Entries) != 0 {
t.Fatalf("hidden staff number matched: %+v %v", found, err)
}
query.SearchFields = []string{"staffNo"}
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
t.Fatal("hidden staff number accepted as search field")
}
query.SearchFields = nil
query.Query = "同名"
if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID {
t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err)
}
if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("owner lookup leaked outsider: %v", err)
}
if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("exact lookup leaked outsider: %v", err)
}
page, err = h.QueryAccountDirectory(context.Background(), query)
if err != nil {
t.Fatal(err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "private-edit"}); err != nil {
t.Fatal(err)
}
query.Cursor = page.NextCursor
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("private edit invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"staffNo": "still-hidden"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("unavailable field invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", outsider.ID, outsider.ProfileVersion, map[string]string{"name": "out-of-scope-change"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("out-of-scope edit invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+2, map[string]string{"name": "changed"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) {
t.Fatalf("visible edit did not invalidate cursor: %v", err)
}
}
// project.member-roster:可信 RDM 解析方点名的项目成员清单。空 query 枚举作用域内
// 全部行(含已停用账号),limit/游标翻页照旧;非 rdm 插件、带 documentId、
// AllCandidates 解析结果一律拒绝;member-candidate 的空 query 仍是空页。
func TestAccountDirectoryProjectRosterEnumeratesResolvedMembers(t *testing.T) {
st := openDirectoryTestStore(t)
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "阿丽", StaffNo: "hidden-a"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "小博", StaffNo: "hidden-b"})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "局外人", StaffNo: "hidden-x"}); err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
var requests []extension.AccountDirectoryScopeRequest
resolver := func(_ context.Context, req extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
requests = append(requests, req)
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
CandidateOwners: []string{alice.Owner, bob.Owner}, ScopeVersion: "roster-1"}, nil
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
slot.pluginID, slot.resolver = "rdm", resolver
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
roster := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeProjectRoster, ProjectID: "project-1",
Mode: extension.AccountDirectoryModeFuzzy, Status: "all"}
page, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil {
t.Fatal(err)
}
if len(page.Entries) != 2 || page.NextCursor != "" {
t.Fatalf("roster page=%+v", page)
}
if page.Entries[0] != (extension.AccountDirectoryEntry{UserID: alice.ID, Owner: "alice", Name: "阿丽", Valid: true}) ||
page.Entries[1] != (extension.AccountDirectoryEntry{UserID: bob.ID, Owner: "bob", Name: "小博", Valid: false}) {
t.Fatalf("roster entries=%+v", page.Entries)
}
if len(requests) != 1 || requests[0].Purpose != extension.AccountDirectoryPurposeProjectRoster ||
requests[0].ProjectID != "project-1" || requests[0].DocumentID != "" || requests[0].Actor != "admin" {
t.Fatalf("resolver requests=%+v", requests)
}
// 游标翻页:同一签名游标,limit 1 两页走完。
roster.Limit = 1
first, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil || len(first.Entries) != 1 || first.Entries[0].UserID != alice.ID || first.NextCursor == "" {
t.Fatalf("roster first page=%+v %v", first, err)
}
roster.Cursor = first.NextCursor
second, err := h.QueryAccountDirectory(context.Background(), roster)
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("roster second page=%+v %v", second, err)
}
roster.Cursor, roster.Limit = "", 0
// 非检索目的的空 query 依旧空页:member-candidate 行为不变。
roster.Purpose, roster.Status = extension.AccountDirectoryPurposeProjectMembers, "enabled"
if page, err := h.QueryAccountDirectory(context.Background(), roster); err != nil || len(page.Entries) != 0 {
t.Fatalf("member candidate empty query=%+v %v", page, err)
}
roster.Purpose = extension.AccountDirectoryPurposeProjectRoster
// 非 rdm 插件、documentId 限定、全量作用域解析结果:均拒绝。
other := h
other.pluginID = "genui"
if _, err := other.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("non-rdm roster: %v", err)
}
withDocument := roster
withDocument.DocumentID = "doc-1"
if _, err := h.QueryAccountDirectory(context.Background(), withDocument); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("document roster: %v", err)
}
wideSlot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
wideSlot.pluginID, wideSlot.resolver = "rdm", func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
AllCandidates: true, ScopeVersion: "roster-wide"}, nil
}
wide := h
wide.caps.directory = wideSlot
if _, err := wide.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("all-candidate roster: %v", err)
}
}
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
if baseDSN == "" {
t.Skip("isolated PostgreSQL test DSN required")
}
if os.Getenv("IAM_U3_DIRECTORY_CHILD") == "1" {
schema := os.Getenv("IAM_U3_DIRECTORY_SCHEMA")
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
st, err := store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"}
q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy,
Query: "same", Limit: 1, Cursor: os.Getenv("IAM_U3_DIRECTORY_CURSOR")}
page, err := h.QueryAccountDirectory(context.Background(), q)
if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "beta" {
t.Fatalf("cross-process page: %+v %v", page, err)
}
return
}
st := openDirectoryTestStore(t)
if err := st.EnsureSystemIdentity("u3-directory-cross-process", "pg"); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
for _, owner := range []string{"alpha", "beta"} {
if _, err := st.CreatePendingAccount("admin", owner, store.AccountProfile{Name: "same"}); err != nil {
t.Fatal(err)
}
}
slot, err := newAccountDirectorySlot(st)
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"}
q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy,
Query: "same", Limit: 1}
page, err := h.QueryAccountDirectory(context.Background(), q)
if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "alpha" || page.NextCursor == "" {
t.Fatalf("initial page: %+v %v", page, err)
}
var schema string
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestPGDirectoryCursorContinuesInAnotherProcess$")
cmd.Env = append(os.Environ(), "IAM_U3_DIRECTORY_CHILD=1", "IAM_U3_DIRECTORY_SCHEMA="+schema,
"IAM_U3_DIRECTORY_CURSOR="+page.NextCursor)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("cross-process cursor failed: %v: %s", err, strings.ReplaceAll(string(out), baseDSN, "[REDACTED]"))
}
}