Files
sub2api-cn-relay-manager/tests/integration/host_stub_test.go

659 lines
19 KiB
Go

package integration_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
)
func TestSub2APIHostAdapterGetHostVersion(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"), sub2api.WithBearerToken("bearer-token"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
version, err := client.GetHostVersion(context.Background())
if err != nil {
t.Fatalf("GetHostVersion() error = %v", err)
}
if version != "0.1.126" {
t.Fatalf("version = %q, want %q", version, "0.1.126")
}
}
func TestSub2APIHostAdapterProbeCapabilities(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
caps, err := client.ProbeCapabilities(context.Background())
if err != nil {
t.Fatalf("ProbeCapabilities() error = %v", err)
}
if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions {
t.Fatalf("ProbeCapabilities() = %+v, want all capabilities true", caps)
}
}
func TestSub2APIHostAdapterMapsUnauthorizedErrors(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.GetHostVersion(context.Background())
if err == nil {
t.Fatal("GetHostVersion() error = nil, want 401 error")
}
var httpErr *sub2api.HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("GetHostVersion() error type = %T, want *sub2api.HTTPError", err)
}
if httpErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusUnauthorized)
}
}
func TestSub2APIHostAdapterMapsNotFoundErrors(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.GetHostVersion(context.Background())
if err == nil {
t.Fatal("GetHostVersion() error = nil, want 404 error")
}
var httpErr *sub2api.HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("GetHostVersion() error type = %T, want *sub2api.HTTPError", err)
}
if httpErr.StatusCode != http.StatusNotFound {
t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusNotFound)
}
}
func TestSub2APIHostAdapterCreateGroup(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"), sub2api.WithBearerToken("bearer-token"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
group, err := client.CreateGroup(context.Background(), sub2api.CreateGroupRequest{
Name: "relay-group",
RateMultiplier: 1.5,
})
if err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if group.ID != "group_1" {
t.Fatalf("group.ID = %q, want %q", group.ID, "group_1")
}
if group.Name != "relay-group" {
t.Fatalf("group.Name = %q, want %q", group.Name, "relay-group")
}
}
func TestSub2APIHostAdapterTestAccountParsesSSE(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
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.TestAccount(context.Background(), "account_1", "deepseek-chat")
if err != nil {
t.Fatalf("TestAccount() error = %v", err)
}
if !result.OK {
t.Fatal("TestAccount() OK = false, want true")
}
if result.Status != "passed" {
t.Fatalf("TestAccount() Status = %q, want %q", result.Status, "passed")
}
if result.Message != "smoke passed" {
t.Fatalf("TestAccount() Message = %q, want %q", result.Message, "smoke passed")
}
}
func TestSub2APIHostAdapterGetAccountModelsParsesEnvelope(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
models, err := client.GetAccountModels(context.Background(), "account_1")
if err != nil {
t.Fatalf("GetAccountModels() error = %v", err)
}
if len(models) != 2 {
t.Fatalf("len(models) = %d, want 2", len(models))
}
if models[0].ID != "deepseek-chat" || models[0].DisplayName != "DeepSeek Chat" || models[0].Type != "chat" {
t.Fatalf("first model = %+v, want id/display_name/type from envelope", models[0])
}
}
func TestSub2APIHostAdapterChecksGatewayAccess(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{
APIKey: "user-api-key",
ExpectedModel: "deepseek-chat",
})
if err != nil {
t.Fatalf("CheckGatewayAccess() error = %v", err)
}
if !result.OK || !result.HasExpectedModel {
t.Fatalf("CheckGatewayAccess() = %+v, want ok with expected model", result)
}
}
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,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if err := client.DeleteAccount(context.Background(), "account_1"); err != nil {
t.Fatalf("DeleteAccount() error = %v", err)
}
if err := client.DeleteChannel(context.Background(), "channel_1"); err != nil {
t.Fatalf("DeleteChannel() error = %v", err)
}
if err := client.DeleteGroup(context.Background(), "group_1"); err != nil {
t.Fatalf("DeleteGroup() error = %v", err)
}
}
func TestSub2APIHostAdapterListManagedResources(t *testing.T) {
server := newSub2APIStubServer(t, sub2APIStubConfig{
requireAPIKey: true,
version: "0.1.126",
})
defer server.Close()
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
snapshot, err := client.ListManagedResources(context.Background(), sub2api.ListManagedResourcesRequest{
GroupName: "crm-deepseek-group",
ChannelName: "crm-deepseek-channel",
PlanName: "crm-deepseek-plan",
AccountNamePrefix: "deepseek-",
})
if err != nil {
t.Fatalf("ListManagedResources() error = %v", err)
}
if len(snapshot.Groups) != 1 || snapshot.Groups[0].ID != "group_1" {
t.Fatalf("Groups = %+v, want one group_1 match", snapshot.Groups)
}
if len(snapshot.Channels) != 2 || snapshot.Channels[0].ID != "channel_1" || snapshot.Channels[1].ID != "channel_2" {
t.Fatalf("Channels = %+v, want two matching channels", snapshot.Channels)
}
if len(snapshot.Plans) != 1 || snapshot.Plans[0].ID != "plan_1" {
t.Fatalf("Plans = %+v, want one plan_1 match", snapshot.Plans)
}
if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" {
t.Fatalf("Accounts = %+v, want two deepseek account matches", snapshot.Accounts)
}
}
type sub2APIStubConfig struct {
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) {
return
}
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": map[string]any{
"version": cfg.version,
},
})
})
mux.HandleFunc("/api/v1/admin/groups", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
switch r.Method {
case http.MethodGet:
writeJSON(t, w, http.StatusOK, map[string]any{
"data": []map[string]any{
{"id": "group_1", "name": "crm-deepseek-group"},
{"id": "group_2", "name": "other-group"},
},
})
case http.MethodPost:
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
if payload["name"] == "" || payload["rate_multiplier"] == nil {
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
return
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"data": map[string]any{
"id": "group_1",
"name": payload["name"],
},
})
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/v1/admin/groups/", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
if r.Method != http.MethodDelete {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/v1/admin/channels", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
switch r.Method {
case http.MethodGet:
writeJSON(t, w, http.StatusOK, map[string]any{
"data": []map[string]any{
{"id": "channel_1", "name": "crm-deepseek-channel"},
{"id": "channel_2", "name": "crm-deepseek-channel"},
{"id": "channel_3", "name": "other-channel"},
},
})
case http.MethodPost:
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/v1/admin/channels/", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
if r.Method != http.MethodDelete {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/v1/admin/payment/plans", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
switch r.Method {
case http.MethodGet:
writeJSON(t, w, http.StatusOK, map[string]any{
"data": []map[string]any{
{"id": "plan_1", "name": "crm-deepseek-plan"},
{"id": "plan_2", "name": "other-plan"},
},
})
case http.MethodPost:
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/v1/admin/accounts", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
switch r.Method {
case http.MethodGet:
writeJSON(t, w, http.StatusOK, map[string]any{
"data": []map[string]any{
{"id": "account_1", "name": "deepseek-01"},
{"id": "account_2", "name": "deepseek-02"},
{"id": "account_3", "name": "other-01"},
},
})
case http.MethodPost:
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/v1/admin/accounts/batch", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": []map[string]any{
{"id": "account_1"},
{"id": "account_2"},
},
})
})
mux.HandleFunc("/api/v1/admin/accounts/", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/admin/accounts/"), "/")
if len(parts) == 1 {
if r.Method != http.MethodDelete {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
if len(parts) != 2 {
http.NotFound(w, r)
return
}
accountID, action := parts[0], parts[1]
switch action {
case "test":
if r.Method == http.MethodGet {
writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{"supported": true}})
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "event: result\n")
fmt.Fprintf(w, "data: {\"status\":\"passed\",\"message\":\"smoke passed\",\"ok\":true,\"account_id\":\"%s\"}\n\n", accountID)
case "models":
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": map[string]any{
"items": accountModels,
},
})
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/v1/admin/subscriptions/assign", func(w http.ResponseWriter, r *http.Request) {
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
return
}
if r.Method == http.MethodGet {
writeJSON(t, w, http.StatusOK, map[string]any{
"data": map[string]any{"supported": true},
})
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": map[string]any{
"id": "subscription_1",
},
})
})
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer "+gatewayExpectedKey {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"data": gatewayModels,
})
})
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer "+gatewayExpectedKey {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}
writeJSON(t, w, http.StatusOK, map[string]any{
"id": "chatcmpl_stub",
"object": "chat.completion",
"choices": []map[string]any{
{
"index": 0,
"message": map[string]any{
"role": "assistant",
"content": "pong",
},
},
},
})
})
return httptest.NewServer(mux)
}
func mustStubAuth(t *testing.T, w http.ResponseWriter, r *http.Request, requireAPIKey bool) bool {
t.Helper()
if !requireAPIKey {
return true
}
if got := r.Header.Get("x-api-key"); got == "api-key" {
if r.Header.Get("Authorization") != "" {
t.Fatalf("Authorization header = %q, want empty when x-api-key is configured", r.Header.Get("Authorization"))
}
return true
}
if got := r.Header.Get("Authorization"); got == "Bearer bearer-token" {
return true
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return false
}
func writeJSON(t *testing.T, w http.ResponseWriter, statusCode int, payload any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("json.Encode() error = %v", err)
}
}