test(host): harden gateway and acceptance validation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
清单 2(channel 宿主契约 / 导入落库)
|
||||
- 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)
|
||||
|
||||
清单 3(access / 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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user