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.
222 lines
8.6 KiB
Go
222 lines
8.6 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)
|
|
}
|
|
}
|
|
|
|
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]"))
|
|
}
|
|
}
|