# Auth 模块测试计划

## 测试矩阵

| 模式 | 启动命令 | Auth 层 | 端口 |
|------|---------|---------|------|
| 标准模式 | `make dev` | Gateway AuthMiddleware（全量） | 2026 (nginx) |
| 直连 Gateway | `cd backend && make gateway` | Gateway AuthMiddleware | 8001 |
| 直连 LangGraph 兼容性 | 手动运行 LangGraph 工具链时使用 | LangGraph auth | 2024 |

`make dev`、Docker dev 和生产部署默认都运行 Gateway embedded runtime。
`app.gateway.langgraph_auth` 仅用于保留的直连 LangGraph 工具链 / Studio 兼容性测试，不是标准服务启动路径。

每种模式下都需执行以下测试。

---

## 一、环境准备

### 1.1 首次启动（干净数据库）

```bash
# 清除已有数据
rm -f backend/.deer-flow/data/deerflow.db

# 启动标准模式（Gateway embedded runtime）
make dev
```

**验证点：**
- [ ] 控制台不输出 admin 邮箱或明文密码
- [ ] 控制台提示 `First boot detected — no admin account exists.`
- [ ] 控制台提示访问 `/setup` 完成 admin 创建
- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": true}`
- [ ] 前端访问 `/login` 会跳转 `/setup`

### 1.2 非首次启动

```bash
# 不清除数据库，直接启动
make dev
```

**验证点：**
- [ ] 控制台不输出密码
- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": false}`
- [ ] 已登录用户如果 `needs_setup=True`，访问 workspace 会被引导到 `/setup` 完成改邮箱 / 改密码流程

### 1.3 环境变量配置

| 变量 | 验证 |
|------|------|
| `AUTH_JWT_SECRET` 未设 | 启动时 warning，自动生成临时密钥 |
| `AUTH_JWT_SECRET` 已设 | 无 warning，重启后 session 保持 |

---

## 二、接口流程测试

> 以下用 `BASE=http://localhost:2026` 为例。标准模式经 nginx 暴露此地址。
> 直连测试替换为对应端口。
>
> **CSRF token 提取**：多处用到从 cookie jar 提取 CSRF token，统一使用：
> ```bash
> CSRF=$(python3 -c "
> import http.cookiejar
> cj = http.cookiejar.MozillaCookieJar('cookies.txt'); cj.load()
> print(next(c.value for c in cj if c.name == 'csrf_token'))
> ")
> ```
> 或简写（多数场景够用）：`CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')`

### 2.1 注册 + 登录 + 会话

#### TC-API-01: Setup 状态查询

```bash
curl -s $BASE/api/v1/auth/setup-status | jq .
```

**预期：**
- 干净数据库且尚未初始化 admin：返回 `{"needs_setup": true}`
- 已存在 admin：返回 `{"needs_setup": false}`

#### TC-API-02: 首次初始化 Admin

```bash
curl -s -X POST $BASE/api/v1/auth/initialize \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"AdminPass1!"}' \
  -c cookies.txt | jq .
```

**预期：**
- 状态码 201
- Body: `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}`
- `cookies.txt` 包含 `access_token`（HttpOnly）和 `csrf_token`（非 HttpOnly）

#### TC-API-03: 获取当前用户

```bash
curl -s $BASE/api/v1/auth/me -b cookies.txt | jq .
```

**预期：** `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}`

#### TC-API-04: 改密码流程

```bash
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"current_password":"AdminPass1!","new_password":"NewPass123!"}' | jq .
```

**预期：**
- 状态码 200
- `{"message": "Password changed successfully"}`
- 再调 `/auth/me` 仍为 `admin@example.com`，`needs_setup` 仍为 `false`

#### TC-API-04a: reset_admin 后的 Setup 流程（改邮箱 + 改密码）

```bash
cd backend
python -m app.gateway.auth.reset_admin --email admin@example.com
# 从 .deer-flow/admin_initial_credentials.txt 读取 reset 后密码

curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=<凭据文件密码>" \
  -c cookies.txt | jq .

CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"current_password":"<凭据文件密码>","new_password":"AdminPass2!","new_email":"admin2@example.com"}' | jq .
```

**预期：**
- 登录返回 `{"expires_in": 604800, "needs_setup": true}`
- `change-password` 后 `/auth/me` 邮箱变为 `admin2@example.com`，`needs_setup` 变为 `false`

#### TC-API-05: 普通用户注册

```bash
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user1@example.com","password":"UserPass1!"}' \
  -c user_cookies.txt | jq .
```

**预期：** 状态码 201，`system_role` 为 `"user"`，自动登录（cookie 已设）

#### TC-API-06: 登出

```bash
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt | jq .
```

**预期：** `{"message": "Successfully logged out"}`，后续用 cookies.txt 访问 `/auth/me` 返回 401

### 2.2 多租户隔离

#### TC-API-07: 用户 A 创建 Thread

```bash
# 以 user1 登录
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=user1@example.com&password=UserPass1!" \
  -c user1.txt

CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}')

# 创建 thread
curl -s -X POST $BASE/api/threads \
  -b user1.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF1" \
  -d '{"metadata":{}}' | jq .thread_id
# 记录 THREAD_ID
```

#### TC-API-08: 用户 B 无法访问用户 A 的 Thread

```bash
# 注册并登录 user2
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user2@example.com","password":"UserPass2!"}' \
  -c user2.txt

# 尝试访问 user1 的 thread
curl -s $BASE/api/threads/$THREAD_ID -b user2.txt
```

**预期：** 状态码 404（不是 403，避免泄露 thread 存在性）

#### TC-API-09: 用户 B 搜索 Thread 看不到用户 A 的

```bash
CSRF2=$(grep csrf_token user2.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/threads/search \
  -b user2.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF2" \
  -d '{}' | jq length
```

**预期：** 返回 0 或仅包含 user2 自己的 thread

### 2.3 LangGraph-compatible Gateway 路由隔离

#### TC-API-10: LangGraph-compatible 端点需要 cookie

```bash
# 不带 cookie 访问 LangGraph-compatible 接口
curl -s -w "%{http_code}" $BASE/api/langgraph/threads
```

**预期：** 401

#### TC-API-11: LangGraph-compatible 路由带 cookie 可访问

```bash
curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
```

**预期：** 200，返回 user1 的 thread 列表

#### TC-API-12: LangGraph-compatible 路由隔离 — 用户只看到自己的

```bash
# user2 查 threads
curl -s $BASE/api/langgraph/threads -b user2.txt | jq length
```

**预期：** 不包含 user1 的 thread

### 2.4 Token 失效

#### TC-API-13: 改密码后旧 token 立即失效

```bash
# 保存当前 cookie
cp user1.txt user1_old.txt

# 改密码
CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b user1.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF1" \
  -d '{"current_password":"UserPass1!","new_password":"NewUserPass1!"}' \
  -c user1.txt

# 用旧 cookie 访问
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b user1_old.txt
```

**预期：** 401（token_version 不匹配）

#### TC-API-14: 改密码后新 cookie 可用

```bash
curl -s $BASE/api/v1/auth/me -b user1.txt | jq .email
```

**预期：** 200，返回用户信息

### 2.5 错误响应格式

#### TC-API-15: 结构化错误响应

```bash
# 错误密码登录
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=wrong" | jq .detail
```

**预期：**
```json
{"code": "invalid_credentials", "message": "Incorrect email or password"}
```

#### TC-API-16: 重复邮箱注册

```bash
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user1@example.com","password":"AnyPass123"}' -w "\n%{http_code}"
```

**预期：** 400，`{"code": "email_already_exists", ...}`

---

## 三、攻击测试

### 3.1 暴力破解防护

#### TC-ATK-01: IP 限速

```bash
# 连续 6 次错误密码
for i in $(seq 1 6); do
  echo "Attempt $i:"
  curl -s -X POST $BASE/api/v1/auth/login/local \
    -d "username=admin@example.com&password=wrong$i" -w " HTTP %{http_code}\n"
done
```

**预期：** 前 5 次返回 401，第 6 次返回 429 `"Too many login attempts. Try again later."`

#### TC-ATK-02: 限速后正确密码也被拒

```bash
# 紧接上一步
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -w " HTTP %{http_code}\n"
```

**预期：** 429（锁定 5 分钟）

#### TC-ATK-03: 成功登录清除限速

```bash
# 等待锁定过期后（或重启服务），用正确密码登录
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -w " HTTP %{http_code}\n"
```

**预期：** 200，计数器重置

### 3.2 CSRF 防护

#### TC-ATK-04: 无 CSRF token 的 POST 请求

```bash
curl -s -X POST $BASE/api/threads \
  -b user1.txt \
  -H "Content-Type: application/json" \
  -d '{"metadata":{}}' -w "\nHTTP %{http_code}"
```

**预期：** 403 `"CSRF token missing"`

#### TC-ATK-05: 错误 CSRF token

```bash
curl -s -X POST $BASE/api/threads \
  -b user1.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: fake-token" \
  -d '{"metadata":{}}' -w "\nHTTP %{http_code}"
```

**预期：** 403 `"CSRF token mismatch"`

### 3.3 Cookie 安全

> HTTP 与 HTTPS 行为差异通过 `X-Forwarded-Proto: https` 模拟。
> **注意：** 经 nginx 代理时，nginx 的 `proxy_set_header X-Forwarded-Proto $scheme` 会覆盖
> 客户端发的值（`$scheme` = nginx 监听端口的 scheme），因此 HTTPS 模拟必须**直连 Gateway（端口 8001）**。
> 每个 case 需在 **login** 和 **register** 两个端点各验证一次。

#### TC-ATK-06: HTTP 模式 Cookie 属性

```bash
# 登录
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie
```

**预期：**
- `access_token`: `HttpOnly; Path=/; SameSite=lax`，无 `Secure`，无 `Max-Age`
- `csrf_token`: `Path=/; SameSite=strict`，无 `HttpOnly`（JS 需要读取），无 `Secure`

```bash
# 注册
curl -s -D - -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"cookie-http@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie
```

**预期：** 同上

#### TC-ATK-07: HTTPS 模式 Cookie 属性

> **必须直连 Gateway**（`GW=http://localhost:8001`），经 nginx 会被 `$scheme` 覆盖。

```bash
GW=http://localhost:8001

# 登录（模拟 HTTPS）
curl -s -D - -X POST $GW/api/v1/auth/login/local \
  -H "X-Forwarded-Proto: https" \
  -d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie
```

**预期：**
- `access_token`: `HttpOnly; Secure; Path=/; SameSite=lax; Max-Age=604800`
- `csrf_token`: `Secure; Path=/; SameSite=strict`，无 `HttpOnly`

```bash
# 注册（模拟 HTTPS）
curl -s -D - -X POST $GW/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -H "X-Forwarded-Proto: https" \
  -d '{"email":"cookie-https@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie
```

**预期：** 同上

#### TC-ATK-07a: HTTP/HTTPS 差异对比

> 直连 Gateway 执行，避免 nginx 覆盖 `X-Forwarded-Proto`。

```bash
GW=http://localhost:8001

for proto in "" "https"; do
  HEADER=""
  LABEL="HTTP"
  if [ -n "$proto" ]; then
    HEADER="-H X-Forwarded-Proto:$proto"
    LABEL="HTTPS"
  fi
  echo "=== $LABEL ==="
  EMAIL="compare-${LABEL,,}-$(date +%s)@example.com"
  curl -s -D - -X POST $GW/api/v1/auth/register \
    -H "Content-Type: application/json" $HEADER \
    -d "{\"email\":\"$EMAIL\",\"password\":\"Compare1!\"}" 2>/dev/null | grep -i set-cookie | while read line; do
    if echo "$line" | grep -q "access_token="; then
      echo "  access_token:"
      echo "    HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)"
      echo "    Secure:   $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)"
      echo "    Max-Age:  $(echo "$line" | grep -oi "max-age=[0-9]*" || echo NONE)"
      echo "    SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")"
    fi
    if echo "$line" | grep -q "csrf_token="; then
      echo "  csrf_token:"
      echo "    HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)"
      echo "    Secure:   $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)"
      echo "    SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")"
    fi
  done
done
```

**预期对比表：**

| 属性 | HTTP access_token | HTTPS access_token | HTTP csrf_token | HTTPS csrf_token |
|------|------|------|------|------|
| HttpOnly | Yes | Yes | No | No |
| Secure | No | **Yes** | No | **Yes** |
| SameSite | Lax | Lax | Strict | Strict |
| Max-Age | 无（session cookie） | **604800**（7天） | 无 | 无 |

### 3.4 越权访问

#### TC-ATK-08: 无 cookie 访问受保护接口

```bash
for path in /api/models /api/mcp/config /api/memory /api/skills \
            /api/agents /api/channels; do
  echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)"
done
```

**预期：** 全部 401

#### TC-ATK-09: 伪造 JWT

```bash
# 用不同 secret 签名的 token
FAKE_TOKEN=$(python3 -c "
import jwt
print(jwt.encode({'sub':'admin-id','ver':0,'exp':9999999999}, 'wrong-secret', algorithm='HS256'))
")

curl -s -w "%{http_code}" $BASE/api/v1/auth/me \
  --cookie "access_token=$FAKE_TOKEN"
```

**预期：** 401（签名验证失败）

#### TC-ATK-10: 过期 JWT

```bash
# 不依赖环境变量，直接用一个已过期的、随机 secret 签名的 token
# 无论 secret 是否匹配，过期 token 都会被拒绝
EXPIRED_TOKEN=$(python3 -c "
import jwt, time
print(jwt.encode({'sub':'x','ver':0,'exp':int(time.time())-100}, 'any-secret-32chars-placeholder!!', algorithm='HS256'))
")

curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me \
  --cookie "access_token=$EXPIRED_TOKEN"
```

**预期：** 401（过期 or 签名不匹配，均被拒绝）

### 3.5 密码安全

#### TC-ATK-11: 密码长度不足

```bash
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"short@example.com","password":"1234567"}' -w "\nHTTP %{http_code}"
```

**预期：** 422（Pydantic validation: min_length=8）

#### TC-ATK-12: 密码不以明文存储

```bash
# 检查数据库
sqlite3 backend/.deer-flow/data/deerflow.db "SELECT email, password_hash FROM users LIMIT 3;"
```

**预期：** `password_hash` 以 `$2b$` 开头（bcrypt 格式）

---

## 四、UI 操作测试

> 浏览器中操作，验证前后端联动。

### 4.1 首次登录流程

#### TC-UI-01: 无 admin 时访问 workspace 跳转 setup

1. 打开 `http://localhost:2026/workspace`
2. **预期：** 自动跳转到 `/setup`

#### TC-UI-02: Setup 页面创建 admin

1. 输入 admin 邮箱、密码、确认密码
2. 点击 Create Admin Account
3. **预期：** 跳转到 `/workspace`
4. 刷新页面不跳回 `/setup`

#### TC-UI-03: 已初始化后 Login 页面

1. 退出登录后访问 `/login`
2. 输入 admin 邮箱和密码
3. 点击 Login
4. **预期：** 跳转到 `/workspace`

#### TC-UI-04: Setup 密码不匹配

1. 新密码和确认密码不一致
2. 点击 Complete Setup
3. **预期：** 显示 "Passwords do not match" 错误

### 4.2 日常使用

#### TC-UI-05: 创建对话

1. 在 workspace 发送一条消息
2. **预期：** 左侧栏出现新 thread

#### TC-UI-06: 对话持久化

1. 创建对话后刷新页面
2. **预期：** 对话列表和内容仍然存在

#### TC-UI-07: 登出

1. 点击头像 → Logout
2. **预期：** 跳转到首页 `/`
3. 直接访问 `/workspace` → 跳转到 `/login`

### 4.3 多用户隔离

#### TC-UI-08: 用户 A 看不到用户 B 的对话

1. 用户 A 在浏览器 1 登录，创建一个对话并发消息
2. 用户 B 在浏览器 2（或隐身窗口）注册并登录
3. **预期：** 用户 B 的 workspace 左侧栏为空，看不到用户 A 的对话

#### TC-UI-09: 直接 URL 访问他人 Thread

1. 复制用户 A 的 thread URL
2. 在用户 B 的浏览器中访问
3. **预期：** 404 或空白页，不显示对话内容

### 4.4 Session 管理

#### TC-UI-10: Tab 切换 Session 检查

1. 登录 workspace
2. 切换到其他 tab 等待 60+ 秒
3. 切回 workspace tab
4. **预期：** 静默检查 session，页面正常（控制台无 401 刷屏）

#### TC-UI-11: Session 过期后 Tab 切回

1. 登录 workspace
2. 在另一个 tab 改密码（使当前 session 失效）
3. 切回 workspace tab
4. **预期：** 自动跳转到 `/login`

#### TC-UI-12: 改密码后 Settings 页面

1. 进入 Settings → Account
2. 修改密码
3. **预期：** 成功提示，页面不需要重新登录（cookie 已自动更新）

### 4.5 注册流程

#### TC-UI-13: 从登录页跳转注册

1. 在 `/login` 页面点击注册链接
2. 输入邮箱和密码
3. **预期：** 注册成功后自动跳转 `/workspace`

#### TC-UI-14: 重复邮箱注册

1. 用已注册的邮箱尝试注册
2. **预期：** 显示 "Email already registered" 错误

### 4.6 密码重置（CLI）

#### TC-UI-15: reset_admin 后重新登录

1. 执行 `cd backend && python -m app.gateway.auth.reset_admin`
2. 从 `.deer-flow/admin_initial_credentials.txt` 读取新密码并登录
3. **预期：** 跳转到 `/setup` 页面（`needs_setup` 被重置为 true）
4. 旧 session 已失效

---

## 五、升级测试

> 模拟从无 auth 版本（main 分支）升级到 auth 版本（feat/rfc-001-auth-module）。

### 5.1 准备旧版数据

```bash
# 1. 切到 main 分支，启动服务
git stash && git checkout main
make dev

# 2. 创建一些对话数据（无 auth，直接访问）
curl -s -X POST http://localhost:2026/api/langgraph/threads \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"title":"old-thread-1"}}' | jq .thread_id

curl -s -X POST http://localhost:2026/api/langgraph/threads \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"title":"old-thread-2"}}' | jq .thread_id

# 3. 记录 thread 数量
curl -s http://localhost:2026/api/langgraph/threads | jq length
# 预期: 2+

# 4. 停止服务
make stop
```

### 5.2 升级并启动

```bash
# 5. 切到 auth 分支
git checkout feat/rfc-001-auth-module && git stash pop
make install
make dev
```

#### TC-UPG-01: 首次启动等待 admin 初始化

**预期：**
- [ ] 控制台不输出 admin 邮箱或随机密码
- [ ] 访问 `/setup` 可创建第一个 admin
- [ ] 无报错，正常启动

#### TC-UPG-02: 旧 Thread 迁移到 admin

```bash
# 创建第一个 admin
curl -s -X POST http://localhost:2026/api/v1/auth/initialize \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"AdminPass1!"}' \
  -c cookies.txt

# 重启一次：启动迁移只在已有 admin 的启动路径执行
make stop && make dev

# 登录 admin
curl -s -X POST http://localhost:2026/api/v1/auth/login/local \
  -d "username=admin@example.com&password=AdminPass1!" \
  -c cookies.txt

# 查看 thread 列表
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST http://localhost:2026/api/threads/search \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{}' | jq length
```

**预期：**
- [ ] 返回的 thread 数量 ≥ 旧版创建的数量
- [ ] 控制台日志有 `Migrated N orphan LangGraph thread(s) to admin`
- [ ] 旧 thread 只对 admin 可见

#### TC-UPG-03: 旧 Thread 内容完整

```bash
# 检查某个旧 thread 的内容
curl -s http://localhost:2026/api/threads/<old-thread-id> \
  -b cookies.txt | jq .metadata
```

**预期：**
- [ ] `metadata.title` 保留原值（如 `old-thread-1`）
- [ ] 响应不回显服务端保留的 `user_id` / `owner_id`

#### TC-UPG-04: 新用户看不到旧 Thread

```bash
# 注册新用户
curl -s -X POST http://localhost:2026/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"newuser@example.com","password":"NewPass123!"}' \
  -c newuser.txt

CSRF2=$(grep csrf_token newuser.txt | awk '{print $NF}')
curl -s -X POST http://localhost:2026/api/threads/search \
  -b newuser.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF2" \
  -d '{}' | jq length
```

**预期：** 返回 0（旧 thread 属于 admin，新用户不可见）

### 5.3 数据库 Schema 兼容

#### TC-UPG-05: 无 deerflow.db 时创建 schema 但不创建默认用户

```bash
ls -la backend/.deer-flow/data/deerflow.db
sqlite3 backend/.deer-flow/data/deerflow.db "SELECT COUNT(*) FROM users;"
```

**预期：** 文件存在，`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列；未调用 `/initialize` 前用户数为 0

#### TC-UPG-06: deerflow.db WAL 模式

```bash
sqlite3 backend/.deer-flow/data/deerflow.db "PRAGMA journal_mode;"
```

**预期：** 返回 `wal`

### 5.4 配置兼容

#### TC-UPG-07: 无 AUTH_JWT_SECRET 的旧 .env 文件

```bash
# 确认 .env 中没有 AUTH_JWT_SECRET
grep AUTH_JWT_SECRET backend/.env || echo "NOT SET"
```

**预期：**
- [ ] 启动时 warning：`AUTH_JWT_SECRET is not set — using auto-generated ephemeral secret`
- [ ] 服务正常可用
- [ ] 重启后旧 session 失效（临时密钥变了）

#### TC-UPG-08: 旧 config.yaml 无 auth 相关配置

```bash
# 检查 config.yaml 没有 auth 段
grep -c "auth" config.yaml || echo "0"
```

**预期：** auth 模块不依赖 config.yaml（配置走环境变量），旧 config.yaml 不影响启动

### 5.5 前端兼容

#### TC-UPG-09: 旧前端缓存

1. 用旧版前端的浏览器缓存访问升级后的服务
2. **预期：** 被 AuthMiddleware 拦截返回 401（旧前端无 cookie），页面自然刷新后加载新前端

#### TC-UPG-10: 书签 URL

1. 用升级前保存的 workspace URL（如 `localhost:2026/workspace/chats/xxx`）直接访问
2. **预期：** 跳转到 `/login`，登录后跳回原 URL（`?next=` 参数）

### 5.6 降级回滚

#### TC-UPG-11: 回退到 main 分支

```bash
make stop
git checkout main
make dev
```

**预期：**
- [ ] 服务正常启动（忽略 `deerflow.db`，无 auth 相关代码不报错）
- [ ] 旧对话数据仍然可访问
- [ ] `deerflow.db` 文件残留但不影响运行

#### TC-UPG-12: 再次升级到 auth 分支

```bash
make stop
git checkout feat/rfc-001-auth-module
make dev
```

**预期：**
- [ ] 识别已有 `deerflow.db`，不重新创建 admin
- [ ] 旧的 admin 账号仍可登录（如果回退期间未删 `deerflow.db`）

### 5.7 Admin 初始化与 reset_admin

> 首次启动不生成默认 admin，也不在日志输出密码。忘记密码时走 `reset_admin`，新密码写入 0600 凭据文件。

#### TC-UPG-13: 未初始化 admin 时重启不创建默认账号

```bash
rm -f backend/.deer-flow/data/deerflow.db
make dev
make stop

make dev
curl -s $BASE/api/v1/auth/setup-status | jq .
```

**预期：**
- [ ] 控制台不输出密码
- [ ] `setup-status` 仍为 `{"needs_setup": true}`
- [ ] 访问 `/setup` 仍可创建第一个 admin

#### TC-UPG-14: 密码丢失 — reset_admin 写入凭据文件

```bash
python -m app.gateway.auth.reset_admin --email admin@example.com
ls -la backend/.deer-flow/admin_initial_credentials.txt
cat backend/.deer-flow/admin_initial_credentials.txt
```

**预期：**
- [ ] 命令行只输出凭据文件路径，不输出明文密码
- [ ] 凭据文件权限为 `0600`
- [ ] 凭据文件包含 email + password 行
- [ ] 该用户下次登录返回 `needs_setup=true`

#### TC-UPG-15: 未初始化 admin 期间普通用户注册策略边界

```bash
# admin 尚不存在，普通用户尝试注册
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \
  -c early.txt -w "\nHTTP %{http_code}"
```

**预期：**
- [ ] 当前代码允许注册普通用户并自动登录（201，角色为 `user`）
- [ ] 但 `setup-status` 仍为 `{"needs_setup": true}`，因为 admin 仍不存在
- [ ] 这是一个产品策略边界：若要求“必须先有 admin”，需要在 `/register` 增加 admin-exists gate

#### TC-UPG-16: 普通用户数据与后续 admin 隔离

```bash
# 普通用户正常创建 thread、发消息
CSRF=$(grep csrf_token early.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/threads \
  -b early.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"metadata":{}}' | jq .thread_id
```

**预期：** 普通用户正常创建 thread；后续 admin 创建后，搜索不到该普通用户 thread

#### TC-UPG-17: reset_admin 后完成 Setup

```bash
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=<凭据文件密码>" \
  -c admin.txt | jq .needs_setup
# 预期: true

# 完成 setup
CSRF=$(grep csrf_token admin.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b admin.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"current_password":"<凭据文件密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \
  -c admin.txt

# 验证
curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}'
```

**预期：**
- [ ] `email` 变为 `admin@real.com`
- [ ] `needs_setup` 变为 `false`
- [ ] 后续登录使用新密码

#### TC-UPG-18: 长期未用后 JWT 密钥轮换

```bash
# 场景：admin 未登录期间，运维更换了 AUTH_JWT_SECRET
# 1. 首次启动用自动生成的临时密钥
# 2. 某天运维在 .env 设置了固定密钥
echo "AUTH_JWT_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" >> .env
make stop && make dev
```

**预期：**
- [ ] 服务正常启动
- [ ] 账号密码仍可登录（密码存在 DB，与 JWT 密钥无关）
- [ ] 旧的 JWT token 失效（密钥变了签名不匹配）

---

## 六、可重入测试

> 验证 auth 模块在重复操作、并发、中断恢复等场景下行为正确，无竞态条件。

### 6.1 启动可重入

#### TC-REENT-01: 连续重启不重复创建 admin

```bash
# 连续启动 3 次（daemon 模式，避免前台阻塞）
for i in 1 2 3; do
  make dev-daemon && sleep 10 && make stop
done

# 检查 admin 数量
sqlite3 backend/.deer-flow/data/deerflow.db \
  "SELECT COUNT(*) FROM users WHERE system_role='admin';"
```

**预期：** 始终为 1。不会因重启创建多个 admin。

#### TC-REENT-02: 多进程同时启动

```bash
# 模拟两个 gateway 进程同时启动（竞争 admin 创建）
cd backend
PYTHONPATH=. uv run python -c "
import asyncio
from app.gateway.app import create_app, _ensure_admin_user

async def boot():
    app = create_app()
    # 模拟两个并发 ensure_admin
    await asyncio.gather(
        _ensure_admin_user(app),
        _ensure_admin_user(app),
    )

asyncio.run(boot())
" 2>&1 | grep -i "admin\|error\|duplicate"
```

**预期：**
- [ ] 不报错（SQLite UNIQUE 约束捕获竞争，第二个静默跳过）
- [ ] 最终只有 1 个 admin

#### TC-REENT-03: Thread 迁移幂等

```bash
# 连续调用 _migrate_orphaned_threads 两次
# 第二次应无 thread 需要迁移（已有 user_id）
```

**预期：** 第二次 `migrated = 0`，无副作用

### 6.2 登录可重入

#### TC-REENT-04: 重复登录获取新 cookie

```bash
# 同一用户连续登录 3 次
for i in 1 2 3; do
  curl -s -X POST $BASE/api/v1/auth/login/local \
    -d "username=admin@example.com&password=正确密码" \
    -c "cookies_$i.txt" -o /dev/null
done

# 三个 cookie 都有效
for i in 1 2 3; do
  echo "Cookie $i: $(curl -s -w '%{http_code}' -o /dev/null $BASE/api/v1/auth/me -b cookies_$i.txt)"
done
```

**预期：** 三个 cookie 都返回 200（未改密码，token_version 相同，多 session 共存）

#### TC-REENT-05: 登录-登出-登录

```bash
# 登录
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" \
  -c cookies.txt -o /dev/null

# 登出
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt -o /dev/null

# 再次登录
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" \
  -c cookies.txt

curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt
```

**预期：** 200。登出→再登录流程无状态残留。

### 6.3 改密码可重入

#### TC-REENT-06: 连续两次改密码

```bash
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')

# 第一次改密码
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"current_password":"Pass1","new_password":"Pass2"}' \
  -c cookies.txt

# 用新 cookie 的 CSRF 再改一次
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"current_password":"Pass2","new_password":"Pass3"}' \
  -c cookies.txt

curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt
```

**预期：**
- [ ] 两次改密码都成功
- [ ] 最终密码为 Pass3
- [ ] `token_version` 递增两次（+2）
- [ ] 最新 cookie 有效

#### TC-REENT-07: 改密码后旧 cookie 全部失效

```bash
# 保存三个时间点的 cookie
# t1: 初始登录 → cookies_t1.txt
# t2: 第一次改密码后 → cookies_t2.txt
# t3: 第二次改密码后 → cookies_t3.txt

# 用 t1 和 t2 的 cookie 访问
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t1.txt  # 预期 401
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t2.txt  # 预期 401
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t3.txt  # 预期 200
```

**预期：** 只有最新的 cookie 有效，历史 cookie 因 token_version 不匹配全部 401

### 6.4 注册可重入

#### TC-REENT-08: 同一邮箱并发注册

```bash
# 并发发送两个相同邮箱的注册请求
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"race@example.com","password":"RacePass1!"}' &
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"race@example.com","password":"RacePass1!"}' &
wait

# 检查用户数
sqlite3 backend/.deer-flow/data/deerflow.db \
  "SELECT COUNT(*) FROM users WHERE email='race@example.com';"
```

**预期：**
- [ ] 一个成功（201），一个失败（400 `email_already_exists`）
- [ ] 数据库中只有 1 条记录（UNIQUE 约束保护）

### 6.5 Rate Limiter 可重入

#### TC-REENT-09: 限速过期后重新计数

```bash
# 触发锁定（5 次错误）
for i in $(seq 1 5); do
  curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \
    -d "username=admin@example.com&password=wrong"
done

# 确认被锁定
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=wrong"
# 预期: 429

# 等待锁定过期（5 分钟）或重启服务清除内存计数器
make stop && make dev

# 重新尝试 — 计数器应已重置
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=wrong"
# 预期: 401（不是 429）
```

**预期：** 锁定过期后恢复正常限速（从 0 开始计数），而非累积

#### TC-REENT-10: 成功登录重置计数后再次失败

```bash
# 3 次失败
for i in $(seq 1 3); do
  curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \
    -d "username=admin@example.com&password=wrong"
done

# 1 次成功（重置计数）
curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码"

# 再 4 次失败（从 0 重新计数，未达阈值 5）
for i in $(seq 1 4); do
  curl -s -w "attempt $i: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
    -d "username=admin@example.com&password=wrong"
done
```

**预期：** 4 次全部返回 401（未锁定），因为成功登录已重置计数器

### 6.6 CSRF Token 可重入

#### TC-REENT-11: 登录后多次 POST 使用同一 CSRF token

```bash
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')

# 同一 CSRF token 多次使用
for i in 1 2 3; do
  echo "Request $i: $(curl -s -w '%{http_code}' -o /dev/null \
    -X POST $BASE/api/threads \
    -b cookies.txt \
    -H 'Content-Type: application/json' \
    -H "X-CSRF-Token: $CSRF" \
    -d '{"metadata":{}}')"
done
```

**预期：** 三次都成功（CSRF token 是 Double Submit Cookie，不是一次性 nonce）

### 6.7 Thread 操作可重入

#### TC-REENT-12: 重复删除同一 Thread

```bash
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')

# 创建 thread
TID=$(curl -s -X POST $BASE/api/threads \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"metadata":{}}' | jq -r .thread_id)

# 第一次删除
curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \
  -b cookies.txt -H "X-CSRF-Token: $CSRF"
# 预期: 200

# 第二次删除（幂等）
curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \
  -b cookies.txt -H "X-CSRF-Token: $CSRF"
```

**预期：** 第二次返回 200 或 404，不报 500

### 6.8 reset_admin 可重入

#### TC-REENT-13: 连续两次 reset_admin

```bash
cd backend
python -m app.gateway.auth.reset_admin
cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p1.txt
P1=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p1.txt)

python -m app.gateway.auth.reset_admin
cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p2.txt
P2=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p2.txt)
```

**预期：**
- [ ] `.deer-flow/admin_initial_credentials.txt` 每次都会被重写，文件权限为 `0600`
- [ ] P1 ≠ P2（每次生成新随机密码）
- [ ] P1 不可用，只有 P2 有效
- [ ] `token_version` 递增了 2
- [ ] `needs_setup` 为 True

### 6.9 Setup 流程可重入

#### TC-REENT-14: 完成 Setup 后再访问 /setup 页面

1. 完成 admin setup（改邮箱 + 改密码）
2. 直接访问 `/setup`
3. **预期：** 应跳转到 `/workspace`（`needs_setup` 已为 false，SSR guard 不会返回 `needs_setup` tag）

#### TC-REENT-15: Setup 中途刷新页面

1. 在 `/setup` 页面填写一半
2. 刷新页面
3. **预期：** 仍在 `/setup`（`needs_setup` 仍为 true），表单清空但不报错

---

## 七、模式差异测试

> 以下用 `GW=http://localhost:8001` 表示直连 Gateway，`BASE=http://localhost:2026` 表示经 nginx。
> 标准启动命令：`make dev`（或 `./scripts/serve.sh --dev`）。

### 7.1 标准启动模式

#### TC-MODE-01: Gateway AuthMiddleware 的 token_version 检查

```bash
# 登录拿 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -c cookies.txt

# 改密码（bumps token_version）
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
  -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
  -d '{"current_password":"正确密码","new_password":"NewPass1!"}' -c new_cookies.txt

# 用旧 cookie 访问 LangGraph-compatible 路由
curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt
# 预期: 401（token_version 不匹配）

# 用新 cookie 访问
CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}')
curl -s -w "%{http_code}" -X POST $BASE/api/langgraph/threads/search \
  -b new_cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}'
# 预期: 200
```

#### TC-MODE-02: Gateway owner filter 隔离

```bash
# user1 创建 thread
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=user1@example.com&password=UserPass1!" -c u1.txt
CSRF1=$(grep csrf_token u1.txt | awk '{print $NF}')
TID=$(curl -s -X POST $BASE/api/langgraph/threads \
  -b u1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \
  -d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])")

# user2 搜索 — 应看不到 user1 的 thread
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=user2@example.com&password=UserPass2!" -c u2.txt
CSRF2=$(grep csrf_token u2.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/langgraph/threads/search \
  -b u2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}' | python3 -c "
import sys,json
threads = json.load(sys.stdin)
ids = [t['thread_id'] for t in threads]
assert '$TID' not in ids, 'LEAK: user2 can see user1 thread'
print('OK: user2 sees', len(threads), 'threads, none belong to user1')
"
```

#### TC-MODE-03: 所有请求经 AuthMiddleware

```bash
# Gateway API 受保护
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
# 预期: 401

# LangGraph 兼容路由（rewrite 到 Gateway）也受保护
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads/search \
  -H "Content-Type: application/json" -d '{}'
# 预期: 401
```

#### TC-MODE-04: 标准模式下完整 auth 流程

```bash
# 登录
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -c cookies.txt

CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')

# 创建 thread（走 Gateway 内嵌 runtime）
curl -s -X POST $BASE/api/langgraph/threads \
  -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
  -d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])"
# 预期: 返回 thread_id

# CSRF 保护（CSRFMiddleware 覆盖所有 Gateway 路由）
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \
  -b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}'
# 预期: 403（CSRF token missing）
```

### 7.3 直连 Gateway（无 nginx）

> 启动命令：`cd backend && make gateway`（端口 8001）
> 不经过 nginx，直接测试 Gateway 的 auth 层。

#### TC-GW-01: AuthMiddleware 保护所有非 public 路由

```bash
GW=http://localhost:8001

for path in /api/models /api/mcp/config /api/memory /api/skills \
            /api/v1/auth/me /api/v1/auth/change-password; do
  echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
done
# 预期: 全部 401
```

#### TC-GW-02: Public 路由不需要 cookie

```bash
GW=http://localhost:8001

for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local \
            /api/v1/auth/register /api/v1/auth/initialize /api/v1/auth/logout; do
  echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
done
# 预期: 200 或 405/422（方法不对但不是 401）
```

#### TC-GW-03: 直连 Gateway 注册 + 登录 + CSRF 完整流程

```bash
GW=http://localhost:8001

# 注册
curl -s -X POST $GW/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"gwtest@example.com","password":"GwTest123!"}' \
  -c gw_cookies.txt -w "\nHTTP %{http_code}"
# 预期: 201

# 登录
curl -s -X POST $GW/api/v1/auth/login/local \
  -d "username=gwtest@example.com&password=GwTest123!" \
  -c gw_cookies.txt -w "\nHTTP %{http_code}"
# 预期: 200

# GET（不需要 CSRF）
curl -s -w "%{http_code}" $GW/api/models -b gw_cookies.txt
# 预期: 200

# POST 无 CSRF
curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload -b gw_cookies.txt
# 预期: 403（CSRF token missing）

# POST 有 CSRF
CSRF=$(grep csrf_token gw_cookies.txt | awk '{print $NF}')
curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload \
  -b gw_cookies.txt -H "X-CSRF-Token: $CSRF"
# 预期: 200
```

#### TC-GW-04: 直连 Gateway 的 Rate Limiter

```bash
GW=http://localhost:8001

# 直连时 request.client.host 是真实 IP（无 nginx 代理），不读 X-Real-IP
for i in $(seq 1 6); do
  echo -n "attempt $i: "
  curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \
    -d "username=admin@example.com&password=wrong"
done
# 预期: 前 5 次 401，第 6 次 429
```

#### TC-GW-05: 直连 Gateway 不受 X-Real-IP 欺骗

```bash
GW=http://localhost:8001

# 直连时 client.host 不是 trusted proxy，X-Real-IP 被忽略
for i in $(seq 1 6); do
  echo -n "attempt $i (X-Real-IP spoofed): "
  curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \
    -H "X-Real-IP: 10.0.0.$i" \
    -d "username=admin@example.com&password=wrong"
done
# 预期: 前 5 次 401，第 6 次 429（伪造的 X-Real-IP 无效，所有请求共享真实 IP 的桶）
```

### 7.4 Docker 部署

> 启动命令：`./scripts/deploy.sh`
> Docker Compose 文件：`docker/docker-compose.yaml`
>
> 前置条件：
> - `.env` 中设置 `AUTH_JWT_SECRET`（否则每次容器重启 session 全部失效）
> - `DEER_FLOW_HOME` 挂载到宿主机目录（持久化 `deerflow.db`）

#### TC-DOCKER-01: deerflow.db 通过 volume 持久化

```bash
# 启动容器
./scripts/deploy.sh

# 等待启动完成
sleep 15
BASE=http://localhost:2026

# 注册用户
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}"

# 检查宿主机上的 deerflow.db
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db
sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db \
  "SELECT email FROM users WHERE email='docker-test@example.com';"
```

**预期：** deerflow.db 在宿主机 `DEER_FLOW_HOME` 目录中，查询可见刚注册的用户。

#### TC-DOCKER-02: 重启容器后 session 保持

```bash
# 登录拿 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=docker-test@example.com&password=DockerTest1!" \
  -c docker_cookies.txt -o /dev/null

# 验证 cookie 有效
curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt
# 预期: 200

# 重启容器（不删 volume）
./scripts/deploy.sh down && ./scripts/deploy.sh
sleep 15

# 用旧 cookie 访问
curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt
```

**预期：**
- 有 `AUTH_JWT_SECRET` → 200（session 保持）
- 无 `AUTH_JWT_SECRET` → 401（每次启动生成新临时密钥，旧 JWT 签名失效）

#### TC-DOCKER-03: 多 Worker 下 Rate Limiter 独立

```bash
# docker-compose.yaml 中 gateway 默认 4 workers
# 每个 worker 有独立的 _login_attempts dict
# 限速可能不精确（请求分散到不同 worker），但不会完全失效

for i in $(seq 1 20); do
  echo -n "attempt $i: "
  curl -s -w "%{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
    -d "username=docker-test@example.com&password=wrong"
done
```

**预期：** 在某个点开始返回 429（每个 worker 独立计数，阈值可能在 5~20 之间触发，取决于负载均衡分布）。

**已知限制：** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速，需要 Redis 等外部存储。

#### TC-DOCKER-04: IM 渠道使用内部认证

```bash
# IM 渠道（Feishu/Slack/Telegram）在 gateway 容器内部通过 LangGraph SDK 调 Gateway
# 请求携带 process-local internal auth header，并带匹配的 CSRF cookie/header

# 验证方式：检查 gateway 日志中 channel manager 的请求不包含 auth 错误
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
```

**预期：** 无 auth 相关错误。渠道不依赖浏览器 cookie；服务端通过内部认证头把请求归入 `default` 用户桶。

#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件（不再走日志）

```bash
# 首次启动不会自动生成 admin 密码。先重置已有 admin，凭据文件写在挂载到宿主机的 DEER_FLOW_HOME 下。
docker exec deer-flow-gateway python -m app.gateway.auth.reset_admin --email docker-test@example.com

ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
# 预期文件权限: -rw------- (0600)

cat ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
# 预期内容: email + password 行

# 容器日志只输出文件路径，不输出密码本身
docker logs deer-flow-gateway 2>&1 | grep -E "Credentials written to|Admin account"
# 预期看到: "Credentials written to: /...../admin_initial_credentials.txt (mode 0600)"

# 反向验证: 日志里 NEVER 出现明文密码
docker logs deer-flow-gateway 2>&1 | grep -iE "Password: .{15,}" && echo "FAIL: leaked" || echo "OK: not leaked"
```

**预期：**
- 凭证文件存在于 `DEER_FLOW_HOME` 下，权限 `0600`
- 容器日志输出**路径**（不是密码本身），符合 CodeQL `py/clear-text-logging-sensitive-data` 规则
- `grep "Password:"` 在日志中**应当无匹配**（旧行为已废弃，simplify pass 移除了日志泄露路径）

#### TC-DOCKER-06: Docker 部署

```bash
# 标准 Docker 模式：runtime 嵌入 gateway 容器
./scripts/deploy.sh
sleep 15

# 确认 gateway 容器存在
docker ps --filter name=deer-flow-gateway --format '{{.Names}}'
# 预期: deer-flow-gateway

# auth 流程正常：未登录受保护接口返回 401
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
# 预期: 401

curl -s -X POST $BASE/api/v1/auth/initialize \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"AdminPass1!"}' \
  -c cookies.txt -w "\nHTTP %{http_code}"
# 预期: 201
```

### 7.4 补充边界用例

#### TC-EDGE-01: 格式正确但随机 JWT

```bash
RANDOM_JWT=$(python3 -c "
import jwt, time, uuid
print(jwt.encode({'sub':str(uuid.uuid4()),'ver':0,'exp':int(time.time())+3600}, 'wrong-secret-32chars-placeholder!!', algorithm='HS256'))
")
curl -s --cookie "access_token=$RANDOM_JWT" $BASE/api/v1/auth/me | jq .detail
```

**预期：** `{"code": "token_invalid", "message": "Token error: invalid_signature"}`

#### TC-EDGE-02: 注册时传 system_role=admin

```bash
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"hacker@example.com","password":"HackPass1!","system_role":"admin"}' | jq .system_role
```

**预期：** `"user"`（`system_role` 字段被忽略）

#### TC-EDGE-03: 并发改密码

```bash
# 注册用户，登录两个 session
curl -s -X POST $BASE/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"edge03@example.com","password":"EdgePass3!"}' -o /dev/null
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=edge03@example.com&password=EdgePass3!" -c s1.txt -o /dev/null
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=edge03@example.com&password=EdgePass3!" -c s2.txt -o /dev/null

CSRF1=$(grep csrf_token s1.txt | awk '{print $NF}')
CSRF2=$(grep csrf_token s2.txt | awk '{print $NF}')

# 并发改密码
curl -s -w "S1: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \
  -b s1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \
  -d '{"current_password":"EdgePass3!","new_password":"NewEdge3a!"}' &
curl -s -w "S2: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \
  -b s2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" \
  -d '{"current_password":"EdgePass3!","new_password":"NewEdge3b!"}' &
wait
```

**预期：** 一个 200、一个 400（current_password 已变导致验证失败）。极端并发下可能两个都 200（SQLite 串行写），但最终只有一个密码生效。

#### TC-EDGE-04: Cookie SameSite 验证

> 完整的 HTTP/HTTPS cookie 属性对比见 §3.3 TC-ATK-06/07/07a。

```bash
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie
```

**预期：** `access_token` → `SameSite=lax`，`csrf_token` → `SameSite=strict`

#### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age

```bash
GW=http://localhost:8001

# HTTP
curl -s -D - -X POST $GW/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" 2>/dev/null \
  | grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)"

# HTTPS：直连 Gateway 才能用 X-Forwarded-Proto 模拟 HTTPS；nginx 会覆盖该 header
curl -s -D - -X POST $GW/api/v1/auth/login/local \
  -H "X-Forwarded-Proto: https" \
  -d "username=admin@example.com&password=正确密码" 2>/dev/null \
  | grep "access_token=" | grep -oi "max-age=[0-9]*"
```

**预期：** HTTP 无 `Max-Age`（session cookie，浏览器关闭即失效），HTTPS 有 `Max-Age=604800`（7 天）

#### TC-EDGE-06: public 路径 trailing slash

```bash
for path in /api/v1/auth/login/local/ /api/v1/auth/register/ \
            /api/v1/auth/logout/ /api/v1/auth/setup-status/; do
  echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)"
done
```

**预期：** 全部 307（redirect 去掉 trailing slash）或 200/405，不是 401

### 7.5 红队对抗测试

> 模拟攻击者视角，验证防线没有可利用的缝隙。

#### 7.5.1 路径混淆绕过

```bash
# 通过编码/双斜杠/路径穿越尝试绕过 AuthMiddleware 公开路径判断
for path in \
  "//api/v1/auth/me" \
  "/api/v1/auth/login/local/../me" \
  "/api/v1/auth/login/local%2f..%2fme" \
  "/api/v1/auth/login/local/..%2Fme" \
  "/API/V1/AUTH/ME"; do
  echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)"
done
```

**预期：** 全部 401 或 404。不应有路径混淆导致跳过 auth 检查。

#### 7.5.2 CSRF 对抗矩阵

```bash
# 登录拿 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -c cookies.txt

CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')

# Case 1: 有 cookie 无 header → 403
curl -s -w "%{http_code}" -o /dev/null \
  -X POST $BASE/api/threads -b cookies.txt \
  -H "Content-Type: application/json" -d '{"metadata":{}}'

# Case 2: 有 header 无 cookie → 403（删除 cookie 中的 csrf_token）
curl -s -w "%{http_code}" -o /dev/null \
  -X POST $BASE/api/threads \
  -b cookies.txt \
  -H "X-CSRF-Token: $CSRF" \
  -H "Content-Type: application/json" -d '{"metadata":{}}'

# Case 3: header 和 cookie 不匹配 → 403
curl -s -w "%{http_code}" -o /dev/null \
  -X POST $BASE/api/threads -b cookies.txt \
  -H "X-CSRF-Token: wrong-token" \
  -H "Content-Type: application/json" -d '{"metadata":{}}'

# Case 4: 旧 CSRF token（登出再登录后） → 旧 token 应失效
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -c cookies.txt
# 用旧 CSRF 发请求
curl -s -w "%{http_code}" -o /dev/null \
  -X POST $BASE/api/threads -b cookies.txt \
  -H "X-CSRF-Token: $CSRF" \
  -H "Content-Type: application/json" -d '{"metadata":{}}'
```

**预期：** Case 1-3 全部 403。Case 4 应 403（旧 CSRF 与新 cookie 不匹配）。

#### 7.5.3 Token Replay（登出后旧 token 重放）

```bash
# 登录，保存 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
  -d "username=admin@example.com&password=正确密码" -c cookies.txt

# 提取 access_token 值
TOKEN=$(grep access_token cookies.txt | awk '{print $NF}')

# 登出
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt

# 手工注入旧 token（模拟攻击者窃取了 token）
curl -s -w "%{http_code}" -o /dev/null \
  $BASE/api/v1/auth/me --cookie "access_token=$TOKEN"
```

**预期：** 200（已知限制：登出只清客户端 cookie，不 bump `token_version`。旧 token 在过期前仍有效）。
**安全备注：** 如需严格防重放，需在登出时 `token_version += 1`。当前设计选择不做，因为成本是所有设备的 session 全部失效。

#### 7.5.4 跨站强制登出

```bash
# 攻击者从第三方站点 POST /logout（无需认证、无需 CSRF）
curl -s -X POST $BASE/api/v1/auth/logout -w "%{http_code}"
```

**预期：** 200（logout 是 public + CSRF 豁免）。
**风险评估：** 低——只影响可用性（被强制登出），不泄露数据。浏览器 `SameSite=Lax` 限制了真实跨站场景下 cookie 不会被带上，所以实际上第三方站点的 POST 不会清除用户 cookie。

#### 7.5.5 Metadata 注入攻击（所有权伪造）

```bash
# 尝试在创建 thread 时注入其他用户的 user_id
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/threads \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -d '{"metadata":{"owner_id":"victim-user-id","user_id":"victim-user-id"}}' | jq .metadata
```

**预期：** 返回的 `metadata` 不包含 `owner_id` 或 `user_id`。真实所有权写入 `threads_meta.user_id`，不从客户端 metadata 接收，也不通过 metadata 回显。

#### 7.5.6 HTTP Method 探测

```bash
# HEAD/OPTIONS 不应泄露受保护资源信息
for method in HEAD OPTIONS TRACE; do
  echo "$method /api/models: $(curl -s -w '%{http_code}' -o /dev/null -X $method $BASE/api/models)"
done
```

**预期：** HEAD/OPTIONS 返回 401 或 405。TRACE 应返回 405。

#### 7.5.7 Rate Limiter IP 维度缺陷验证

```bash
# 通过不同的 X-Forwarded-For 绕过限速（验证是否用 client.host 而非 header）
for i in $(seq 1 6); do
  curl -s -w "attempt $i: %{http_code}\n" -o /dev/null \
    -X POST $BASE/api/v1/auth/login/local \
    -H "X-Forwarded-For: 10.0.0.$i" \
    -d "username=admin@example.com&password=wrong"
done
```

**预期：** 如果 rate limiter 基于 `request.client.host`（实际 TCP 连接 IP），所有请求来自同一 IP，第 6 个应返回 429。X-Forwarded-For 不应影响限速判断。

#### 7.5.8 Junk Cookie 穿透验证

```bash
# middleware 只检查 cookie 存在性，不验证 JWT
# 确认 junk cookie 能过 middleware 但被下游 @require_auth 拦截
curl -s -w "%{http_code}" $BASE/api/v1/auth/me \
  --cookie "access_token=not-a-jwt"
```

**预期：** 401（middleware 放行，`get_current_user_from_request` 解码失败返回 401）。
**安全备注：** middleware 是 presence-only 检查，有意设计。完整验证交给 `@require_auth`。

#### 7.5.9 路由覆盖审计

```bash
# 列出所有注册的路由，检查哪些没有 @require_auth
cd backend && PYTHONPATH=. python3 -c "
from app.gateway.app import create_app
app = create_app()
public_prefixes = ['/health', '/docs', '/redoc', '/openapi.json',
                   '/api/v1/auth/login', '/api/v1/auth/register',
                   '/api/v1/auth/logout', '/api/v1/auth/setup-status']
for route in app.routes:
    path = getattr(route, 'path', '')
    if not path or not path.startswith('/api'):
        continue
    is_public = any(path.startswith(p) for p in public_prefixes)
    if not is_public:
        print(f'  {path}')
" 2>/dev/null
```

**预期：** 列出的所有路由都应由 AuthMiddleware（cookie 存在性）+ `@require_auth`/`@require_permission`（JWT 验证）双层保护。检查是否有遗漏。

---

## 八、回归清单

每次 auth 相关代码变更后必须通过：

```bash
# 单元测试（168 个）
cd backend && PYTHONPATH=. uv run pytest \
  tests/test_auth.py \
  tests/test_auth_config.py \
  tests/test_auth_errors.py \
  tests/test_auth_type_system.py \
  tests/test_auth_middleware.py \
  tests/test_langgraph_auth.py \
  -v

# 核心接口冒烟
curl -s $BASE/health                              # 200
curl -s $BASE/api/models                          # 401 (无 cookie)
curl -s $BASE/api/v1/auth/setup-status            # 200
curl -s $BASE/api/v1/auth/me -b cookies.txt       # 200 (有 cookie)
```
