test(host): harden gateway and acceptance validation

This commit is contained in:
phamnazage-jpg
2026-05-21 15:45:55 +08:00
parent 2649956b59
commit 7c6e18f94d
3 changed files with 261 additions and 11 deletions

View File

@@ -657,6 +657,108 @@ func TestCreateChannelWithMock(t *testing.T) {
}
}
func TestUpdateChannelWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Fatalf("method = %s, want PUT", r.Method)
}
if r.URL.Path != "/api/v1/admin/channels/201" {
t.Fatalf("path = %s, want /api/v1/admin/channels/201", r.URL.Path)
}
var req struct {
ModelMapping map[string]map[string]string `json:"model_mapping"`
ModelPricing []struct {
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
} `json:"model_pricing"`
RestrictModels bool `json:"restrict_models"`
BillingModelSource string `json:"billing_model_source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" {
t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", req.ModelMapping)
}
if len(req.ModelPricing) != 1 || req.ModelPricing[0].Platform != "openai" || req.ModelPricing[0].BillingMode != "token" {
t.Fatalf("model_pricing = %+v, want openai/token entry", req.ModelPricing)
}
if !req.RestrictModels {
t.Fatal("restrict_models = false, want true")
}
if req.BillingModelSource != "channel_mapped" {
t.Fatalf("billing_model_source = %q, want channel_mapped", req.BillingModelSource)
}
w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
if err := client.UpdateChannel(context.Background(), "201", CreateChannelRequest{
Name: "ch",
GroupIDs: []string{"101"},
ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"},
ModelPricing: []ChannelModelPricing{{Platform: "openai", Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}},
RestrictModels: true,
BillingModelSource: "channel_mapped",
}); err != nil {
t.Fatal(err)
}
}
func TestCreateChannelRequestMarshalJSONDefaultsPricingPlatform(t *testing.T) {
t.Run("request platform", func(t *testing.T) {
payload, err := json.Marshal(CreateChannelRequest{
Name: "ch",
GroupIDs: []string{"101"},
Platform: "openai",
ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"},
ModelPricing: []ChannelModelPricing{{Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}},
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got struct {
ModelMapping map[string]map[string]string `json:"model_mapping"`
ModelPricing []struct {
Platform string `json:"platform"`
} `json:"model_pricing"`
}
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got.ModelMapping["openai"]["deepseek-v4-pro"] != "deepseek-v4-pro" {
t.Fatalf("model_mapping = %+v, want openai/deepseek-v4-pro passthrough", got.ModelMapping)
}
if len(got.ModelPricing) != 1 || got.ModelPricing[0].Platform != "openai" {
t.Fatalf("model_pricing = %+v, want platform openai", got.ModelPricing)
}
})
t.Run("openai fallback", func(t *testing.T) {
payload, err := json.Marshal(CreateChannelRequest{
Name: "ch",
GroupIDs: []string{"101"},
ModelMapping: map[string]string{"deepseek-v4-pro": "deepseek-v4-pro"},
ModelPricing: []ChannelModelPricing{{Models: []string{"deepseek-v4-pro"}, BillingMode: "token"}},
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got struct {
ModelPricing []struct {
Platform string `json:"platform"`
} `json:"model_pricing"`
}
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.ModelPricing) != 1 || got.ModelPricing[0].Platform != "openai" {
t.Fatalf("model_pricing = %+v, want platform openai fallback", got.ModelPricing)
}
})
}
func TestCreatePlanWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {

View File

@@ -43,6 +43,53 @@ save_json() {
printf '%s\n' "$payload" > "$ARTIFACT_DIR/$name.json"
}
write_checklist_guide() {
mkdir -p "$ARTIFACT_DIR"
cat > "$ARTIFACT_DIR/00-artifact-guide.txt" <<EOF
真实宿主验收产物 -> 速查清单对应
清单 1环境 / host 前置)
- 01-create-host.json
- 02-probe-host.json
清单 2channel 宿主契约 / 导入落库)
- 03-install-pack.json
- 04-preview-import.json
- 05-import.json
- 05a-batch-detail-pre-access.json若拿到 batch_id 且非 dry-run
- 08-provider-status.json
- 09-reconcile.json
- 10-batch-detail.json若拿到 batch_id 且非 dry-run
清单 3access / key 闭环状态)
- 06-access-preview.json
- 07-access-status.json
清单 4必须分层留证据不可混用
- account 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 GET /api/v1/admin/accounts/:id/models
- 普通用户 / managed key 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 GET /v1/models
- completion 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 POST /v1/chat/completions
红线:
- /api/v1/admin/accounts/:id/models 正确 ≠ /v1/models 正确
- /v1/models 正确 ≠ /v1/chat/completions 正确
- admin API 成功 ≠ 普通用户链路成功
当前 hook 配置:$( [[ -n "$AFTER_IMPORT_HOOK_COMMAND" ]] && printf 'enabled' || printf 'disabled' )
EOF
}
print_artifact_summary() {
echo "artifact guide: $ARTIFACT_DIR/00-artifact-guide.txt"
echo "checklist import evidence: 04-preview-import.json 05-import.json 05a-batch-detail-pre-access.json(optional) 08-provider-status.json 09-reconcile.json"
echo "checklist access evidence: 06-access-preview.json 07-access-status.json"
if [[ -n "$AFTER_IMPORT_HOOK_COMMAND" ]]; then
echo "checklist layered evidence: see 05b-after-import-hook.stdout.txt / 05b-after-import-hook.stderr.txt and hook-generated files under $ARTIFACT_DIR"
else
echo "checklist layered evidence: missing hook-generated /accounts/:id/models, /v1/models, /v1/chat/completions artifacts"
fi
}
curl_json() {
local method="$1"
local path="$2"
@@ -135,6 +182,7 @@ export HOST_API_KEY HOST_BEARER_TOKEN ACCESS_API_KEY SUBSCRIPTION_USERS KEYS
mkdir -p "$ARTIFACT_DIR"
echo "artifacts: $ARTIFACT_DIR"
write_checklist_guide
HOST_AUTH_JSON="$(build_host_auth_payload)"
export HOST_AUTH_JSON
@@ -281,4 +329,5 @@ PY
save_json 11-rollback "$RESP_ROLLBACK"
fi
print_artifact_summary
echo "acceptance flow completed"

View File

@@ -217,6 +217,89 @@ func TestSub2APIHostAdapterChecksGatewayAccess(t *testing.T) {
}
}
func TestSub2APIHostAdapterSeparatesAccountModelsFromGatewayModels(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
accountModels: []map[string]any{{"id": "deepseek-account-only", "display_name": "DeepSeek Account Only", "type": "chat"}},
gatewayModels: []map[string]any{{"id": "deepseek-gateway-only"}},
gatewayExpectedKey: "managed-user-key",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
accountModels, err := client.GetAccountModels(context.Background(), "account_1")
if err != nil {
t.Fatalf("GetAccountModels() error = %v", err)
}
if len(accountModels) != 1 || accountModels[0].ID != "deepseek-account-only" {
t.Fatalf("GetAccountModels() = %+v, want admin account models only", accountModels)
}
gatewayResult, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{
APIKey: "managed-user-key",
ExpectedModel: "deepseek-gateway-only",
})
if err != nil {
t.Fatalf("CheckGatewayAccess() error = %v", err)
}
if !gatewayResult.OK || !gatewayResult.HasExpectedModel {
t.Fatalf("CheckGatewayAccess() = %+v, want gateway models only", gatewayResult)
}
if len(gatewayResult.Models) != 1 || gatewayResult.Models[0] != "deepseek-gateway-only" {
t.Fatalf("gateway models = %+v, want gateway-only model list", gatewayResult.Models)
}
if gatewayResult.Models[0] == accountModels[0].ID {
t.Fatalf("gateway models = %+v unexpectedly matched account models %+v", gatewayResult.Models, accountModels)
}
}
func TestSub2APIHostAdapterGatewayProbeDoesNotReuseAdminCredential(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
gatewayExpectedKey: "managed-user-key",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{
APIKey: "managed-user-key",
ExpectedModel: "deepseek-chat",
})
if err != nil {
t.Fatalf("CheckGatewayAccess() error = %v", err)
}
if !result.OK {
t.Fatalf("CheckGatewayAccess() = %+v, want OK=true with managed user key", result)
}
wrongKeyResult, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{
APIKey: "api-key",
ExpectedModel: "deepseek-chat",
})
if err != nil {
t.Fatalf("CheckGatewayAccess() with admin key error = %v", err)
}
if wrongKeyResult.OK {
t.Fatalf("CheckGatewayAccess() with admin key = %+v, want OK=false", wrongKeyResult)
}
if wrongKeyResult.StatusCode != http.StatusUnauthorized {
t.Fatalf("StatusCode = %d, want %d", wrongKeyResult.StatusCode, http.StatusUnauthorized)
}
if wrongKeyResult.HasExpectedModel {
t.Fatalf("CheckGatewayAccess() with admin key = %+v, want HasExpectedModel=false", wrongKeyResult)
}
}
func TestSub2APIHostAdapterDeletesManagedResources(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
@@ -277,13 +360,35 @@ func TestSub2APIHostAdapterListManagedResources(t *testing.T) {
}
type sub2APIStubConfig struct {
requireAPIKey bool
version string
requireAPIKey bool
version string
accountModels []map[string]any
gatewayModels []map[string]any
gatewayExpectedKey string
}
func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server {
t.Helper()
accountModels := cfg.accountModels
if len(accountModels) == 0 {
accountModels = []map[string]any{
{"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"},
{"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"},
}
}
gatewayModels := cfg.gatewayModels
if len(gatewayModels) == 0 {
gatewayModels = []map[string]any{
{"id": "deepseek-chat"},
{"id": "deepseek-reasoner"},
}
}
gatewayExpectedKey := cfg.gatewayExpectedKey
if gatewayExpectedKey == "" {
gatewayExpectedKey = "user-api-key"
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/admin/system/version", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
@@ -458,10 +563,7 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": map[string]any{
"items": []map[string]any{
{"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"},
{"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"},
},
"items": accountModels,
},
})
default:
@@ -489,16 +591,13 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server
})
})
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("x-api-key"); got != "user-api-key" {
if got := r.Header.Get("x-api-key"); got != gatewayExpectedKey {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": []map[string]any{
{"id": "deepseek-chat"},
{"id": "deepseek-reasoner"},
},
"data": gatewayModels,
})
})