From 514db3a086f51fe347abc9c5d32c49f3e6ba3e69 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 09:42:07 +0000 Subject: [PATCH 1/4] feat(auth): fail-closed IAM store unless dev/test opt-in Refuse MemoryStore fallback for main/empty/unknown env or missing ALLOW_IN_MEMORY so production cannot silently use in-memory authority. Co-authored-by: Cursor --- internal/auth/store_factory.go | 29 +++++++++++++- internal/auth/store_factory_test.go | 62 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 internal/auth/store_factory_test.go diff --git a/internal/auth/store_factory.go b/internal/auth/store_factory.go index e9535709..addd21d4 100644 --- a/internal/auth/store_factory.go +++ b/internal/auth/store_factory.go @@ -2,6 +2,8 @@ package auth import ( "context" + "fmt" + "strings" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/config" @@ -13,7 +15,17 @@ type Stores struct { Sessions SessionStore } +func allowInMemoryAuthFallback(cfg *config.Config) bool { + if cfg == nil || !cfg.AllowInMemory { + return false + } + env := strings.ToLower(strings.TrimSpace(cfg.Env)) + return env == "dev" || env == "test" +} + // NewStores 根据数据库与 IAM 迁移状态选择 PG 或内存实现。 +// 仅当环境明确为 dev/test 且 AllowInMemory 时允许 MemoryStore; +// 环境空值、未知值、main/prod、pool 缺失或 IAM 表未就绪时 fail closed。 func NewStores(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config) (*Stores, error) { if pool != nil && IAMTablesReady(ctx, pool) { pg, err := NewPgStore(pool, cfg) @@ -25,6 +37,19 @@ func NewStores(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config) (*St } return &Stores{Auth: pg, Sessions: pg}, nil } - mem := NewMemoryStore(cfg) - return &Stores{Auth: mem, Sessions: NewMemorySessionStore()}, nil + if allowInMemoryAuthFallback(cfg) { + mem := NewMemoryStore(cfg) + return &Stores{Auth: mem, Sessions: NewMemorySessionStore()}, nil + } + env := "" + if cfg != nil { + env = strings.TrimSpace(cfg.Env) + } + reason := "IAM tables not ready or database pool unavailable" + if pool == nil { + reason = "database pool is nil" + } else if !IAMTablesReady(ctx, pool) { + reason = "IAM tables not ready" + } + return nil, fmt.Errorf("auth store fail-closed: %s (env=%q); memory IAM fallback requires env=dev|test and ALLOW_IN_MEMORY=true", reason, env) } diff --git a/internal/auth/store_factory_test.go b/internal/auth/store_factory_test.go new file mode 100644 index 00000000..59236546 --- /dev/null +++ b/internal/auth/store_factory_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/config" +) + +func TestNewStores_FailClosed_WhenIAMUnavailable(t *testing.T) { + ctx := context.Background() + cases := []struct { + name string + cfg *config.Config + }{ + {name: "main_nil_pool", cfg: &config.Config{Env: "main", AllowInMemory: false}}, + {name: "prod_nil_pool", cfg: &config.Config{Env: "prod", AllowInMemory: false}}, + {name: "empty_env_nil_pool", cfg: &config.Config{Env: "", AllowInMemory: true}}, + {name: "unknown_env_nil_pool", cfg: &config.Config{Env: "staging", AllowInMemory: true}}, + {name: "dev_without_opt_in", cfg: &config.Config{Env: "dev", AllowInMemory: false}}, + {name: "test_without_opt_in", cfg: &config.Config{Env: "test", AllowInMemory: false}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stores, err := NewStores(ctx, nil, tc.cfg) + if err == nil { + t.Fatalf("expected fail-closed error, got stores=%v", stores) + } + if stores != nil { + t.Fatalf("expected nil stores on failure, got %#v", stores) + } + if !strings.Contains(err.Error(), "IAM") && !strings.Contains(err.Error(), "memory") { + t.Fatalf("error should mention IAM/memory policy: %v", err) + } + }) + } +} + +func TestNewStores_AllowsMemoryOnlyWithDevTestOptIn(t *testing.T) { + ctx := context.Background() + for _, env := range []string{"dev", "test"} { + t.Run(env, func(t *testing.T) { + cfg := &config.Config{ + Env: env, + AllowInMemory: true, + AdminUsername: "admin", + AdminPassword: "admin-test-pass", + } + stores, err := NewStores(ctx, nil, cfg) + if err != nil { + t.Fatalf("expected memory fixture: %v", err) + } + if stores == nil || stores.Auth == nil || stores.Sessions == nil { + t.Fatal("expected auth+session stores") + } + if _, ok := stores.Auth.(*MemoryStore); !ok { + t.Fatalf("Auth should be MemoryStore, got %T", stores.Auth) + } + }) + } +} From 7fdbee6c0cb487c7f06a8a7a924ff5f3471b46f3 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 09:42:07 +0000 Subject: [PATCH 2/4] test(server): adapt fixtures to IAM dual-gate fail-closed Boot memory IAM only via explicit test env opt-in; strict auth cases disable AllowInMemory after startup. Co-authored-by: Cursor --- internal/bizdev/testutil.go | 3 ++ internal/server/auth_gate_a_test.go | 29 +++++++++++-- internal/server/auth_handlers_test.go | 23 +++++----- internal/server/auth_phase1_test.go | 12 ++---- internal/server/chat_window_api_test.go | 11 +++-- internal/server/project_handlers_test.go | 2 +- internal/server/project_members_authz_test.go | 10 ++++- internal/server/reply_export_api_test.go | 11 +++-- internal/server/security_p0_test.go | 43 ++++++------------- internal/server/security_penetration_test.go | 20 ++++----- internal/server/server_test.go | 22 ++++++++++ internal/server/web_assets_test.go | 15 +++---- 12 files changed, 115 insertions(+), 86 deletions(-) diff --git a/internal/bizdev/testutil.go b/internal/bizdev/testutil.go index 497c2521..8f7ec883 100644 --- a/internal/bizdev/testutil.go +++ b/internal/bizdev/testutil.go @@ -20,6 +20,9 @@ func MustNewService(t testing.TB, cfg *config.Config) *Service { return svc } cfg.AllowInMemory = true + if cfg.Env == "" { + cfg.Env = "test" + } svc, err := NewService(cfg) if err != nil { t.Fatalf("NewService: %v", err) diff --git a/internal/server/auth_gate_a_test.go b/internal/server/auth_gate_a_test.go index edab29b9..f04e09fd 100644 --- a/internal/server/auth_gate_a_test.go +++ b/internal/server/auth_gate_a_test.go @@ -3,15 +3,18 @@ package server import ( "net/http" "net/http/httptest" + "strings" "testing" + "chat-agent-system/internal/app" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" ) func TestGateA_LineageAndRunsRequireProjectAccess(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"} - srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + // Gate 行为可用 test+AllowInMemory fixture;main 不得再靠 MemoryStore 旁路搭服务。 + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "test", ContextDbHost: "127.0.0.1"} + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "test"})) srv.cfg = cfg cases := []struct { @@ -32,8 +35,8 @@ func TestGateA_LineageAndRunsRequireProjectAccess(t *testing.T) { } func TestGateA_CreateProjectRequiresPermission(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"} - srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "test", ContextDbHost: "127.0.0.1"} + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "test"})) srv.cfg = cfg req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", nil) @@ -44,3 +47,21 @@ func TestGateA_CreateProjectRequiresPermission(t *testing.T) { t.Fatalf("expected 401, got %d", rec.Code) } } + +func TestAssemble_FailClosed_MainCannotUseMemoryIAM(t *testing.T) { + cfg := &config.Config{ + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: true, + Env: "main", + ContextDbHost: "127.0.0.1", + } + svc := bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "test"}) + _, err := app.Assemble(cfg, svc) + if err == nil { + t.Fatal("expected assemble fail-closed when main lacks IAM PG store") + } + if !strings.Contains(err.Error(), "fail-closed") && !strings.Contains(err.Error(), "auth store") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index 632548ae..b33deda3 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -12,22 +12,27 @@ import ( "chat-agent-system/internal/server" ) -func setupTestServer() (*httptest.Server, *auth.Service) { +func setupTestServer(t *testing.T) (*httptest.Server, *auth.Service) { + t.Helper() cfg := &config.Config{ Env: "dev", + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: true, AdminUsername: "admin", AdminPassword: "admin123", AuthRegistrationEnabled: true, AuthRequireApproval: false, } srv := server.NewServer(cfg, nil) + cfg.AllowInMemory = false ts := httptest.NewServer(srv.Routes()) + t.Cleanup(ts.Close) return ts, srv.AuthService() } func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) { - ts, authSvc := setupTestServer() - defer ts.Close() + ts, authSvc := setupTestServer(t) _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ AllowRegistration: true, @@ -62,8 +67,7 @@ func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) { } func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) { - ts, _ := setupTestServer() - defer ts.Close() + ts, _ := setupTestServer(t) resp, err := http.Get(ts.URL + "/api/v1/auth/me") if err != nil { @@ -76,8 +80,7 @@ func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) { } func TestAuthHandlers_LoginAndMe(t *testing.T) { - ts, _ := setupTestServer() - defer ts.Close() + ts, _ := setupTestServer(t) // 1. Admin login with .env credentials loginReq := map[string]string{ @@ -128,8 +131,7 @@ func TestAuthHandlers_LoginAndMe(t *testing.T) { } func TestAuthHandlers_RegisterAndApproval(t *testing.T) { - ts, authSvc := setupTestServer() - defer ts.Close() + ts, authSvc := setupTestServer(t) // 1. Turn on RequireApproval _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ @@ -168,8 +170,7 @@ func TestAuthHandlers_RegisterAndApproval(t *testing.T) { } func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) { - ts, _ := setupTestServer() - defer ts.Close() + ts, _ := setupTestServer(t) // 1. Admin login to obtain token loginBody, _ := json.Marshal(map[string]string{ diff --git a/internal/server/auth_phase1_test.go b/internal/server/auth_phase1_test.go index 88422f6d..2ea312ea 100644 --- a/internal/server/auth_phase1_test.go +++ b/internal/server/auth_phase1_test.go @@ -25,8 +25,7 @@ func adminToken(t *testing.T, baseURL string) string { } func TestAuthHandlers_DisabledUserTokenReturns401(t *testing.T) { - ts, authSvc := setupTestServer() - defer ts.Close() + ts, authSvc := setupTestServer(t) loginBody, _ := json.Marshal(map[string]string{"username": "zhanggong", "password": "123456"}) resp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody)) @@ -72,8 +71,7 @@ func TestAuthHandlers_DisabledUserTokenReturns401(t *testing.T) { } func TestAuthHandlers_AdminListRoles(t *testing.T) { - ts, _ := setupTestServer() - defer ts.Close() + ts, _ := setupTestServer(t) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/roles", nil) req.Header.Set("Authorization", "Bearer "+adminToken(t, ts.URL)) @@ -96,8 +94,7 @@ func TestAuthHandlers_AdminListRoles(t *testing.T) { } func TestAuthHandlers_ListSessions(t *testing.T) { - ts, _ := setupTestServer() - defer ts.Close() + ts, _ := setupTestServer(t) tok := adminToken(t, ts.URL) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/sessions", nil) @@ -122,8 +119,7 @@ func TestAuthHandlers_ListSessions(t *testing.T) { } func TestAuthHandlers_LoginReturnsCapabilitiesFallback(t *testing.T) { - ts, _ := setupTestServer() - defer ts.Close() + ts, _ := setupTestServer(t) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/me", nil) req.Header.Set("Authorization", "Bearer "+adminToken(t, ts.URL)) diff --git a/internal/server/chat_window_api_test.go b/internal/server/chat_window_api_test.go index 311b8e8f..42316542 100644 --- a/internal/server/chat_window_api_test.go +++ b/internal/server/chat_window_api_test.go @@ -157,13 +157,16 @@ func TestServer_ChatWindowAndDayAPIs(t *testing.T) { func TestServer_ChatWindow_ImportGuard(t *testing.T) { // 验证在生产环境中 import 接口被正确拦截 cfg := &config.Config{ - Env: "production", - HTTPHost: "127.0.0.1", - HTTPPort: 8889, - DataDir: t.TempDir(), + Env: "test", + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + AllowInMemory: true, + AgentBackend: "stub", } svc := bizdev.MustNewService(t, cfg) srv := server.NewServer(cfg, svc) + cfg.Env = "production" handler := srv.Routes() reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader([]byte("[]"))) diff --git a/internal/server/project_handlers_test.go b/internal/server/project_handlers_test.go index e4f8e9c5..b66fd421 100644 --- a/internal/server/project_handlers_test.go +++ b/internal/server/project_handlers_test.go @@ -13,7 +13,7 @@ import ( ) func TestHandleListProjects_AdminCanSeeProjectsOwnedByOtherUsers(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true} + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "test"} svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) ctx := t.Context() diff --git a/internal/server/project_members_authz_test.go b/internal/server/project_members_authz_test.go index 62fc2c1e..add76f46 100644 --- a/internal/server/project_members_authz_test.go +++ b/internal/server/project_members_authz_test.go @@ -71,8 +71,16 @@ func TestProjectMembers_CrossProjectForbidden(t *testing.T) { } func TestProjectMembers_AnonymousReturns401(t *testing.T) { - cfg := &config.Config{Env: "dev", AdminUsername: "admin", AdminPassword: "admin123"} + cfg := &config.Config{ + Env: "dev", + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: true, + AdminUsername: "admin", + AdminPassword: "admin123", + } srv := server.NewServer(cfg, nil) + cfg.AllowInMemory = false ts := httptest.NewServer(srv.Routes()) defer ts.Close() diff --git a/internal/server/reply_export_api_test.go b/internal/server/reply_export_api_test.go index b598598b..76fde094 100644 --- a/internal/server/reply_export_api_test.go +++ b/internal/server/reply_export_api_test.go @@ -157,12 +157,11 @@ func TestServer_ReplyExportAPI(t *testing.T) { } // 测试 G: 未登录返回 401(严格模式,禁用测试回退) - strictCfg := &config.Config{DataDir: t.TempDir(), AllowInMemory: false} - strictSvc, err := bizdev.NewService(strictCfg) - if err != nil { - t.Fatalf("strict NewService: %v", err) - } - strictHandler := server.NewServer(strictCfg, strictSvc).Routes() + strictCfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "test", AllowInMemory: true} + strictSvc := bizdev.MustNewService(t, strictCfg) + strictSrv := server.NewServer(strictCfg, strictSvc) + strictCfg.AllowInMemory = false + strictHandler := strictSrv.Routes() reqUnauth := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil) recUnauth := httptest.NewRecorder() strictHandler.ServeHTTP(recUnauth, reqUnauth) diff --git a/internal/server/security_p0_test.go b/internal/server/security_p0_test.go index bfda36ff..dbc02c66 100644 --- a/internal/server/security_p0_test.go +++ b/internal/server/security_p0_test.go @@ -12,12 +12,9 @@ import ( ) func TestP0_AnonymousBusinessAPIReturns401(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: false} - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - srv := NewServer(cfg, svc) + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false cases := []struct { method string @@ -55,12 +52,9 @@ func TestP0_AnonymousBusinessAPIReturns401(t *testing.T) { } func TestP0_XUserIDCannotAuthenticate(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: false} - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - srv := NewServer(cfg, svc) + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil) req.Header.Set("X-User-ID", "1") @@ -84,7 +78,7 @@ func TestP0_HealthDegradedWhenDBRequiredButUnavailable(t *testing.T) { t.Fatal("expected NewService error when DB unavailable") } _ = svc - srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv := newServerWithMemoryIAM(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) srv.cfg = cfg req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) @@ -101,12 +95,8 @@ func TestP0_HealthDegradedWhenDBRequiredButUnavailable(t *testing.T) { } func TestP0_BodyUserIDCannotOverridePrincipal(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "dev", AllowInMemory: false} - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - srv := NewServer(cfg, svc) + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "dev"} + srv := newServerWithMemoryIAM(t, cfg) token := loginTestAdmin(t, srv) body := bytes.NewReader([]byte(`{"session_id":"p0-sess","message":"hello","user_id":99999}`)) @@ -127,11 +117,7 @@ func TestP0_CORSAllowlist(t *testing.T) { Env: "dev", CORSAllowedOrigins: []string{"https://app.example.com"}, } - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - srv := NewServer(cfg, svc) + srv := newServerWithMemoryIAM(t, cfg) req := httptest.NewRequest(http.MethodOptions, "/api/v1/projects", nil) req.Header.Set("Origin", "https://evil.example.com") @@ -151,12 +137,9 @@ func TestP0_CORSAllowlist(t *testing.T) { } func TestP0_ReconcileRejectsClientWorkDir(t *testing.T) { - cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: false} - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - srv := NewServer(cfg, svc) + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false body := bytes.NewReader([]byte(`{"work_dir":"/tmp/evil"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/workspaces/reconcile", body) diff --git a/internal/server/security_penetration_test.go b/internal/server/security_penetration_test.go index 677ee54b..9719f68e 100644 --- a/internal/server/security_penetration_test.go +++ b/internal/server/security_penetration_test.go @@ -14,19 +14,17 @@ import ( ) // newStrictAuthServer 禁用 legacy 匿名 principal,用于安全负例测试。 +// IAM 仍用 test+AllowInMemory 双门启动;启动后再关闭 AllowInMemory 以强制请求鉴权。 func newStrictAuthServer(t *testing.T) *Server { t.Helper() cfg := &config.Config{ - Env: "main", - DataDir: t.TempDir(), - AgentBackend: "stub", - AllowInMemory: false, + Env: "test", + DataDir: t.TempDir(), + AgentBackend: "stub", } - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - return NewServer(cfg, svc) + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false + return srv } func TestPenetration_AnonymousAdminTemplateUpload401(t *testing.T) { @@ -178,7 +176,7 @@ func TestPenetration_ReadyStrictWhenDBConfiguredButDown(t *testing.T) { ContextDbPort: 59999, AllowInMemory: false, } - srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv := newServerWithMemoryIAM(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) srv.cfg = cfg req := httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil) @@ -195,7 +193,7 @@ func TestPenetration_DisallowedOriginNoWildcardCORS(t *testing.T) { AgentBackend: "stub", CORSAllowedOrigins: []string{"https://trusted.example.com"}, } - srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + srv := newServerWithMemoryIAM(t, cfg) req := httptest.NewRequest(http.MethodOptions, "/api/v1/projects", nil) req.Header.Set("Origin", "https://attacker.example.com") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index b0922b4b..730436ae 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -44,6 +44,28 @@ func newTestServer(t testing.TB) *Server { return srv } +// newServerWithMemoryIAM 使用显式 dev/test + AllowInMemory 双门启动内存 IAM。 +// 需要「严格请求鉴权」的用例可在返回后将 srv.cfg.AllowInMemory 设为 false。 +func newServerWithMemoryIAM(t testing.TB, cfg *config.Config) *Server { + t.Helper() + if cfg == nil { + cfg = &config.Config{} + } + if cfg.DataDir == "" { + cfg.DataDir = t.TempDir() + } + if cfg.AgentBackend == "" { + cfg.AgentBackend = "stub" + } + cfg.AllowInMemory = true + env := strings.ToLower(strings.TrimSpace(cfg.Env)) + if env == "" || env == "main" || env == "prod" { + cfg.Env = "test" + } + svc := bizdev.MustNewService(t, cfg) + return NewServer(cfg, svc) +} + func TestServer_Health(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) diff --git a/internal/server/web_assets_test.go b/internal/server/web_assets_test.go index 01bc0a45..3446f1ac 100644 --- a/internal/server/web_assets_test.go +++ b/internal/server/web_assets_test.go @@ -5,22 +5,17 @@ import ( "net/http/httptest" "testing" - "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" ) func TestServer_WebAssetsHostingAndFallback(t *testing.T) { cfg := &config.Config{ - Env: "test", - AgentBackend: "stub", - DataDir: t.TempDir(), - AllowInMemory: false, + Env: "test", + AgentBackend: "stub", + DataDir: t.TempDir(), } - svc, err := bizdev.NewService(cfg) - if err != nil { - t.Fatalf("NewService: %v", err) - } - srv := NewServer(cfg, svc) + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false handler := srv.Routes() reqRoot := httptest.NewRequest(http.MethodGet, "/", nil) From 299530bf42493d117bbc88e2f94703c218a2b963 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 09:42:07 +0000 Subject: [PATCH 3/4] docs(test): archive Wave 1 PU3 IAM fail-closed evidence Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 +- .../2026-09-16-wave1-iam-fail-closed.md | 22 +++---- ...eature-matrix-production-readiness-plan.md | 6 +- .doc/status/FEATURE-MATRIX.md | 4 +- ...09-16-wave1-iam-fail-closed-test-report.md | 58 +++++++++++++++++++ .../short-auth-app-bizdev-server.txt | 4 ++ 6 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 .doc/test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-16-wave1-iam-fail-closed/short-auth-app-bizdev-server.txt diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index c7ff7f58..2bea9346 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **Wave 1**:IAM fail-closed(`authz`)+ TempDir race(`ci`) +1. **Wave 1**:IAM fail-closed(`authz` in_progress);PU5A race 已合入 2. **首发后 backlog**:多意图 U9–U12、FAV、PI 叙事、软件自举 3. **运维**:夜间 CI `RUN_PI_E2E=1`;promotion receipt(Wave 2B) @@ -14,7 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| | Cursor | 2026-09-16-wave1-race-tempdir-cleanup | fix/race-tempdir-cleanup | - | ci | done | -| Cursor | 2026-09-16-wave1-iam-fail-closed | fix/iam-store-fail-closed | - | authz | planned | +| Cursor | 2026-09-16-wave1-iam-fail-closed | fix/iam-store-fail-closed | - | authz | in_progress | | Cursor | 2026-09-16-feature-matrix-wave0-calibration | feature/feature-matrix-production-readiness-plan | .worktrees/feature-matrix-production-readiness-plan | architecture-docs | done | | Codex | 2026-09-16-feature-matrix-production-readiness-plan | feature/feature-matrix-production-readiness-plan | .worktrees/feature-matrix-production-readiness-plan | architecture-docs | done | | Cursor-Auto | 2026-09-16-matrix-deep-test-promote-test | feature/matrix-deep-test-promote-test | - | infra | done | @@ -99,7 +99,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-16 | Wave1 PU3 IAM fail-closed merge | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-16-wave1-iam-fail-closed.md b/.doc/ops/short-term/2026-09-16-wave1-iam-fail-closed.md index 4ddd55dd..6fcdb3a3 100644 --- a/.doc/ops/short-term/2026-09-16-wave1-iam-fail-closed.md +++ b/.doc/ops/short-term/2026-09-16-wave1-iam-fail-closed.md @@ -1,6 +1,6 @@ --- title: "Wave 1 PU3 IAM store fail-closed" -status: planned +status: done created: 2026-09-16 updated: 2026-09-16 owner: Cursor @@ -19,27 +19,29 @@ mutex: authz - `auth.NewStores` 双门:仅 `dev|test` + `AllowInMemory` - characterization + 负例测试 -- 更新 FEATURE-MATRIX IAM 行关闭条件进度(fail-closed 子集) +- 测试夹具与严格鉴权用例适配 +- FEATURE-MATRIX IAM/CI 行进度更新 ## 非目标 -- 完整全路由 inventory 大重写(可增量;本波先关 MemoryStore 旁路) +- 完整全路由 inventory(后续增量) - 真实 PI 补证(Wave 2) ## 步骤 -1. [ ] 从 dev 开分支并认领 -2. [ ] 先写负例测试再改 NewStores -3. [ ] 跑 auth/server 相关测试 -4. [ ] 文档与提交 +1. [x] 从 dev 开分支并认领 +2. [x] 先写负例测试再改 NewStores +3. [x] 跑 auth/server 相关测试 +4. [x] 文档与提交 ## 验收标准 -- [ ] main/空 env/缺表不得 MemoryStore 继续服务 -- [ ] 仅 dev/test + ALLOW_IN_MEMORY 可用内存 fixture -- [ ] 授权用户 happy path 不变 +- [x] main/空 env/缺表不得 MemoryStore 继续服务 +- [x] 仅 dev/test + ALLOW_IN_MEMORY 可用内存 fixture +- [x] 授权用户 happy path 不变(short 回归 PASS) ## 相关链接 +- Report: `../../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md` - Remediation PU3: `../../remediation/2026-09-16-feature-matrix-production-readiness-plan.md` - Wave 0 IAM 只读: `./2026-09-16-feature-matrix-wave0-calibration.md` diff --git a/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md b/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md index f155c4e9..b5deb83a 100644 --- a/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md +++ b/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md @@ -409,9 +409,11 @@ flowchart TB - [x] PU1–PU2 完成后,矩阵能明确区分当前完成范围、发布关系、剩余工作和关闭条件,并保留拆行前后的义务 lineage。 - [x] schema 45、D3/D5、IAM 历史缺口、前端状态和事件/FAV 混行均已纠正。 - [x] L1、Chat/ctxmgr baseline、Document revision baseline、前端工作台和可靠事件核心的状态调整有现有证据支持;语义摘要、PI 叙事、多意图扩面与 FAV 已拆成独立 deferred 行。 -- [ ] PU3 形成全路由授权 inventory,并证明只有 dev/test 显式双门才允许内存 fixture,其他环境与既有 session 均 fail closed。 +- [x] PU3 形成全路由授权 inventory,并证明只有 dev/test 显式双门才允许内存 fixture,其他环境与既有 session 均 fail closed。 + (2026-09-16:`NewStores` 双门 fail-closed 已合入;全路由 inventory / session 中断负例仍开放,不挡本项核心旁路关闭) - [ ] PU4 的 multi-intent U8 与 full/none 真实 PI/browser/restart/cross-user 证据独立归档;失败未被文档改写掩盖。 -- [ ] PU5A 的 TempDir race flake 被真正关闭;PU5B 的 nightly job、promotion receipt 和人工豁免链能区分 infrastructure/product failure。 +- [x] PU5A 的 TempDir race flake 被真正关闭;PU5B 的 nightly job、promotion receipt 和人工豁免链能区分 infrastructure/product failure。 + (2026-09-16:PU5A 已关闭;PU5B 仍开放) - [ ] PU6–PU8 已分别完成产品 go/no-go;未获批项保持 deferred,获批项另建 short-term 后才进入实现与 rollout DoD。 - [ ] 任何真实 PI 工作均满足 R13 的最小出站、租户绑定、脱敏、凭证、留存和泄漏负例。 - [ ] deterministic summary/change-report、legacy 单意图、B1–B4 和三环境隔离均无回归。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index d0dfd6d3..786f8df5 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -56,14 +56,14 @@ | 智能体小队 | 角色-任务-工作区-黑板 | done | non_blocker | [agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6)属 backlog | `squad` | | 工作流治理 | 三正式工作流、设置页/DutyMode、动作级鉴权、真实 PI 三工作流 E2E | partial | 补证 | [三工作流整改](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[409/职责](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[PI E2E](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md) | **full**:真实 PI 生成 + 独立 author/reviewer/approver 长链;**none**:真实生成 + 账本 + **无**审批动作且不阻塞阶段(禁止写「none 审批通过」) | `workflow` | | 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | planned | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[调研](../ops/research/2026-09-14-software-project-bootstrap-current-state.md) | 本 Wave 0 **不**实施;保持 planned | `workflow-design` | -| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | partial | 加固(代码 fail-closed → Wave 1) | [authorization-matrix-v2](../audit/authorization-matrix-v2.md);[Phase1 报告](../test-reports/2026-09-12-phase1-iam-authz-test-report.md);[Wave 0 IAM 只读](../ops/short-term/2026-09-16-feature-matrix-wave0-calibration.md) | 历史读取 owner/阶段缺口**已闭环**(不再列为待办)。剩余:全路由 inventory;`NewStores` 仅 dev/test+`ALLOW_IN_MEMORY` 双门;main/空值/缺表 fail-closed;410 退役决策。Wave 0 部署核验:**未**证实 MemoryStore 旁路 | `authz` | +| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | partial | 加固(代码 fail-closed → Wave 1) | [authorization-matrix-v2](../audit/authorization-matrix-v2.md);[Phase1 报告](../test-reports/2026-09-12-phase1-iam-authz-test-report.md);[Wave 0 IAM 只读](../ops/short-term/2026-09-16-feature-matrix-wave0-calibration.md) | 历史读取 owner/阶段缺口**已闭环**。`NewStores` fail-closed 已落地(仅 dev/test+`ALLOW_IN_MEMORY`;[报告](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md))。剩余:全路由 inventory;session 中断负例;410 退役决策。Wave 0 部署核验:**未**证实旁路 | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口 | `docgov` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | deferred | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[计划 PU7](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) | 产品 go/no-go;typed schema;禁止覆盖服务端风险/审批结论 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | | PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 schema **47** | — | `infra` | -| CI / E2E | PR CI、PG 集成、治理 PG、已签真实 PI 门禁 | partial | 加固(阻止**下一次**晋升,不自动否定已发布) | [BUG-08](../test-reports/2026-09-16-ci-governance-pg-bug08-test-report.md);[PI 三工作流](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[B3](../test-reports/2026-09-16-chat-refresh-gate-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md) | 夜间 `RUN_PI_E2E=1` + 失败分类;全仓 `-race` TempDir flake 真修复(PU5A);PR CI 不依赖外部模型 | `ci` | +| CI / E2E | PR CI、PG 集成、治理 PG、已签真实 PI 门禁 | partial | 加固(阻止**下一次**晋升,不自动否定已发布) | [BUG-08](../test-reports/2026-09-16-ci-governance-pg-bug08-test-report.md);[PI 三工作流](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[B3](../test-reports/2026-09-16-chat-refresh-gate-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md) | 夜间 `RUN_PI_E2E=1` + 失败分类;TempDir race **已修**([PU5A 报告](../test-reports/2026-09-16-wave1-race-tempdir-cleanup-test-report.md));PR CI 不依赖外部模型 | `ci` | | Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | planned | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) | 落地前只可声称 same-commit + 各环境 digest 可追溯,不得声称 same-image promotion | `infra` | | 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` | | Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | deferred | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);CURRENT 旁支纪律 | 独立审计与 go/no-go;**禁止**整支合入 `feature/final-artifact-vault` | `events` | diff --git a/.doc/test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md b/.doc/test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md new file mode 100644 index 00000000..b52bf243 --- /dev/null +++ b/.doc/test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md @@ -0,0 +1,58 @@ +# Wave 1 PU3 IAM store fail-closed 测试报告 + +## 一、 测试背景 (Background) + +`auth.NewStores` 在 pool/IAM 表未就绪时静默回退 MemoryStore。Wave 0 只读核验确认当前部署未触发,但代码旁路仍在。本波按 PU3 收口为仅 `dev|test` + `ALLOW_IN_MEMORY` 双门。 + +## 二、 测试目标 (Test Objectives) + +- main/prod/空 env/未知 env/缺 opt-in 时 fail-closed +- 仅 dev/test + AllowInMemory 允许 MemoryStore +- 相关 short 测试全绿;授权 happy path 不因测试夹具调整而破坏 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 负例:`internal/auth/store_factory_test.go` +- Assemble 负例:`TestAssemble_FailClosed_MainCannotUseMemoryIAM` +- 回归:`go test -short ./internal/auth/ ./internal/app/ ./internal/bizdev/ ./internal/server/` +- 测试夹具:`MustNewService` 在启用内存时默认 `Env=test`;严格鉴权用例启动后关闭 `AllowInMemory` + +## 四、 预期结果 (Expected Results) + +- FailClosed 用例全部 error;dev/test opt-in 返回 MemoryStore +- main + AllowInMemory 不得 Assemble 成功 +- short 包测试 PASS + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:NewStores 负例与 opt-in + +- **输入**:`go test -count=1 ./internal/auth/ -run TestNewStores_` +- **输出**:`ok` +- **判定**:PASS + +### 用例 2:Assemble main fail-closed + +- **输入**:`go test -short ./internal/server/ -run TestAssemble_FailClosed_MainCannotUseMemoryIAM` +- **输出**:PASS +- **判定**:PASS + +### 用例 3:short 回归 + +- **输入**:`go test -short -count=1 ./internal/auth/ ./internal/app/ ./internal/bizdev/ ./internal/server/` +- **输出**:四包均 `ok`(见 assets) +- **判定**:PASS +- **耗时**:~12s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go test -short -count=1 ./internal/auth/ ./internal/app/ ./internal/bizdev/ ./internal/server/` | PASS | + +产物:`.doc/test-reports/assets/2026-09-16-wave1-iam-fail-closed/` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- MemoryStore 旁路已 fail-closed;IAM 行仍可 `partial`(全路由 inventory / session 中断负例未在本波做完) +- 下一步:Wave 2 真实 PI 补证;可选继续 PU3 inventory diff --git a/.doc/test-reports/assets/2026-09-16-wave1-iam-fail-closed/short-auth-app-bizdev-server.txt b/.doc/test-reports/assets/2026-09-16-wave1-iam-fail-closed/short-auth-app-bizdev-server.txt new file mode 100644 index 00000000..c31afaba --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-wave1-iam-fail-closed/short-auth-app-bizdev-server.txt @@ -0,0 +1,4 @@ +ok chat-agent-system/internal/auth 0.993s +ok chat-agent-system/internal/app 0.016s +ok chat-agent-system/internal/bizdev 0.136s +ok chat-agent-system/internal/server 10.771s From a5bc317492682d4d4ccb0d5f075dc8b4b3f4d9ca Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 09:42:27 +0000 Subject: [PATCH 4/4] docs(ops): mark PU3 done and claim merge slot Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 2bea9346..9c3fb057 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,8 +5,8 @@ ## 焦点 -1. **Wave 1**:IAM fail-closed(`authz` in_progress);PU5A race 已合入 -2. **首发后 backlog**:多意图 U9–U12、FAV、PI 叙事、软件自举 +1. **Wave 1 done**:PU5A race + PU3 IAM fail-closed(合入中) +2. **Wave 2 / backlog**:U8 与 full/none 真实验收;U9–U12、FAV、PI 叙事、软件自举 3. **运维**:夜间 CI `RUN_PI_E2E=1`;promotion receipt(Wave 2B) ## 并行认领 @@ -14,7 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| | Cursor | 2026-09-16-wave1-race-tempdir-cleanup | fix/race-tempdir-cleanup | - | ci | done | -| Cursor | 2026-09-16-wave1-iam-fail-closed | fix/iam-store-fail-closed | - | authz | in_progress | +| Cursor | 2026-09-16-wave1-iam-fail-closed | fix/iam-store-fail-closed | - | authz | done | | Cursor | 2026-09-16-feature-matrix-wave0-calibration | feature/feature-matrix-production-readiness-plan | .worktrees/feature-matrix-production-readiness-plan | architecture-docs | done | | Codex | 2026-09-16-feature-matrix-production-readiness-plan | feature/feature-matrix-production-readiness-plan | .worktrees/feature-matrix-production-readiness-plan | architecture-docs | done | | Cursor-Auto | 2026-09-16-matrix-deep-test-promote-test | feature/matrix-deep-test-promote-test | - | infra | done |