318 lines
13 KiB
Go
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]"))
|
|
}
|
|
}
|