test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路

用户要求的四类测试全部落地,且每类都抓到了真问题:

## 0) 测试暴露的真实缺陷(先修后测)

**保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前
是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1
形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、
读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。
core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing,
修掉了一个 nil-deref)。

## 1) 加密解密(internal/lua/secrets_crypto_test.go)
- EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700
- PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文
- WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据)
- ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确

## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go)
- 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用,
  单一事实源)
- 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 /
  expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用
- 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时
  两个变异体幸存(落进通用 failed 分支),加 message 后全杀。
  这正是变异测试的价值:暴露断言太弱。
- 基准(未变异)必须全过 —— 防止测试自身失效的假阳性

## 3) 压力(internal/core/channels_stress_test.go)
- ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态
- ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失)

## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试)
- LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封
- LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、
  落盘 seal、master.key 0600 生成

全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。
This commit is contained in:
JianFeeeee
2026-10-11 20:50:52 +08:00
parent 6f2b6b8d3d
commit da0282a3db
9 changed files with 977 additions and 161 deletions

View File

@ -43,6 +43,24 @@ import (
// secretsDirName is the vault directory inside plugin_dir. The leading dot keeps
// it out of a casual `ls` of the plugin directory, which is where operators look
// for "what plugins do I have" — the vault is not a plugin.
// secretSealer 是保险库落盘加密所需的最小能力面。
//
// 不直接 import config.SecretBox 的原因与 KernelAPI 相同:保持依赖注入的缝,
// 测试可以用桩;core 侧把 *config.SecretBox 注进来(Encrypt/Decrypt 语义与
// config.yaml 的凭据完全一致 —— 同一把 master key、同一种 enc:v1 前缀)。
type secretSealer interface {
Encrypt(plain string) (string, error)
Decrypt(v string) (string, error)
}
// SetSecretBox 注入落盘加密器。必须在任何插件写凭据之前调用(core 启动序里
// 在 NewPlugins 之后立即做)。传 nil 合法(=不加密,测试环境)。
func (ps *Plugins) SetSecretBox(box secretSealer) {
ps.mu.Lock()
ps.secretBox = box
ps.mu.Unlock()
}
const secretsDirName = ".secrets"
// secretsDir returns the vault directory (empty when there is no plugin dir, in
@ -105,6 +123,18 @@ func (ps *Plugins) loadSecretsLocked(plugin string) (map[string]string, error) {
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("vault for plugin %s is unreadable: %w", plugin, err)
}
// Decrypt each value. Decrypt passes values without the enc:v1 prefix through
// unchanged, so vaults written by an older plaintext build keep loading —
// and get re-sealed on the next write (SetSecret rewrites the whole file).
if ps.secretBox != nil {
for k, v := range m {
plain, err := ps.secretBox.Decrypt(v)
if err != nil {
return nil, fmt.Errorf("vault for plugin %s: key %q: %w (master key changed?)", plugin, k, err)
}
m[k] = plain
}
}
return m, nil
}
@ -126,7 +156,23 @@ func (ps *Plugins) writeSecretsLocked(plugin string, m map[string]string) error
// vault created before this mode was enforced would keep its old permissions.
_ = os.Chmod(dir, 0o700)
b, err := json.MarshalIndent(m, "", " ")
// Encrypt-at-rest: each VALUE is sealed with the deployment's master key
// (same enc:v1 form as config.yaml credentials). Keys stay plaintext so the
// file stays diffable/debuggable without exposing token bytes. A nil box
// writes plaintext — tests only; core always injects a real box.
sealed := make(map[string]string, len(m))
for k, v := range m {
if ps.secretBox != nil {
enc, err := ps.secretBox.Encrypt(v)
if err != nil {
return fmt.Errorf("seal secret %q: %w", k, err)
}
sealed[k] = enc
} else {
sealed[k] = v
}
}
b, err := json.MarshalIndent(sealed, "", " ")
if err != nil {
return err
}