merge: fix/iam-store-fail-closed

Wave 1 PU3: IAM NewStores fail-closed unless explicit dev/test opt-in.
This commit is contained in:
weichen
2026-09-16 09:42:27 +00:00
20 changed files with 288 additions and 106 deletions
+4 -4
View File
@@ -5,8 +5,8 @@
## 焦点
1. **Wave 1**:IAM fail-closed(`authz`)+ TempDir race(`ci`)
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 | planned |
| 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 |
@@ -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`
@@ -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`
@@ -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 和三环境隔离均无回归。
+2 -2
View File
@@ -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` |
@@ -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
@@ -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
+27 -2
View File
@@ -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)
}
+62
View File
@@ -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)
}
})
}
}
+3
View File
@@ -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)
+25 -4
View File
@@ -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)
}
}
+12 -11
View File
@@ -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{
+4 -8
View File
@@ -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))
+7 -4
View File
@@ -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("[]")))
+1 -1
View File
@@ -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()
@@ -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()
+5 -6
View File
@@ -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)
+13 -30
View File
@@ -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)
+9 -11
View File
@@ -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")
+22
View File
@@ -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)
+5 -10
View File
@@ -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)