-
Notifications
You must be signed in to change notification settings - Fork 31
Expand file tree
/
Copy pathhuma_api_test.go
More file actions
313 lines (285 loc) · 9.28 KB
/
Copy pathhuma_api_test.go
File metadata and controls
313 lines (285 loc) · 9.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golbat/config"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
"github.com/danielgtaylor/huma/v2/humatest"
"github.com/gin-gonic/gin"
gojson "github.com/goccy/go-json"
)
func TestHumaConfigUsesGoccy(t *testing.T) {
cfg := newHumaConfig("test")
f, ok := cfg.Formats["application/json"]
if !ok || f.Marshal == nil {
t.Fatal("application/json format not configured")
}
var buf bytes.Buffer
if err := f.Marshal(&buf, map[string]int{"a": 1}); err != nil {
t.Fatalf("marshal: %v", err)
}
want, _ := gojson.Marshal(map[string]int{"a": 1})
if got := bytes.TrimSpace(buf.Bytes()); !bytes.Equal(got, want) {
t.Errorf("configured marshaler output = %s, want %s", got, want)
}
}
func TestHumaConfigDeclaresSecurityScheme(t *testing.T) {
cfg := newHumaConfig("test")
if cfg.Components == nil || cfg.Components.SecuritySchemes == nil {
t.Fatal("no security schemes configured")
}
scheme, ok := cfg.Components.SecuritySchemes["golbatSecret"]
if !ok {
t.Fatal("golbatSecret scheme missing")
}
if scheme.Type != "apiKey" || scheme.In != "header" || scheme.Name != "X-Golbat-Secret" {
t.Errorf("unexpected scheme: %+v", scheme)
}
}
// registerSecretTestOps registers a secured /secure operation (declaring the
// golbatSecret security requirement) and an unsecured /open operation on api.
func registerSecretTestOps(api huma.API) {
type emptyOut struct {
Body struct{}
}
handler := func(ctx context.Context, _ *struct{}) (*emptyOut, error) {
return &emptyOut{}, nil
}
huma.Register(api, huma.Operation{
OperationID: "secure",
Method: http.MethodGet,
Path: "/secure",
Security: []map[string][]string{{securitySchemeName: {}}},
}, handler)
huma.Register(api, huma.Operation{
OperationID: "open",
Method: http.MethodGet,
Path: "/open",
}, handler)
}
func TestHumaSecretMiddleware(t *testing.T) {
prev := config.Config.ApiSecret
config.Config.ApiSecret = "topsecret"
defer func() { config.Config.ApiSecret = prev }()
_, api := humatest.New(t, newHumaConfig("test"))
api.UseMiddleware(golbatSecretMiddleware(api))
registerSecretTestOps(api)
t.Run("secure without header is 401", func(t *testing.T) {
resp := api.Get("/secure")
if resp.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", resp.Code)
}
})
t.Run("secure with wrong header is 401", func(t *testing.T) {
resp := api.Get("/secure", "X-Golbat-Secret: wrong")
if resp.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", resp.Code)
}
})
t.Run("secure with correct header is 200", func(t *testing.T) {
resp := api.Get("/secure", "X-Golbat-Secret: topsecret")
if resp.Code != http.StatusOK {
t.Errorf("got %d, want 200", resp.Code)
}
})
t.Run("open without header is 200", func(t *testing.T) {
resp := api.Get("/open")
if resp.Code != http.StatusOK {
t.Errorf("got %d, want 200", resp.Code)
}
})
}
func TestHumaSecretMiddlewareDisabledWhenSecretEmpty(t *testing.T) {
prev := config.Config.ApiSecret
config.Config.ApiSecret = ""
defer func() { config.Config.ApiSecret = prev }()
_, api := humatest.New(t, newHumaConfig("test"))
api.UseMiddleware(golbatSecretMiddleware(api))
registerSecretTestOps(api)
resp := api.Get("/secure")
if resp.Code != http.StatusOK {
t.Errorf("auth-disabled: got %d, want 200", resp.Code)
}
}
func TestOpenAPISpecIsDiscoverable(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
api := humagin.New(r, newHumaConfig("test"))
registerHumaRoutes(api)
spec, err := api.OpenAPI().YAML()
if err != nil {
t.Fatalf("YAML: %v", err)
}
s := string(spec)
for _, want := range []string{
"scan-pokemon-v2",
"scan-pokemon-v3",
"ApiPvpRankings",
"ApiPvpEntry",
"ApiPokemonResult",
"golbatSecret",
"X-Golbat-Secret",
} {
if !strings.Contains(s, want) {
t.Errorf("OpenAPI spec missing %q", want)
}
}
}
// TestScanRequestRequiredFields pins the per-field required/optional contract for
// the scan request schemas: only the bounding box (min/max) is required at the top
// level; filter attributes are all optional. Range objects accept partial bounds
// for legacy wire compatibility (an omitted bound binds to 0, as gin BindJSON did).
func TestScanRequestRequiredFields(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
api := humagin.New(r, newHumaConfig("test"))
registerHumaRoutes(api)
registerFortScanRoutes(api)
registerPokemonReadRoutes(api)
registerTier3Routes(api)
raw, err := gojson.Marshal(api.OpenAPI())
if err != nil {
t.Fatalf("marshal openapi: %v", err)
}
var doc struct {
Components struct {
Schemas map[string]struct {
Required []string `json:"required"`
} `json:"schemas"`
} `json:"components"`
}
if err := gojson.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal openapi: %v", err)
}
required := func(name string) []string {
s, ok := doc.Components.Schemas[name]
if !ok {
names := make([]string, 0, len(doc.Components.Schemas))
for n := range doc.Components.Schemas {
names = append(names, n)
}
t.Fatalf("schema %q not found; available: %v", name, names)
}
return s.Required
}
asSet := func(ss []string) map[string]bool {
m := map[string]bool{}
for _, s := range ss {
m[s] = true
}
return m
}
wantExactly := func(name string, want ...string) {
got := asSet(required(name))
wantSet := asSet(want)
if len(got) != len(wantSet) {
t.Errorf("%s required = %v, want exactly %v", name, required(name), want)
return
}
for w := range wantSet {
if !got[w] {
t.Errorf("%s required = %v, missing %q", name, required(name), w)
}
}
}
// Bounding box required; limit/filters optional.
wantExactly("ApiPokemonScan2", "min", "max")
wantExactly("ApiPokemonScan3", "min", "max")
// Range bounds are optional for legacy compatibility: gin BindJSON accepted
// a lone min/max (the missing bound bound to 0).
wantExactly("ApiPokemonDnfMinMax")
// Filter attributes are all optional.
wantExactly("ApiPokemonDnfFilter")
wantExactly("ApiPokemonDnfFilter3")
// Within a pokemon selector, id is required (a form without an id can never
// match); form stays optional.
wantExactly("ApiPokemonDnfId", "id")
// Fort ranges mirror the pokemon ranges.
wantExactly("ApiFortDnfMinMax")
// Gym search: limit and every filter field are optional (each filter field is
// an independent alternative); the handler enforces "at least one filter".
wantExactly("ApiGymSearch")
wantExactly("ApiGymSearchFilter")
// Pokemon search: min/max optional to keep the legacy center-only mode.
wantExactly("ApiPokemonSearch")
}
// TestApiDocsGating pins the api_docs config contract: when enabled (the
// default), /openapi.json and /docs are served without the api secret; when
// disabled, the routes are not registered at all.
func TestApiDocsGating(t *testing.T) {
prevDocs := config.Config.ApiDocs
prevSecret := config.Config.ApiSecret
config.Config.ApiSecret = "topsecret"
defer func() {
config.Config.ApiDocs = prevDocs
config.Config.ApiSecret = prevSecret
}()
serve := func(path string) int {
gin.SetMode(gin.TestMode)
r := gin.New()
api := humagin.New(r, newHumaConfig("test"))
api.UseMiddleware(golbatSecretMiddleware(api))
registerHumaRoutes(api)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w.Code
}
config.Config.ApiDocs = true
for _, path := range []string{"/openapi.json", "/docs"} {
if code := serve(path); code != http.StatusOK {
t.Errorf("api_docs=true: GET %s = %d, want 200 without the secret header", path, code)
}
}
config.Config.ApiDocs = false
for _, path := range []string{"/openapi.json", "/docs"} {
if code := serve(path); code != http.StatusNotFound {
t.Errorf("api_docs=false: GET %s = %d, want 404", path, code)
}
}
}
// TestLatLonSchemaDocumentsOnlyLatLon asserts the OpenAPI advertises only the
// canonical lat/lon spelling, even though latitude/longitude is still accepted at
// runtime (see decoder.ApiLatLon.UnmarshalJSON and TestHumaScanAcceptsLatLonSpellings).
func TestLatLonSchemaDocumentsOnlyLatLon(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
api := humagin.New(r, newHumaConfig("test"))
registerHumaRoutes(api)
raw, err := gojson.Marshal(api.OpenAPI())
if err != nil {
t.Fatalf("marshal openapi: %v", err)
}
// ApiLatLon is a SchemaProvider returning an unregistered schema, so Huma
// inlines it into the min/max properties rather than as a named component.
var doc struct {
Components struct {
Schemas map[string]struct {
Properties map[string]struct {
Properties map[string]any `json:"properties"`
} `json:"properties"`
} `json:"schemas"`
} `json:"components"`
}
if err := gojson.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal openapi: %v", err)
}
coord := doc.Components.Schemas["ApiPokemonScan2"].Properties["min"].Properties
if coord == nil {
t.Fatalf("ApiPokemonScan2.min has no inlined coordinate properties")
}
for _, want := range []string{"lat", "lon"} {
if _, ok := coord[want]; !ok {
t.Errorf("coordinate schema should document %q; properties=%v", want, coord)
}
}
for _, notWant := range []string{"latitude", "longitude"} {
if _, ok := coord[notWant]; ok {
t.Errorf("coordinate schema must NOT advertise %q; properties=%v", notWant, coord)
}
}
}