Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/app/server_test.go
2026-07-27 16:46:15 +08:00

434 lines
17 KiB
Go

package app
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestWithRequestTimeoutAddsContextDeadline(t *testing.T) {
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deadline, ok := r.Context().Deadline()
if !ok {
t.Fatal("request context should have deadline")
}
if time.Until(deadline) > 250*time.Millisecond {
t.Fatalf("deadline too far away: %s", time.Until(deadline))
}
w.WriteHeader(http.StatusNoContent)
}), 200*time.Millisecond)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/ops/health", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
func TestProductionDataModeFailsClosedWithoutMySQL(t *testing.T) {
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ops/health", nil))
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "DATA_STORE_UNAVAILABLE") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAlertNotificationConfigExposesOnlyReadyChannelsAndTargetReferences(t *testing.T) {
cfg := config.Config{
AlertNotificationTargetsJSON: `[{"id":"night-shift","label":"夜班负责人","channels":["sms","wecom"]}]`,
AlertNotificationSMSURL: "https://gateway.example.test/sms",
AlertNotificationSMSSecret: "signing-secret",
AlertNotificationEmailURL: "https://gateway.example.test/email",
}
result := alertNotificationConfig(cfg, "production")
if len(result.Channels) != 4 || !result.Channels[0].Configured || !result.Channels[1].Configured || result.Channels[2].Configured || result.Channels[3].Configured {
t.Fatalf("gateway readiness must require both endpoint and secret: %+v", result.Channels)
}
if len(result.Targets) != 2 || result.Targets[0].ID != "platform-operators" || result.Targets[1].ID != "night-shift" {
t.Fatalf("target catalog was not normalized: %+v", result.Targets)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "signing-secret") || strings.Contains(string(encoded), "gateway.example.test") {
t.Fatalf("public notification config leaked gateway credentials: %s", encoded)
}
}
func TestOpenPlatformDocsAreServedOnlyByStandaloneService(t *testing.T) {
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
for _, path := range []string{"/open-api/docs/", "/open-api/swagger/", "/open-api/openapi.yaml"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("internal service unexpectedly served standalone docs: path=%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
}
func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) {
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}), time.Millisecond)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/ops/health", nil)
req.Header.Set("X-Trace-Id", "trace-from-test")
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
TraceID string `json:"traceId"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("timeout response should be JSON: %v body=%s", err, rec.Body.String())
}
if body.Error.Code != "REQUEST_TIMEOUT" || body.Error.Message != "请求处理超时" || body.TraceID != "trace-from-test" {
t.Fatalf("unexpected timeout envelope: %+v body=%s", body, rec.Body.String())
}
}
func TestAppConfigScriptIsRuntimeRendered(t *testing.T) {
handler := withAppConfig(http.NotFoundHandler(), config.Config{
AMapWebJSKey: "web-key",
AMapAPIKey: "server-api-key",
AMapSecurityCode: "security-code",
AMapServiceHost: "/_AMapService",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/app-config.js", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
body := rec.Body.String()
for _, want := range []string{"window.__LINGNIU_APP_CONFIG__=", `"amapWebJsKey":"web-key"`, `"amapSecurityServiceHost":"/_AMapService"`} {
if !strings.Contains(body, want) {
t.Fatalf("app config script missing %q: %s", want, body)
}
}
if strings.Contains(body, "security-code") || strings.Contains(body, "server-api-key") || strings.Contains(body, "amapSecurityJsCode") || strings.Contains(body, "amapApiKey") {
t.Fatalf("app config script should not expose server-side AMap secrets: %s", body)
}
}
func TestAMapSecurityProxyAppendsServerSideJscode(t *testing.T) {
var gotPath string
var gotJscode string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotJscode = r.URL.Query().Get("jscode")
w.Header().Set("X-AMap-Test", "ok")
_, _ = w.Write([]byte("proxied"))
}))
defer upstream.Close()
handler := withAMapSecurityProxy(http.NotFoundHandler(), config.Config{
AMapSecurityCode: "security-code",
AMapServiceHost: "/_AMapService",
}, amapProxyUpstreams{RestAPI: upstream.URL}, http.DefaultClient)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/_AMapService/v3/weather/weatherInfo?city=440100", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if gotPath != "/v3/weather/weatherInfo" || gotJscode != "security-code" {
t.Fatalf("upstream path=%q jscode=%q", gotPath, gotJscode)
}
if rec.Body.String() != "proxied" || rec.Header().Get("X-AMap-Test") != "ok" {
t.Fatalf("proxy response not copied: headers=%v body=%s", rec.Header(), rec.Body.String())
}
}
func TestAMapSecurityProxyRoutesKnownMapResources(t *testing.T) {
seen := map[string]string{}
newUpstream := func(name string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen[name] = r.URL.Path
_, _ = w.Write([]byte(name))
}))
}
rest := newUpstream("rest")
webapi := newUpstream("webapi")
fmap := newUpstream("fmap")
defer rest.Close()
defer webapi.Close()
defer fmap.Close()
handler := withAMapSecurityProxy(http.NotFoundHandler(), config.Config{
AMapSecurityCode: "security-code",
AMapServiceHost: "/_AMapService",
}, amapProxyUpstreams{RestAPI: rest.URL, WebAPI: webapi.URL, FMap: fmap.URL}, http.DefaultClient)
for _, path := range []string{
"/_AMapService/v4/map/styles",
"/_AMapService/v3/vectormap",
"/_AMapService/v3/weather/weatherInfo",
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
}
}
if seen["webapi"] != "/v4/map/styles" || seen["fmap"] != "/v3/vectormap" || seen["rest"] != "/v3/weather/weatherInfo" {
t.Fatalf("unexpected proxy routing: %+v", seen)
}
}
func TestAMapReverseGeocodeAPIUsesServerSideKey(t *testing.T) {
var gotKey string
var gotLocation string
var upstreamCalls int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalls++
gotKey = r.URL.Query().Get("key")
gotLocation = r.URL.Query().Get("location")
if r.URL.Path != "/v3/geocode/regeo" {
t.Fatalf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{
"status":"1",
"info":"OK",
"regeocode":{
"formatted_address":"广东省广州市天河区测试路",
"addressComponent":{
"province":"广东省",
"city":"广州市",
"district":"天河区",
"township":"五山街道",
"adcode":"440106"
}
}
}`))
}))
defer upstream.Close()
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, upstream.URL, http.DefaultClient)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.1234567&latitude=23.7654321", nil)
req.Header.Set("X-Trace-Id", "trace-map")
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Reverse-Geocode-Cache") != "MISS" {
t.Fatalf("first response cache header = %q", rec.Header().Get("X-Reverse-Geocode-Cache"))
}
mapLongitude, mapLatitude := wgs84ToGCJ02(113.1234567, 23.7654321)
if gotKey != "server-api-key" || gotLocation != fmt.Sprintf("%.6f,%.6f", mapLongitude, mapLatitude) {
t.Fatalf("key=%q location=%q", gotKey, gotLocation)
}
var body struct {
Data struct {
Provider string `json:"provider"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
FormattedAddress string `json:"formattedAddress"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Township string `json:"township"`
Adcode string `json:"adcode"`
} `json:"data"`
TraceID string `json:"traceId"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response should be JSON: %v body=%s", err, rec.Body.String())
}
if body.TraceID != "trace-map" || body.Data.Provider != "AMap" || body.Data.FormattedAddress != "广东省广州市天河区测试路" || body.Data.Adcode != "440106" {
t.Fatalf("unexpected reverse geocode body: %+v", body)
}
secondLongitude, secondLatitude := 113.1234571, 23.7654324
secondRecorder := httptest.NewRecorder()
secondRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/map/reverse-geocode?longitude=%.7f&latitude=%.7f", secondLongitude, secondLatitude), nil)
secondRequest.Header.Set("X-Trace-Id", "trace-map-cache")
handler.ServeHTTP(secondRecorder, secondRequest)
if secondRecorder.Code != http.StatusOK || secondRecorder.Header().Get("X-Reverse-Geocode-Cache") != "HIT" {
t.Fatalf("cached status=%d header=%q body=%s", secondRecorder.Code, secondRecorder.Header().Get("X-Reverse-Geocode-Cache"), secondRecorder.Body.String())
}
var cachedBody struct {
Data mapReverseGeocodeResponse `json:"data"`
TraceID string `json:"traceId"`
}
if err := json.Unmarshal(secondRecorder.Body.Bytes(), &cachedBody); err != nil {
t.Fatalf("cached response should be JSON: %v", err)
}
if upstreamCalls != 1 || cachedBody.TraceID != "trace-map-cache" || cachedBody.Data.Longitude != secondLongitude || cachedBody.Data.Latitude != secondLatitude {
t.Fatalf("upstreamCalls=%d cachedBody=%+v", upstreamCalls, cachedBody)
}
}
func TestReverseGeocodeCacheEvictsLeastRecentlyUsedAndExpires(t *testing.T) {
clock := time.Date(2026, 7, 16, 2, 0, 0, 0, time.UTC)
cache := newReverseGeocodeCache(2, time.Hour)
cache.now = func() time.Time { return clock }
cache.put("a", mapReverseGeocodeResponse{FormattedAddress: "A"})
cache.put("b", mapReverseGeocodeResponse{FormattedAddress: "B"})
if _, ok := cache.get("a"); !ok {
t.Fatal("recent entry a should exist")
}
cache.put("c", mapReverseGeocodeResponse{FormattedAddress: "C"})
if _, ok := cache.get("b"); ok {
t.Fatal("least recently used entry b should be evicted")
}
if _, ok := cache.get("a"); !ok {
t.Fatal("recent entry a should survive capacity eviction")
}
clock = clock.Add(time.Hour + time.Nanosecond)
if _, ok := cache.get("a"); ok {
t.Fatal("entry a should expire after the TTL")
}
}
func TestAMapReverseGeocodeAPICollapsesConcurrentCacheMisses(t *testing.T) {
var upstreamCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamCalls.Add(1)
time.Sleep(20 * time.Millisecond)
_, _ = w.Write([]byte(`{"status":"1","regeocode":{"formatted_address":"并发测试地址","addressComponent":{}}}`))
}))
defer upstream.Close()
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, upstream.URL, http.DefaultClient)
const workers = 8
start := make(chan struct{})
results := make(chan int, workers)
var wait sync.WaitGroup
wait.Add(workers)
for index := 0; index < workers; index++ {
go func() {
defer wait.Done()
<-start
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.123456&latitude=23.123456", nil))
results <- recorder.Code
}()
}
close(start)
wait.Wait()
close(results)
for status := range results {
if status != http.StatusOK {
t.Fatalf("concurrent response status = %d", status)
}
}
if upstreamCalls.Load() != 1 {
t.Fatalf("upstream calls = %d, want 1", upstreamCalls.Load())
}
}
func TestAMapReverseGeocodeAPIDoesNotCacheFailures(t *testing.T) {
var upstreamCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if upstreamCalls.Add(1) == 1 {
http.Error(w, "temporary failure", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"status":"1","regeocode":{"formatted_address":"恢复地址","addressComponent":{}}}`))
}))
defer upstream.Close()
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, upstream.URL, http.DefaultClient)
first := httptest.NewRecorder()
handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.12&latitude=23.12", nil))
second := httptest.NewRecorder()
handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.12&latitude=23.12", nil))
if first.Code != http.StatusBadGateway || second.Code != http.StatusOK || upstreamCalls.Load() != 2 {
t.Fatalf("first=%d second=%d upstreamCalls=%d", first.Code, second.Code, upstreamCalls.Load())
}
}
func TestAMapReverseGeocodeAPIRequiresServerSideKey(t *testing.T) {
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{}, "https://example.com", http.DefaultClient)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.1&latitude=23.1", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "AMAP_API_UNCONFIGURED") {
t.Fatalf("body should mention missing AMap API config: %s", rec.Body.String())
}
}
func TestAMapReverseGeocodeAPIRejectsBadCoordinate(t *testing.T) {
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, "https://example.com", http.DefaultClient)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=0&latitude=0", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "BAD_COORDINATE") {
t.Fatalf("body should mention bad coordinate: %s", rec.Body.String())
}
}
func TestServerRequiresAuthenticationForAMapReverseGeocode(t *testing.T) {
handler := NewServer(config.Config{
AuthMode: "enforce",
AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`,
DataMode: "mock",
AMapAPIKey: "server-api-key",
RequestTimeout: time.Second,
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=121.47&latitude=31.23", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestServerDoesNotExposeOpenPlatformPublicDataRoutes(t *testing.T) {
handler := NewServer(config.Config{
AuthMode: "enforce",
AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`,
DataMode: "mock",
RequestTimeout: time.Second,
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/vehicles/hydrogen-consumption/query", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("internal server should not expose anonymous open-platform data routes: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}