package app import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "sub2api-cn-relay-manager/internal/config" "sub2api-cn-relay-manager/internal/metrics" "sub2api-cn-relay-manager/internal/store/sqlite" ) func testUserKeyAuthConfig() config.UserKeyAuthConfig { return config.UserKeyAuthConfig{ TrustedSubjectHeader: testTrustedSubjectHeader, TrustedProxySecretHeader: testTrustedProxySecretHeader, TrustedProxySecret: testTrustedProxySecret, } } func applyTrustedProxyAuthHeaders(req *http.Request, subjectID string) { req.Header.Set(testTrustedSubjectHeader, subjectID) req.Header.Set(testTrustedProxySecretHeader, testTrustedProxySecret) } func makeCreateBody(groupID, displayName string, models []string) io.Reader { b, _ := json.Marshal(map[string]any{ "logical_group_id": groupID, "display_name": displayName, "allowed_models": models, }) return bytes.NewReader(b) } func makeCreateRequest(t *testing.T, method, path string, body io.Reader) *http.Request { t.Helper() req := httptest.NewRequest(method, path, body) req.Header.Set("Content-Type", "application/json") return req } func TestUserKeyAPIUsesPortalSubjectHeader(t *testing.T) { t.Parallel() store := openAppTestStore(t) defer closeAppTestStore(t, store) // Seed a logical group + route + host so resolveLogicalGroupHost succeeds _, _ = store.Hosts().Create(context.Background(), sqlite.Host{ HostID: "test-host", BaseURL: "http://127.0.0.1:1", HostVersion: "0.0.1", CapabilityProbeJSON: "{}", AuthType: "apikey", AuthToken: "test-token", }) _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ LogicalGroupID: "gpt-shared", DisplayName: "GPT Shared", Status: "active", }) _, _ = store.LogicalGroupRoutes().Create(context.Background(), sqlite.LogicalGroupRoute{ RouteID: "test-route", LogicalGroupID: "gpt-shared", Name: "Test Route", Status: "active", ShadowHostID: "test-host", ShadowGroupID: "999", }) handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store), testUserKeyAuthConfig()), }) req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("gpt-shared", "portal key", []string{"gpt-5.4"})) applyTrustedProxyAuthHeaders(req, "smoke-user") resp := httptestRecorder(handler, req) // We expect 500 because test host is unreachable (port 1), but the important // thing is the request decoded the subject header and reached the host resolution // step (not 401 "user credentials required") if resp.code == http.StatusUnauthorized || resp.code == http.StatusNotImplemented { t.Fatalf("status code = %d, expected to pass auth layer", resp.code) } var errResp struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(resp.Body().Bytes(), &errResp); err == nil { if strings.Contains(errResp.Error.Message, "POST /api/v1/auth/login") || strings.Contains(errResp.Error.Message, "no such host") || strings.Contains(errResp.Error.Message, "connect: connection refused") || strings.Contains(errResp.Error.Message, "dial tcp") { t.Logf("expected host-level error (not auth): code=%s msg=%s", errResp.Error.Code, errResp.Error.Message) } else { t.Logf("unexpected error shape: code=%s msg=%s", errResp.Error.Code, errResp.Error.Message) } } } func TestUserKeyCreateRejectsMissingSubject(t *testing.T) { t.Parallel() handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t))), }) req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("gpt-shared", "portal key", nil)) resp := httptestRecorder(handler, req) if resp.code != http.StatusUnauthorized { t.Fatalf("status code = %d, want 401", resp.code) } } func TestUserKeyCreateRejectsMissingGroup(t *testing.T) { t.Parallel() handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t)), testUserKeyAuthConfig()), }) body := bytes.NewReader([]byte(`{"display_name":"portal key"}`)) req := makeCreateRequest(t, http.MethodPost, "/api/keys", body) applyTrustedProxyAuthHeaders(req, "smoke-user") resp := httptestRecorder(handler, req) if resp.code != http.StatusBadRequest { t.Fatalf("status code = %d, want 400", resp.code) } } func TestUserKeyResetRejectsMissingSubject(t *testing.T) { t.Parallel() handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t))), }) req := httptest.NewRequest(http.MethodPost, "/api/keys/key_123/reset", nil) req.Header.Set("Content-Type", "application/json") resp := httptestRecorder(handler, req) if resp.code != http.StatusUnauthorized { t.Fatalf("status code = %d, want 401", resp.code) } } func TestUserKeyRateLimitNoDB(t *testing.T) { t.Parallel() store := openAppTestStore(t) defer closeAppTestStore(t, store) _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ LogicalGroupID: "gpt-shared", DisplayName: "GPT Shared", Status: "active", }) handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store), testUserKeyAuthConfig()), }) req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("gpt-shared", "rate-test", nil)) applyTrustedProxyAuthHeaders(req, "rate-user") resp := httptestRecorder(handler, req) if resp.code == http.StatusUnauthorized || resp.code == http.StatusNotImplemented { t.Fatalf("status code = %d, expected to pass auth layer", resp.code) } } func TestUserKeyCreateUsesPerRecordManagedKeyAndConsistentMetadata(t *testing.T) { t.Parallel() store := openAppTestStore(t) defer closeAppTestStore(t, store) const logicalGroupID = "gpt-shared" const hostGroupID = "999" const subjectID = "portal-user:13" var loginEmail string var customKey string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): w.Write([]byte(`{"data":{"items":[]}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": w.Write([]byte(`{"data":{"id":84,"email":"managed@sub2api.local"}}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84": w.Write([]byte(`{"data":{"id":84}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/84/balance": w.Write([]byte(`{"data":{"id":84}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/subscriptions/assign": w.Write([]byte(`{"data":{"id":401}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/auth/login": var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode login request: %v", err) } loginEmail = fmt.Sprint(req["email"]) if !strings.Contains(loginEmail, "@sub2api.local") || strings.Contains(loginEmail, subjectID) { t.Fatalf("login email = %q, want synthesized per-record managed identity", loginEmail) } w.Write([]byte(`{"data":{"access_token":"user-jwt"}}`)) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/keys": var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode managed key request: %v", err) } customKey = fmt.Sprint(req["custom_key"]) if !strings.HasPrefix(customKey, "sk-relay-") { t.Fatalf("custom_key = %q, want sk-relay-*", customKey) } w.Write([]byte(`{"data":{"id":501,"key":"placeholder-from-host","name":"managed-key"}}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501": w.Write([]byte(`{"data":{"api_key":{"id":501}}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() _, _ = store.Hosts().Create(context.Background(), sqlite.Host{ HostID: "test-host", BaseURL: server.URL, HostVersion: "0.0.1", CapabilityProbeJSON: "{}", AuthType: "apikey", AuthToken: "test-token", }) _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ LogicalGroupID: logicalGroupID, DisplayName: "GPT Shared", Status: "active", }) _, _ = store.LogicalGroupRoutes().Create(context.Background(), sqlite.LogicalGroupRoute{ RouteID: "test-route", LogicalGroupID: logicalGroupID, Name: "Test Route", Status: "active", ShadowHostID: "test-host", ShadowGroupID: hostGroupID, }) handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store), testUserKeyAuthConfig()), }) req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody(logicalGroupID, "portal key", []string{"gpt-5.4"})) applyTrustedProxyAuthHeaders(req, subjectID) resp := httptestRecorder(handler, req) if resp.code != http.StatusCreated { t.Fatalf("status code = %d, want 201, body=%s", resp.code, resp.Body().String()) } var createResp CreateUserKeyResponse if err := json.Unmarshal(resp.Body().Bytes(), &createResp); err != nil { t.Fatalf("decode create response: %v", err) } if createResp.PlaintextKey != customKey { t.Fatalf("plaintext_key = %q, want host custom_key %q", createResp.PlaintextKey, customKey) } wantMasked := "sk-****" + customKey[len(customKey)-4:] if createResp.Key.MaskedPreview != wantMasked { t.Fatalf("masked_preview = %q, want %q", createResp.Key.MaskedPreview, wantMasked) } record, err := store.UserKeys().GetByID(context.Background(), createResp.Key.KeyID) if err != nil { t.Fatalf("UserKeys().GetByID() error = %v", err) } if strings.TrimSpace(record.ManagedIdentitySelector) == "" || !strings.Contains(record.ManagedIdentitySelector, createResp.Key.KeyID) { t.Fatalf("managed_identity_selector = %q, want non-empty selector tied to key id", record.ManagedIdentitySelector) } if record.KeyFingerprint != "sha256:"+sha256Hex(customKey) { t.Fatalf("key_fingerprint = %q, want sha256 of returned plaintext key", record.KeyFingerprint) } if record.MaskedPreview != wantMasked { t.Fatalf("stored masked_preview = %q, want %q", record.MaskedPreview, wantMasked) } if loginEmail == "" { t.Fatal("login email was not observed") } } type managedIdentityExpectation struct { Email string CustomKey string } func expectedManagedIdentity(selector, groupID string) managedIdentityExpectation { normalizedSelector := strings.TrimSpace(strings.ToLower(selector)) sum := sha256Hex(normalizedSelector + "|" + strings.TrimSpace(groupID)) prefix := expectedManagedPrefix(selector) shortHash := sum[:16] return managedIdentityExpectation{ Email: fmt.Sprintf("%s-%s@sub2api.local", prefix, shortHash), CustomKey: "sk-relay-" + sum[:32], } } func expectedManagedPrefix(value string) string { value = strings.ToLower(strings.TrimSpace(value)) var b strings.Builder lastDash := false for _, r := range value { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) lastDash = false case !lastDash: b.WriteByte('-') lastDash = true } } prefix := strings.Trim(b.String(), "-") if prefix == "" { prefix = "relay-sub" } if len(prefix) > 24 { prefix = strings.Trim(prefix[:24], "-") } return prefix } func TestUserKeyCreateAndResetDoNotReuseSameManagedKeyWithinSubjectGroup(t *testing.T) { t.Parallel() store := openAppTestStore(t) defer closeAppTestStore(t, store) const logicalGroupID = "gpt-shared" const hostGroupID = "999" const subjectID = "portal-user:multi" type managedUser struct { ID int64 Email string } usersByEmail := map[string]managedUser{} nextUserID := int64(100) nextKeyID := int64(500) createdCustomKeys := make([]string, 0, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): search := strings.TrimSpace(r.URL.Query().Get("search")) items := make([]map[string]any, 0, 1) if user, ok := usersByEmail[search]; ok { items = append(items, map[string]any{"id": user.ID, "email": user.Email}) } _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"items": items}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode create user request: %v", err) } email := strings.TrimSpace(fmt.Sprint(req["email"])) nextUserID++ usersByEmail[email] = managedUser{ID: nextUserID, Email: email} _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": nextUserID, "email": email}}) case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/"): _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": 1}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/101/balance": _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": 101}}) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/") && strings.HasSuffix(r.URL.Path, "/balance"): _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": 1}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/subscriptions/assign": _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": 401}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/auth/login": _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"access_token": "user-jwt"}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/keys": var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode create key request: %v", err) } customKey := strings.TrimSpace(fmt.Sprint(req["custom_key"])) createdCustomKeys = append(createdCustomKeys, customKey) nextKeyID++ _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": nextKeyID, "key": customKey, "name": fmt.Sprint(req["name"])}}) case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/admin/api-keys/"): _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"api_key": map[string]any{"id": 501}}}) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() _, _ = store.Hosts().Create(context.Background(), sqlite.Host{ HostID: "test-host", BaseURL: server.URL, HostVersion: "0.0.1", CapabilityProbeJSON: "{}", AuthType: "apikey", AuthToken: "test-token", }) _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ LogicalGroupID: logicalGroupID, DisplayName: "GPT Shared", Status: "active", }) _, _ = store.LogicalGroupRoutes().Create(context.Background(), sqlite.LogicalGroupRoute{ RouteID: "test-route", LogicalGroupID: logicalGroupID, Name: "Test Route", Status: "active", ShadowHostID: "test-host", ShadowGroupID: hostGroupID, }) handler := buildUserKeyHandler(appTestDSN(t, store), testUserKeyAuthConfig()) first, err := handler.createFn(context.Background(), CreateUserKeyRequest{SubjectID: subjectID, LogicalGroupID: logicalGroupID, DisplayName: "first", AllowedModels: []string{"gpt-5.4"}}) if err != nil { t.Fatalf("first createFn() error = %v", err) } second, err := handler.createFn(context.Background(), CreateUserKeyRequest{SubjectID: subjectID, LogicalGroupID: logicalGroupID, DisplayName: "second", AllowedModels: []string{"gpt-5.4"}}) if err != nil { t.Fatalf("second createFn() error = %v", err) } if first.PlaintextKey == second.PlaintextKey { t.Fatalf("createFn() reused plaintext key across records: first=%q second=%q", first.PlaintextKey, second.PlaintextKey) } reset, err := handler.resetFn(context.Background(), first.Key.KeyID, subjectID) if err != nil { t.Fatalf("resetFn() error = %v", err) } if reset.PlaintextKey == first.PlaintextKey { t.Fatalf("resetFn() reused original plaintext key: before=%q after=%q", first.PlaintextKey, reset.PlaintextKey) } if reset.PlaintextKey == second.PlaintextKey { t.Fatalf("resetFn() collided with sibling key: reset=%q sibling=%q", reset.PlaintextKey, second.PlaintextKey) } firstRecord, err := store.UserKeys().GetByID(context.Background(), first.Key.KeyID) if err != nil { t.Fatalf("GetByID(first) error = %v", err) } secondRecord, err := store.UserKeys().GetByID(context.Background(), second.Key.KeyID) if err != nil { t.Fatalf("GetByID(second) error = %v", err) } if firstRecord.KeyFingerprint == secondRecord.KeyFingerprint { t.Fatalf("distinct key records share fingerprint: %q", firstRecord.KeyFingerprint) } if firstRecord.KeyFingerprint != "sha256:"+sha256Hex(reset.PlaintextKey) { t.Fatalf("first record fingerprint = %q, want reset plaintext fingerprint", firstRecord.KeyFingerprint) } if len(createdCustomKeys) < 3 { t.Fatalf("createdCustomKeys len = %d, want at least 3", len(createdCustomKeys)) } if createdCustomKeys[0] == createdCustomKeys[1] || createdCustomKeys[0] == createdCustomKeys[2] { t.Fatalf("host custom keys were reused unexpectedly: %#v", createdCustomKeys) } } func TestUserKeyAPIMetricsMiddlewareAndCreateMetric(t *testing.T) { t.Parallel() handler := NewAPIHandler("t", ActionSet{ UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t)), testUserKeyAuthConfig()), }) req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("", "portal key", nil)) applyTrustedProxyAuthHeaders(req, "smoke-user") _ = httptestRecorder(handler, req) metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil) metricsResp := httptest.NewRecorder() metrics.Handler().ServeHTTP(metricsResp, metricsReq) body := metricsResp.Body.String() if !strings.Contains(body, "http_requests_total") { t.Fatal("expected metrics endpoint to expose http_requests_total after middleware-wrapped request") } if !strings.Contains(body, "user_key_operations_total") { t.Fatal("expected metrics endpoint to expose user_key_operations_total after create validation failure") } } func TestUserKeyPauseResumeDeleteLifecycleUpdatesHostAndStore(t *testing.T) { t.Parallel() store := openAppTestStore(t) defer closeAppTestStore(t, store) const logicalGroupID = "gpt-shared" const hostGroupID = "999" const subjectID = "portal-user:lifecycle" const keyID = "key_lifecycle" var allowedGroupsUpdates [][]int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): w.Write([]byte(`{"data":{"items":[{"id":84,"email":"` + expectedManagedIdentity(subjectID, hostGroupID).Email + `"}]}}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84": var payload struct { AllowedGroups []int64 `json:"allowed_groups"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode update payload: %v", err) } allowedGroupsUpdates = append(allowedGroupsUpdates, append([]int64(nil), payload.AllowedGroups...)) w.Write([]byte(`{"data":{"id":84}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() _, _ = store.Hosts().Create(context.Background(), sqlite.Host{ HostID: "test-host", BaseURL: server.URL, HostVersion: "0.0.1", CapabilityProbeJSON: "{}", AuthType: "apikey", AuthToken: "test-token", }) _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ LogicalGroupID: logicalGroupID, DisplayName: "GPT Shared", Status: "active", }) _, _ = store.LogicalGroupRoutes().Create(context.Background(), sqlite.LogicalGroupRoute{ RouteID: "test-route", LogicalGroupID: logicalGroupID, Name: "Test Route", Status: "active", ShadowHostID: "test-host", ShadowGroupID: hostGroupID, }) if _, err := store.UserKeys().Create(context.Background(), sqlite.UserKeyRecord{ KeyID: keyID, OwnerSubjectID: subjectID, KeyFingerprint: "sha256:test", MaskedPreview: "sk-****test", DisplayName: "lifecycle key", LogicalGroupID: logicalGroupID, AllowedModels: []string{"gpt-5.4"}, AdminStatus: "active", QuotaStatus: "ok", }); err != nil { t.Fatalf("UserKeys().Create() error = %v", err) } handler := buildUserKeyHandler(appTestDSN(t, store)) paused, err := handler.pauseFn(context.Background(), keyID, subjectID, "") if err != nil { t.Fatalf("pauseFn() error = %v", err) } if paused.AdminStatus != "paused" { t.Fatalf("pauseFn() admin_status = %q, want paused", paused.AdminStatus) } row, err := store.UserKeys().GetByID(context.Background(), keyID) if err != nil { t.Fatalf("UserKeys().GetByID() after pause error = %v", err) } if row.AdminStatus != "paused" { t.Fatalf("stored admin_status after pause = %q, want paused", row.AdminStatus) } resumed, err := handler.resumeFn(context.Background(), keyID, subjectID) if err != nil { t.Fatalf("resumeFn() error = %v", err) } if resumed.AdminStatus != "active" { t.Fatalf("resumeFn() admin_status = %q, want active", resumed.AdminStatus) } row, err = store.UserKeys().GetByID(context.Background(), keyID) if err != nil { t.Fatalf("UserKeys().GetByID() after resume error = %v", err) } if row.AdminStatus != "active" { t.Fatalf("stored admin_status after resume = %q, want active", row.AdminStatus) } if err := handler.deleteFn(context.Background(), keyID, subjectID); err != nil { t.Fatalf("deleteFn() error = %v", err) } row, err = store.UserKeys().GetByID(context.Background(), keyID) if err != nil { t.Fatalf("UserKeys().GetByID() after delete error = %v", err) } if row.AdminStatus != "retired" { t.Fatalf("stored admin_status after delete = %q, want retired", row.AdminStatus) } if len(allowedGroupsUpdates) != 2 { t.Fatalf("allowedGroupsUpdates len = %d, want 2", len(allowedGroupsUpdates)) } if len(allowedGroupsUpdates[0]) != 0 { t.Fatalf("pause allowed_groups = %#v, want empty", allowedGroupsUpdates[0]) } if len(allowedGroupsUpdates[1]) != 1 || allowedGroupsUpdates[1][0] != 999 { t.Fatalf("resume allowed_groups = %#v, want [999]", allowedGroupsUpdates[1]) } } func TestResolveShadowHostGroupIDByName(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/admin/groups": w.Write([]byte(`{"data":{"items":[{"id":321,"name":"group-by-name"}]}}`)) case "/api/v1/admin/channels", "/api/v1/admin/payment/plans", "/api/v1/admin/accounts": w.Write([]byte(`{"data":{"items":[]}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() client, err := newSub2APIClient(server.URL, CreateHostAuth{Type: "apikey", Token: "test-token"}) if err != nil { t.Fatalf("newSub2APIClient() error = %v", err) } groupID, err := resolveShadowHostGroupID(context.Background(), client, sqlite.LogicalGroupRoute{ShadowGroupID: "group-by-name"}) if err != nil { t.Fatalf("resolveShadowHostGroupID() error = %v", err) } if groupID != "321" { t.Fatalf("groupID = %q, want 321", groupID) } }