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.
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
package server
|
||
|
||
// EXT-15 红测(提案 2026-10-09-ext-g1-b1-b3 §3):成员(经适配器放行)能在
|
||
// GET /api/projects 里看到共享项目(shared 标注、去重、不含未授权),并能在
|
||
// 共享项目下建会话;撤销后两者立即拒绝;未注册适配器=现状。
|
||
|
||
import (
|
||
"bytes"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
func TestSharedProjectListAndConversation(t *testing.T) {
|
||
srv, st := newAuthTestServer(t)
|
||
h := srv.Handler()
|
||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
pj, err := st.CreateProject("alice", "共享项目X")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
pid := pj.ID
|
||
|
||
type pv struct {
|
||
ID string `json:"id"`
|
||
Shared bool `json:"shared"`
|
||
}
|
||
call := func(key, method, path string) (*httptest.ResponseRecorder, []pv) {
|
||
req := httptest.NewRequest(method, path, nil)
|
||
req.Header.Set("Authorization", "Bearer "+key)
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
var list []pv
|
||
if rec.Body.Len() > 0 {
|
||
readJSON(t, rec.Body.Bytes(), &list)
|
||
}
|
||
return rec, list
|
||
}
|
||
|
||
// 未注册适配器:bob 看不到 alice 的项目(现状)。
|
||
rec, list := call(bob, http.MethodGet, "/api/projects")
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("list: %d", rec.Code)
|
||
}
|
||
if len(list) != 0 {
|
||
t.Fatalf("before adapter: bob sees %d projects, want 0", len(list))
|
||
}
|
||
|
||
// 放行 bob×pid。
|
||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||
return owner == "bob" && string(projectID) == pid, nil
|
||
})
|
||
rec, list = call(bob, http.MethodGet, "/api/projects")
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("list after grant: %d", rec.Code)
|
||
}
|
||
if len(list) != 1 || list[0].ID != pid || !list[0].Shared {
|
||
t.Fatalf("after grant: bob sees %+v, want 1 item id=%s shared=true", list, pid)
|
||
}
|
||
// alice 本人:仍只看到自己的(无 shared 标注)。
|
||
_, alist := call(alice, http.MethodGet, "/api/projects")
|
||
if len(alist) != 1 || alist[0].ID != pid || alist[0].Shared {
|
||
t.Fatalf("alice own list = %+v, want own without shared", alist)
|
||
}
|
||
|
||
// bob 在共享项目下建会话(EXT-15 第二入口)。
|
||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "成员会话"})))
|
||
req.Header.Set("Authorization", "Bearer "+bob)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec = httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("member create conversation: %d %s(须放行)", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
// 撤销:列表回到 0,建会话 404(不泄露存在性)。
|
||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return false, nil })
|
||
_, list = call(bob, http.MethodGet, "/api/projects")
|
||
if len(list) != 0 {
|
||
t.Fatalf("after revoke: bob sees %d, want 0", len(list))
|
||
}
|
||
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "x"})))
|
||
req.Header.Set("Authorization", "Bearer "+bob)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec = httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusNotFound {
|
||
t.Fatalf("after revoke: create conversation = %d, want 404", rec.Code)
|
||
}
|
||
}
|