package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/gin-gonic/gin" "account/internal/agentserver" "account/internal/store" ) func TestAgentUsersUseAccountUUIDAsStatsEmail(t *testing.T) { gin.SetMode(gin.TestMode) st := store.NewMemoryStore() ctx := context.Background() if err := st.CreateUser(ctx, &store.User{ Name: "Stats User", Email: "stats@example.com", PasswordHash: "hashed", EmailVerified: true, Role: store.RoleUser, Level: store.LevelUser, Active: true, ProxyUUID: "proxy-user-id", }); err != nil { t.Fatalf("create user: %v", err) } user, err := st.GetUserByEmail(ctx, "stats@example.com") if err != nil { t.Fatalf("get user: %v", err) } registry, err := agentserver.NewRegistry(agentserver.Config{ Credentials: []agentserver.Credential{{ ID: "*", Name: "test-agent", Token: "agent-token", }}, }) if err != nil { t.Fatalf("new registry: %v", err) } router := gin.New() RegisterRoutes(router, WithStore(st), WithAgentRegistry(registry), WithEmailVerification(false)) req := httptest.NewRequest(http.MethodGet, "/api/agent-server/v1/users", nil) req.Header.Set("Authorization", "Bearer agent-token") req.Header.Set("X-Agent-ID", "hk-xhttp.svc.plus") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) } var payload struct { Clients []struct { ID string `json:"id"` Email string `json:"email"` } `json:"clients"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode payload: %v", err) } for _, client := range payload.Clients { if client.Email == user.ID { return } } t.Fatalf("expected stats email %q in payload, got %#v", user.ID, payload.Clients) } func TestAccountUsageAndPolicyEndpoints(t *testing.T) { gin.SetMode(gin.TestMode) st := store.NewMemoryStore() ctx := context.Background() if err := st.CreateUser(ctx, &store.User{ Name: "Billing User", Email: "billing@example.com", PasswordHash: "hashed", EmailVerified: true, Role: store.RoleUser, Level: store.LevelUser, Active: true, }); err != nil { t.Fatalf("create user: %v", err) } user, err := st.GetUserByEmail(ctx, "billing@example.com") if err != nil { t.Fatalf("get user: %v", err) } sessionToken := "usage-session-token" if err := st.CreateSession(ctx, sessionToken, user.ID, time.Now().UTC().Add(time.Hour)); err != nil { t.Fatalf("create session: %v", err) } bucketStart := time.Date(2026, 4, 1, 10, 30, 0, 0, time.UTC) if err := st.UpsertTrafficMinuteBucket(ctx, &store.TrafficMinuteBucket{ BucketStart: bucketStart, NodeID: "node-a", AccountUUID: user.ID, Region: "hk", LineCode: "premium", UplinkBytes: 128, DownlinkBytes: 256, TotalBytes: 384, Multiplier: 1.5, RatingStatus: store.RatingStatusRated, }); err != nil { t.Fatalf("upsert bucket: %v", err) } if err := st.UpsertAccountQuotaState(ctx, &store.AccountQuotaState{ AccountUUID: user.ID, RemainingIncludedQuota: 2048, CurrentBalance: 87.5, Arrears: false, ThrottleState: "normal", SuspendState: "active", EffectiveAt: time.Now().UTC(), }); err != nil { t.Fatalf("upsert quota state: %v", err) } if err := st.UpsertAccountBillingProfile(ctx, &store.AccountBillingProfile{ AccountUUID: user.ID, PackageName: "starter", IncludedQuotaBytes: 4096, BasePricePerByte: 0.125, RegionMultiplier: 1.2, LineMultiplier: 1.5, PeakMultiplier: 1.0, OffPeakMultiplier: 1.0, PricingRuleVersion: "pricing-v1", }); err != nil { t.Fatalf("upsert billing profile: %v", err) } if err := st.InsertBillingLedgerEntry(ctx, &store.BillingLedgerEntry{ ID: "ledger-1", AccountUUID: user.ID, BucketStart: bucketStart, BucketEnd: bucketStart.Add(time.Minute), EntryType: "traffic_charge", RatedBytes: 384, AmountDelta: -1.25, BalanceAfter: 87.5, PricingRuleVersion: "pricing-v1", }); err != nil { t.Fatalf("insert billing ledger: %v", err) } if err := st.UpsertAccountPolicySnapshot(ctx, &store.AccountPolicySnapshot{ AccountUUID: user.ID, PolicyVersion: "policy-v1", AuthState: "active", RateProfile: "standard", ConnProfile: "standard", EligibleNodeGroups: []string{"hk-premium"}, PreferredStrategy: "ewma", DegradeMode: "fallback", ExpiresAt: time.Now().UTC().Add(5 * time.Minute), }); err != nil { t.Fatalf("upsert policy: %v", err) } router := gin.New() RegisterRoutes(router, WithStore(st), WithEmailVerification(false)) req := httptest.NewRequest(http.MethodGet, "/api/account/usage/summary", nil) req.Header.Set("Authorization", "Bearer "+sessionToken) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("usage summary status: %d body=%s", rec.Code, rec.Body.String()) } var usagePayload struct { AccountUUID string `json:"accountUuid"` TotalBytes int64 `json:"totalBytes"` SourceOfTruth string `json:"sourceOfTruth"` BillingProfile struct { PackageName string `json:"packageName"` BasePricePerByte float64 `json:"basePricePerByte"` RegionMultiplier float64 `json:"regionMultiplier"` LineMultiplier float64 `json:"lineMultiplier"` PricingRuleVersion string `json:"pricingRuleVersion"` } `json:"billingProfile"` } if err := json.Unmarshal(rec.Body.Bytes(), &usagePayload); err != nil { t.Fatalf("decode usage payload: %v", err) } if usagePayload.AccountUUID != user.ID { t.Fatalf("expected account uuid %q, got %q", user.ID, usagePayload.AccountUUID) } if usagePayload.TotalBytes != 384 { t.Fatalf("expected total bytes 384, got %d", usagePayload.TotalBytes) } if usagePayload.SourceOfTruth != "postgresql" { t.Fatalf("expected source of truth postgresql, got %q", usagePayload.SourceOfTruth) } if usagePayload.BillingProfile.PackageName != "starter" { t.Fatalf("expected billing profile package starter, got %q", usagePayload.BillingProfile.PackageName) } bucketsReq := httptest.NewRequest(http.MethodGet, "/api/account/usage/buckets", nil) bucketsReq.Header.Set("Authorization", "Bearer "+sessionToken) bucketsRec := httptest.NewRecorder() router.ServeHTTP(bucketsRec, bucketsReq) if bucketsRec.Code != http.StatusOK { t.Fatalf("usage buckets status: %d body=%s", bucketsRec.Code, bucketsRec.Body.String()) } var bucketsPayload struct { AccountUUID string `json:"accountUuid"` SourceOfTruth string `json:"sourceOfTruth"` Buckets []struct { TotalBytes int64 `json:"totalBytes"` NodeID string `json:"nodeId"` BucketStart time.Time `json:"bucketStart"` } `json:"buckets"` } if err := json.Unmarshal(bucketsRec.Body.Bytes(), &bucketsPayload); err != nil { t.Fatalf("decode usage buckets payload: %v", err) } if bucketsPayload.AccountUUID != user.ID { t.Fatalf("expected usage buckets account uuid %q, got %q", user.ID, bucketsPayload.AccountUUID) } if bucketsPayload.SourceOfTruth != "postgresql" { t.Fatalf("expected usage buckets source of truth postgresql, got %q", bucketsPayload.SourceOfTruth) } if len(bucketsPayload.Buckets) != 1 { t.Fatalf("expected 1 usage bucket, got %d", len(bucketsPayload.Buckets)) } if bucketsPayload.Buckets[0].TotalBytes != 384 { t.Fatalf("expected usage bucket total bytes 384, got %d", bucketsPayload.Buckets[0].TotalBytes) } if bucketsPayload.Buckets[0].NodeID != "node-a" { t.Fatalf("expected usage bucket node node-a, got %q", bucketsPayload.Buckets[0].NodeID) } billingReq := httptest.NewRequest(http.MethodGet, "/api/account/billing/summary", nil) billingReq.Header.Set("Authorization", "Bearer "+sessionToken) billingRec := httptest.NewRecorder() router.ServeHTTP(billingRec, billingReq) if billingRec.Code != http.StatusOK { t.Fatalf("billing summary status: %d body=%s", billingRec.Code, billingRec.Body.String()) } var billingPayload struct { AccountUUID string `json:"accountUuid"` SourceOfTruth string `json:"sourceOfTruth"` QuotaState struct { CurrentBalance float64 `json:"currentBalance"` } `json:"quotaState"` BillingProfile struct { PackageName string `json:"packageName"` IncludedQuotaBytes int64 `json:"includedQuotaBytes"` BasePricePerByte float64 `json:"basePricePerByte"` } `json:"billingProfile"` Ledger []struct { ID string `json:"id"` EntryType string `json:"entryType"` RatedBytes int64 `json:"ratedBytes"` AmountDelta float64 `json:"amountDelta"` } `json:"ledger"` } if err := json.Unmarshal(billingRec.Body.Bytes(), &billingPayload); err != nil { t.Fatalf("decode billing payload: %v", err) } if billingPayload.AccountUUID != user.ID { t.Fatalf("expected billing account uuid %q, got %q", user.ID, billingPayload.AccountUUID) } if billingPayload.SourceOfTruth != "postgresql" { t.Fatalf("expected billing source of truth postgresql, got %q", billingPayload.SourceOfTruth) } if billingPayload.BillingProfile.IncludedQuotaBytes != 4096 { t.Fatalf("expected billing profile included quota 4096, got %d", billingPayload.BillingProfile.IncludedQuotaBytes) } if billingPayload.QuotaState.CurrentBalance != 87.5 { t.Fatalf("expected billing current balance 87.5, got %v", billingPayload.QuotaState.CurrentBalance) } if len(billingPayload.Ledger) != 1 { t.Fatalf("expected 1 billing ledger entry, got %d", len(billingPayload.Ledger)) } if billingPayload.Ledger[0].ID != "ledger-1" { t.Fatalf("expected billing ledger id ledger-1, got %q", billingPayload.Ledger[0].ID) } if billingPayload.Ledger[0].EntryType != "traffic_charge" { t.Fatalf("expected billing entry type traffic_charge, got %q", billingPayload.Ledger[0].EntryType) } if billingPayload.Ledger[0].RatedBytes != 384 { t.Fatalf("expected billing rated bytes 384, got %d", billingPayload.Ledger[0].RatedBytes) } policyReq := httptest.NewRequest(http.MethodGet, "/api/account/policy", nil) policyReq.Header.Set("Authorization", "Bearer "+sessionToken) policyRec := httptest.NewRecorder() router.ServeHTTP(policyRec, policyReq) if policyRec.Code != http.StatusOK { t.Fatalf("policy status: %d body=%s", policyRec.Code, policyRec.Body.String()) } var policyPayload struct { AccountUUID string `json:"accountUuid"` PreferredStrategy string `json:"preferredStrategy"` EligibleNodeGroups []string `json:"eligibleNodeGroups"` } if err := json.Unmarshal(policyRec.Body.Bytes(), &policyPayload); err != nil { t.Fatalf("decode policy payload: %v", err) } if policyPayload.AccountUUID != user.ID { t.Fatalf("expected policy account uuid %q, got %q", user.ID, policyPayload.AccountUUID) } if policyPayload.PreferredStrategy != "ewma" { t.Fatalf("expected preferred strategy ewma, got %q", policyPayload.PreferredStrategy) } if len(policyPayload.EligibleNodeGroups) != 1 || policyPayload.EligibleNodeGroups[0] != "hk-premium" { t.Fatalf("unexpected eligible node groups %#v", policyPayload.EligibleNodeGroups) } } func TestInternalNetworkIdentitiesEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("INTERNAL_SERVICE_TOKEN", "test-internal-token") st := store.NewMemoryStore() ctx := context.Background() if err := st.CreateUser(ctx, &store.User{ Name: "Exporter User", Email: "exporter@example.com", PasswordHash: "hashed", EmailVerified: true, Role: store.RoleUser, Level: store.LevelUser, Active: true, ProxyUUID: "proxy-exporter-id", }); err != nil { t.Fatalf("create user: %v", err) } if err := st.CreateUser(ctx, &store.User{ Name: "Inactive User", Email: "inactive@example.com", PasswordHash: "hashed", EmailVerified: true, Role: store.RoleUser, Level: store.LevelUser, Active: false, ProxyUUID: "proxy-inactive-id", }); err != nil { t.Fatalf("create inactive user: %v", err) } router := gin.New() RegisterRoutes(router, WithStore(st), WithEmailVerification(false)) req := httptest.NewRequest(http.MethodGet, "/api/internal/network/identities", nil) req.Header.Set("X-Service-Token", os.Getenv("INTERNAL_SERVICE_TOKEN")) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("internal identities status: %d body=%s", rec.Code, rec.Body.String()) } var payload struct { Identities []struct { UUID string `json:"uuid"` Email string `json:"email"` AccountUUID string `json:"accountUuid"` } `json:"identities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode internal identities payload: %v", err) } foundExporter := false for _, identity := range payload.Identities { if identity.Email != "exporter@example.com" { continue } foundExporter = true if identity.UUID == "" { t.Fatalf("expected exporter UUID to be populated") } if identity.AccountUUID == "" { t.Fatalf("expected account uuid to be populated") } } if !foundExporter { t.Fatalf("expected exporter identity in payload, got %#v", payload.Identities) } }