diff --git a/tests/integration/multi-instance/multi_instance_test.go b/tests/integration/multi-instance/multi_instance_test.go index bde0fbd5..6c6ff29f 100644 --- a/tests/integration/multi-instance/multi_instance_test.go +++ b/tests/integration/multi-instance/multi_instance_test.go @@ -40,16 +40,16 @@ import ( const ( aliceOwner = "mi_alice" - alicePass0 = "mi-alice-pass-0" alicePass = "mi-alice-pass-1" httpWait = 15 * time.Second ) type instance struct { - name string - base string - cmd *exec.Cmd - log *lockedBuffer + name string + base string + dataDir string // serve 的 cwd:本地实例身份(manifest)所在,账户 CLI 须以此身份运行 + cmd *exec.Cmd + log *lockedBuffer } type lockedBuffer struct { @@ -312,6 +312,20 @@ func (e *env) startPair() { e.t.Helper() e.a = e.startInstance(e.t, "miA", "127.0.0.1:"+freePort(e.t)) e.b = e.startInstance(e.t, "miB", "127.0.0.1:"+freePort(e.t)) + // 库内服务身份是显式引导(svc init 同款),且为部署级单行:双进程共享 + // 同一身份,只对 A ensure 一次。账户 CLI 的审计与核验都依赖它。 + e.identityEnsure(e.t, e.a) +} + +// identityEnsure 建立/对齐库内服务身份行(agent_system_identity)。 +func (e *env) identityEnsure(t *testing.T, inst *instance) { + t.Helper() + cmd := exec.Command(e.bin, "identity", "ensure", "--instance-id", inst.name, "--data", inst.dataDir, "--dbref", "pg") + cmd.Dir = inst.dataDir + cmd.Env = append(os.Environ(), "AGENT_SYSTEM_DSN="+e.dbDSN) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("identity ensure %s: %v\n%s", inst.name, err, out) + } } func (e *env) startInstance(t *testing.T, name, addr string) *instance { @@ -332,11 +346,12 @@ func (e *env) startInstance(t *testing.T, name, addr string) *instance { // 否则测不到带类型空 *S3 回落本地目录的路径。 cmd.Env = withoutBlobstoreS3(os.Environ()) // 独立 cwd:serve 会向 ./data 写 skills/SQLite 等,不能落在测试目录。 - cmd.Dir = t.TempDir() + dataDir := t.TempDir() + cmd.Dir = dataDir if err := cmd.Start(); err != nil { t.Fatalf("start %s: %v", name, err) } - inst := &instance{name: name, base: "http://" + addr, cmd: cmd, log: log} + inst := &instance{name: name, base: "http://" + addr, dataDir: dataDir, cmd: cmd, log: log} t.Cleanup(func() { e.stopInstance(inst) }) e.waitHealthy(t, inst) return inst @@ -435,29 +450,35 @@ func (e *env) killNine(t *testing.T, inst *instance) { // loginAlice 引导 alice 账号并发放 API key(CLI 直连测试库;key 存共享 PG, // 对两实例同时有效——程序化验收的首选凭证,SSE 亦可用 ?key= 兜底)。 -// 建号后 must_change=1:API Key 在改密完成前不能走业务接口(login_keys 门禁), -// 故先经会话完成 alicePass0→alicePass,再发 Key。 +// U3 后本地无密码创建:user create 只签发待激活账号的一次性激活 Key(凭据 +// 独占落 JSON),redeem 会话进入 grant-setup 改密设 alicePass,完成后再发 Key。 func (e *env) loginAlice(t *testing.T) *userClient { t.Helper() - e.cli(t, "user", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "Alice", "--password", alicePass0) + // 空库先引导首个管理员(本地 pending 创建要求库内已有用户),再建 alice。 + // 两步都签发一次性凭据,独占落临时 JSON 后即弃。 + rootCred := filepath.Join(t.TempDir(), "root-cred.json") + e.cliIn(t, e.a.dataDir, "user", "bootstrap-admin", "--dsn", e.dbDSN, "--owner", "mi-root", "--out-file", rootCred) + activation := filepath.Join(t.TempDir(), "activation.json") + e.cliIn(t, e.a.dataDir, "user", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "Alice", "--out", activation) + activateKey := activationSecret(t, activation) jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } sess := &http.Client{Timeout: httpWait, Jar: jar} - // e.uc 尚未赋值 → baseFor 走 A;会话 cookie 落 jar 后改密清 must_change。 - e.do(t, sess, "POST", "/api/auth/login", map[string]any{ - "owner": aliceOwner, "password": alicePass0, + // e.uc 尚未赋值 → baseFor 走 A;redeem 会话(grant-setup)直接设 alicePass。 + e.do(t, sess, "POST", "/api/auth/login-key/redeem", map[string]any{ + "key": activateKey, }, nil) e.do(t, sess, "POST", "/api/auth/password", map[string]any{ - "current": alicePass0, "next": alicePass, + "next": alicePass, }, nil) - key := e.cliOut(t, "apikey", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "mi-acceptance") + apiKey := e.cliOut(t, "apikey", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "mi-acceptance") uc := &userClient{ - api: &http.Client{Timeout: httpWait, Transport: bearerRT{key: key}}, - apiB: &http.Client{Timeout: httpWait, Transport: bearerRT{key: key}}, - streamB: &http.Client{Transport: bearerRT{key: key}}, // SSE 无整体超时;chan 背压控制 + api: &http.Client{Timeout: httpWait, Transport: bearerRT{key: apiKey}}, + apiB: &http.Client{Timeout: httpWait, Transport: bearerRT{key: apiKey}}, + streamB: &http.Client{Transport: bearerRT{key: apiKey}}, // SSE 无整体超时;chan 背压控制 } // runtime.mock 自系统受管设置体制(backend #188/#190)起由 --mock 播种进 // 系统层,经 Effective 链对所有租户生效(selectConversationRunner 按 @@ -474,6 +495,22 @@ func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) { return http.DefaultTransport.RoundTrip(r2) } +// activationSecret 读 CLI 凭据独占文件里的激活 Key(JSON {"kind","owner","secret"})。 +func activationSecret(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var v struct { + Secret string `json:"secret"` + } + if err := json.Unmarshal(b, &v); err != nil || v.Secret == "" { + t.Fatalf("activation credential unreadable: %v", err) + } + return v.Secret +} + // cliOut 运行 CLI 并取 stdout 首行(apikey create 的明文仅此一次输出)。 func (e *env) cliOut(t *testing.T, args ...string) string { t.Helper() @@ -492,9 +529,16 @@ func (e *env) cliOut(t *testing.T, args ...string) string { } func (e *env) cli(t *testing.T, args ...string) { + t.Helper() + e.cliIn(t, t.TempDir(), args...) +} + +// cliIn 以指定 cwd 运行 CLI:账户命令经 assertLocalAccountOwner 核验本地实例 +// 身份,必须从持有该身份的 dataDir 运行。 +func (e *env) cliIn(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command(e.bin, args...) - cmd.Dir = t.TempDir() + cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("cli %v…: %v\n%s", args[:2], err, out)