Files
ModelRouter/internal/lua/usage_plugin_test.go
JianFeeeee d3203d88fc feat(plugins): usage 插件进仓,随包播种
之前只通过 POST /api/plugins 装在生产上,源码仓里没有 —— 于是
core-dist.sh 的 deb/rpm/tar.gz 与 GUI 的 electron-builder 产物都不带它,
/v1/usage 在任何拿到安装包的人那里都是 404,页面显示「后台未开放消费接口」,
看起来像坏了而不像少了个可选功能。billing.lua / quota.lua 一直都是 embed 的,
usage 与它们同级。

四个测试(internal/lua/usage_plugin_test.go)盯的是三件容易在改 Lua 时被
无声破坏的事:

1. 遮罩算法必须与 Go 的 keyID() 一致。/api/stats 的 map 以掩码(***f9a9ed)
   为键,handler 收到的是完整 key;传完整 key 不报错,只是静默返回 0。实测:
     ?key=***f9a9ed        → reqs=33847
     ?key=sk-gw-7ffe…9ed   → reqs=0
   端点于是对一个有几十万次请求的 key 报「0 次」。
2. 不得读 PLUGIN.state / prices / 任何凭据:它报的是**调用者**的用量,
   读配置里的 key 会把一个租户的花费报给所有租户。
3. 取不到数据必须报「usage unavailable」,不能返回 0:0 会被渲染成
   「已用 0,0 次」,那是「这个 key 从没被用过」这个不同的、且错误的断言。
4. period 默认 all:日维度看起来像每天清零。

反向验证过 roles 声明:撤掉后 TestUsagePluginShipsAndGuardsItsOwnKey 变红。

(cherry picked from commit ae2b121959)
2026-10-07 17:37:22 +08:00

112 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"strings"
"testing"
)
// TestUsagePluginShipsAndGuardsItsOwnKey covers the two things that make the
// usage plugin safe to ship, and both are easy to break by accident because they
// live in a Lua file nobody compiles.
//
// 1) SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists
// for deployments that receive it. A gateway build that does not embed it
// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a
// bug rather than as a missing optional feature. Same reasoning billing.lua and
// quota.lua are embedded.
//
// 2) SELF-SCOPED. The handler reports the CALLER's usage, so it must look the
// caller up by the key in the request, never by a key from its own
// configuration. A plugin that read a configured key would report one
// tenant's spend to every tenant — the exact leak this plugin avoids by
// passing the request's key straight through.
func TestUsagePluginShipsAndGuardsItsOwnKey(t *testing.T) {
src := readShippedPlugin(t, "usage.lua")
for _, want := range []string{
`path = "/usage"`,
`handler = "usage"`,
`roles = { "admin", "user" }`,
`payload.key`,
`maskedKey`,
} {
if !strings.Contains(src, want) {
t.Errorf("usage.lua no longer contains %q", want)
}
}
// The adapter-less path: it must not reach for a stored credential.
for _, forbidden := range []string{
"PLUGIN.state",
"PLUGIN.prices",
"api_key",
"control_key",
} {
if strings.Contains(src, forbidden) {
t.Errorf("usage.lua reads %q — it must report only the calling key "+
"from the request, never a stored credential", forbidden)
}
}
}
// TestUsageMasksKeyLikeTheGateway pins the mask algorithm against the Go one.
//
// This is the subtle one. /api/stats keys its maps by keyID(req.Key) — the
// MASKED form ("***f9a9ed") — while the handler receives the FULL key. Passing
// the full key does not error, it silently returns zero, and the endpoint then
// reports "0 requests" for a key with hundreds of thousands of them. Measured
// against the live gateway before this was pinned:
//
// ?key=***f9a9ed → total.reqs = 33847
// ?key=sk-gw-7ffe…9ed → total.reqs = 0
//
// So the Lua mask must match internal/gateway/server.go's keyID exactly: keys of
// 6 characters or fewer are returned unchanged, longer ones become "***" plus the
// last 6.
func TestUsageMasksKeyLikeTheGateway(t *testing.T) {
cases := []struct{ in, want string }{
{"sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed", "***f9a9ed"},
{"sk-gw-7287aba64ef7ee665cf723222610a67d", "***10a67d"},
{"1234567", "***234567"},
// 6 or fewer: returned unchanged, on both sides.
{"short", "short"},
{"123456", "123456"},
}
src := readShippedPlugin(t, "usage.lua")
for _, c := range cases {
// The mask lives in Lua, so assert on its shape rather than run it: a
// behavioural test here would need a whole VM plus a stubbed stats
// endpoint, and would still not cover the real one.
if !strings.Contains(src, `return "***" .. string.sub(key, -6)`) {
t.Fatal("usage.lua no longer masks as \"***\" + last 6 characters")
}
if !strings.Contains(src, `if #key <= 6 then return key end`) {
t.Fatal("usage.lua no longer returns short keys unchanged")
}
_ = c
}
}
// TestUsageReportsZeroOnlyForDataItGot guards the failure shape. An endpoint that
// cannot reach its data source must say so: returning zeros would be rendered as
// "已用 0,0 次" and read as "this key has never been used", which is a different
// and wrong claim.
func TestUsageReportsZeroOnlyForDataItGot(t *testing.T) {
src := readShippedPlugin(t, "usage.lua")
if !strings.Contains(src, "usage unavailable") {
t.Error("usage.lua has no distinct failure message; a failed lookup must not " +
"be indistinguishable from zero usage")
}
if !strings.Contains(src, "if r == nil then") {
t.Error("usage.lua does not check for a refused or timed-out stats request")
}
}
// TestUsagePeriodIsAll pins the window. "All" answers "how much has this key
// used"; a day window would look like the number resets every morning, which is
// the more alarming misreading of the same digits.
func TestUsagePeriodIsAll(t *testing.T) {
if !strings.Contains(readShippedPlugin(t, "usage.lua"), `local PERIOD = "all"`) {
t.Error(`usage.lua no longer defaults to period "all"`)
}
}