@stackonward/cli 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/index.js +8 -8
  2. package/dist/index.js.map +1 -1
  3. package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +14 -2
  4. package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +3 -1
  5. package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +2 -2
  6. package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +2 -2
  7. package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +2 -2
  8. package/dist/resources/components/nuxt/site-source/app/composables/useWebPush.ts +4 -0
  9. package/dist/resources/components/nuxt/site-source/app/plugins/web-push.ts +52 -0
  10. package/dist/resources/components/nuxt/site-source/app/theme/button.ts +18 -22
  11. package/dist/resources/components/nuxt/site-source/app/theme/index.ts +24 -0
  12. package/dist/resources/components/nuxt/site-source/app/theme/input.ts +32 -17
  13. package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +1 -1
  14. package/dist/resources/components/nuxt/site-source/app/theme/select.ts +62 -18
  15. package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +73 -1
  16. package/dist/resources/components/nuxt/site-source/app/web-push/worker.ts +8 -0
  17. package/dist/resources/components/registry.json +11 -1
  18. package/dist/resources/registry.json +15 -3
  19. package/dist/resources/templates/base/{.claude → .codex}/docs/architecture-template.md +1 -1
  20. package/dist/resources/templates/base/{.claude → .codex}/docs/architecture.md +1 -1
  21. package/dist/resources/templates/base/{.claude → .codex}/docs/index.md +3 -3
  22. package/dist/resources/templates/base/{.claude → .codex}/docs/verification.md +1 -1
  23. package/dist/resources/templates/base/AGENTS.md +24 -24
  24. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/api-design.md +1 -1
  25. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/architecture-template.md +1 -1
  26. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/check-list.md +3 -3
  27. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/database.md +1 -1
  28. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/error-handling.md +1 -1
  29. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/index.md +1 -1
  30. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/logging.md +1 -1
  31. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-onboarding-sop.md +2 -2
  32. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-quick-reference.md +1 -1
  33. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/module-standards.md +1 -1
  34. package/dist/resources/templates/go-backend/.dockerignore +1 -2
  35. package/dist/resources/templates/go-backend/AGENTS.md +19 -19
  36. package/dist/resources/templates/go-backend/README.md +5 -1
  37. package/dist/resources/templates/go-backend/deployments/onex/product-onboarding.example.json +201 -0
  38. package/dist/resources/templates/go-backend/docs/onex-notifications.md +85 -0
  39. package/dist/resources/templates/go-backend/docs/openapi-provenance.json +1 -1
  40. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +23 -2
  41. package/dist/resources/templates/go-backend/docs/source-provenance.json +124 -6
  42. package/dist/resources/templates/go-backend/docs/standards-provenance.json +30 -30
  43. package/dist/resources/templates/go-backend/internal/common/middleware/cors.go +1 -0
  44. package/dist/resources/templates/go-backend/internal/common/middleware/middleware_test.go +1 -0
  45. package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post.go +28 -0
  46. package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post_test.go +56 -0
  47. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client.go +453 -0
  48. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client_test.go +772 -0
  49. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/codec.go +33 -0
  50. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error.go +146 -0
  51. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error_notification_test.go +29 -0
  52. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client.go +132 -0
  53. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client_test.go +222 -0
  54. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/contracts.go +219 -0
  55. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/resilience.go +23 -0
  56. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/transport_policy.go +96 -0
  57. package/dist/resources/templates/go-backend/internal/products/README.md +1 -1
  58. package/dist/resources/templates/go-backend/internal/sharedevents/README.md +1 -1
  59. package/dist/resources/templates/go-backend/scripts/check-openapi-clean-deletion.sh +3 -3
  60. package/dist/resources/templates/mcp-server/.prettierignore +1 -0
  61. package/dist/resources/templates/nuxt/{.claude → .codex}/docs/stack.md +17 -1
  62. package/dist/resources/templates/nuxt/.dockerignore +1 -1
  63. package/dist/resources/templates/nuxt/.env.example +6 -0
  64. package/dist/resources/templates/nuxt/.prettierignore +2 -1
  65. package/dist/resources/templates/nuxt/DESIGN.md +55 -10
  66. package/dist/resources/templates/nuxt/README.md +8 -0
  67. package/dist/resources/templates/nuxt/nuxt.config.ts +13 -1
  68. package/dist/resources/templates/nuxt/package.json +4 -2
  69. package/package.json +1 -1
  70. package/dist/resources/templates/base/CLAUDE.md +0 -437
  71. package/dist/resources/templates/go-backend/CLAUDE.md +0 -1139
  72. /package/dist/resources/templates/base/{.claude → .codex}/docs/security-lifecycle.md +0 -0
  73. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/e2e-standards.md +0 -0
  74. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/event-runtime-boundary.md +0 -0
  75. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/identifier-contract.md +0 -0
  76. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/openapi-contract.md +0 -0
  77. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/payment-gateway-onboarding.md +0 -0
  78. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/platform-boundary.md +0 -0
  79. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/quality-gates.md +0 -0
  80. /package/dist/resources/templates/mcp-server/{.claude → .codex}/docs/stack.md +0 -0
@@ -0,0 +1,772 @@
1
+ package v1
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "net/url"
10
+ "strings"
11
+ "sync/atomic"
12
+ "testing"
13
+ "time"
14
+
15
+ "go.uber.org/zap"
16
+
17
+ "{{module}}/internal/common/types"
18
+ "{{module}}/internal/core/httpclient"
19
+ )
20
+
21
+ type signerFixture struct{}
22
+
23
+ func (signerFixture) AuthHeaders(context.Context) (map[string]string, error) {
24
+ return map[string]string{"Authorization": "Bearer signed-service-token"}, nil
25
+ }
26
+
27
+ func (signerFixture) AuthorizeRequest(context.Context, *http.Request) error { return nil }
28
+
29
+ type recordingRetryWaiter struct{ delays []time.Duration }
30
+
31
+ func (waiter *recordingRetryWaiter) Wait(_ context.Context, delay time.Duration) error {
32
+ waiter.delays = append(waiter.delays, delay)
33
+ return nil
34
+ }
35
+
36
+ type signerHeadersFixture map[string]string
37
+
38
+ func (fixture signerHeadersFixture) AuthHeaders(context.Context) (map[string]string, error) {
39
+ return fixture, nil
40
+ }
41
+
42
+ func (signerHeadersFixture) AuthorizeRequest(context.Context, *http.Request) error { return nil }
43
+
44
+ type failingSignerFixture struct{ err error }
45
+
46
+ func (fixture failingSignerFixture) AuthHeaders(context.Context) (map[string]string, error) {
47
+ return nil, fixture.err
48
+ }
49
+
50
+ func (failingSignerFixture) AuthorizeRequest(context.Context, *http.Request) error { return nil }
51
+
52
+ func TestClientAddsTrustedHeadersAndStrictlyDecodesResponse(t *testing.T) {
53
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
54
+ if request.URL.Path != "/internal/v1/entitlements/decisions" || request.Header.Get("Authorization") != "Bearer signed-service-token" || request.Header.Get(types.HeaderOneXProductCode) != "sample_product" || request.Header.Get(types.HeaderInternalUserID) != "42" || request.Header.Get(types.HeaderIdempotencyKey) != "command_1234567890" || request.Header.Get("Idempotency-Key") != "" || request.Header.Get(types.HeaderRequestID) != "request-01" {
55
+ t.Fatalf("trusted request contract mismatch: path=%q headers=%v", request.URL.Path, request.Header)
56
+ }
57
+ writer.Header().Set("Content-Type", "application/json")
58
+ writer.WriteHeader(http.StatusOK)
59
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
60
+ }))
61
+ defer server.Close()
62
+
63
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product"})
64
+ if err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ var output struct {
68
+ Value string `json:"value"`
69
+ }
70
+ err = client.Do(context.Background(), Call{Method: http.MethodPost, Path: "/entitlements/decisions", UserID: 42, RequestID: "request-01", IdempotencyKey: "command_1234567890", Body: struct{}{}, ExpectedStatuses: []int{http.StatusOK}}, &output)
71
+ if err != nil || output.Value != "accepted" {
72
+ t.Fatalf("Do() = %#v, %v", output, err)
73
+ }
74
+ }
75
+
76
+ func TestClientRetriesOnlyConfiguredSafeGET(t *testing.T) {
77
+ var attempts atomic.Int32
78
+ var requestIDs []string
79
+ waiter := &recordingRetryWaiter{}
80
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
81
+ attempt := attempts.Add(1)
82
+ requestIDs = append(requestIDs, request.Header.Get(types.HeaderRequestID))
83
+ writer.Header().Set("Content-Type", "application/json")
84
+ if attempt < 3 {
85
+ writer.WriteHeader(http.StatusServiceUnavailable)
86
+ _, _ = writer.Write([]byte(`{"error":{"type":"api_error","code":"service_unavailable","message":"Unavailable"}}`))
87
+ return
88
+ }
89
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
90
+ }))
91
+ t.Cleanup(server.Close)
92
+
93
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
94
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second,
95
+ SafeReadRetry: SafeReadRetryConfig{MaxRetries: 2, InitialBackoff: time.Millisecond, MaxBackoff: 2 * time.Millisecond},
96
+ RetryWaiter: waiter,
97
+ })
98
+ if err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ var output struct {
102
+ Value string `json:"value"`
103
+ }
104
+ if err := client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", RequestID: "request-safe-read", ExpectedStatuses: []int{http.StatusOK}}, &output); err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ if attempts.Load() != 3 || len(waiter.delays) != 2 || output.Value != "accepted" {
108
+ t.Fatalf("attempts=%d delays=%v output=%#v", attempts.Load(), waiter.delays, output)
109
+ }
110
+ for _, requestID := range requestIDs {
111
+ if requestID != "request-safe-read" {
112
+ t.Fatalf("request IDs = %#v", requestIDs)
113
+ }
114
+ }
115
+ }
116
+
117
+ func TestClientHonorsRetryAfterForSafeGET(t *testing.T) {
118
+ var attempts atomic.Int32
119
+ waiter := &recordingRetryWaiter{}
120
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
121
+ writer.Header().Set("Content-Type", "application/json")
122
+ if attempts.Add(1) == 1 {
123
+ writer.Header().Set(types.HeaderRetryAfter, "2")
124
+ writer.WriteHeader(http.StatusTooManyRequests)
125
+ _, _ = writer.Write([]byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"Rate limited"}}`))
126
+ return
127
+ }
128
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
129
+ }))
130
+ t.Cleanup(server.Close)
131
+
132
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
133
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: 5 * time.Second,
134
+ SafeReadRetry: SafeReadRetryConfig{MaxRetries: 1, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond},
135
+ RetryWaiter: waiter,
136
+ })
137
+ if err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ var output struct {
141
+ Value string `json:"value"`
142
+ }
143
+ if err := client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}}, &output); err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ if attempts.Load() != 2 || len(waiter.delays) != 1 || waiter.delays[0] != 2*time.Second {
147
+ t.Fatalf("attempts=%d delays=%v", attempts.Load(), waiter.delays)
148
+ }
149
+ }
150
+
151
+ func TestClientRetriesSafeGETConnectionFailure(t *testing.T) {
152
+ var attempts atomic.Int32
153
+ waiter := &recordingRetryWaiter{}
154
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
155
+ if attempts.Add(1) == 1 {
156
+ hijacker, ok := writer.(http.Hijacker)
157
+ if !ok {
158
+ t.Fatal("response writer cannot be hijacked")
159
+ }
160
+ connection, _, err := hijacker.Hijack()
161
+ if err != nil {
162
+ t.Fatal(err)
163
+ }
164
+ _ = connection.Close()
165
+ return
166
+ }
167
+ writer.Header().Set("Content-Type", "application/json")
168
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
169
+ }))
170
+ t.Cleanup(server.Close)
171
+
172
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
173
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second,
174
+ SafeReadRetry: SafeReadRetryConfig{MaxRetries: 1, InitialBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond},
175
+ RetryWaiter: waiter,
176
+ })
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ var output struct {
181
+ Value string `json:"value"`
182
+ }
183
+ if err := client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}}, &output); err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ if attempts.Load() != 2 || len(waiter.delays) != 1 || output.Value != "accepted" {
187
+ t.Fatalf("attempts=%d delays=%v output=%#v", attempts.Load(), waiter.delays, output)
188
+ }
189
+ }
190
+
191
+ func TestClientDoesNotRetryOtherHTTPStatuses(t *testing.T) {
192
+ tests := []struct {
193
+ status int
194
+ errorType string
195
+ code string
196
+ wantKind ErrorKind
197
+ }{
198
+ {status: http.StatusBadRequest, errorType: "invalid_request_error", code: "invalid_parameters", wantKind: ErrorKindValidation},
199
+ {status: http.StatusUnauthorized, errorType: "authentication_error", code: "unauthorized", wantKind: ErrorKindUnauthenticated},
200
+ {status: http.StatusForbidden, errorType: "authorization_error", code: "access_denied", wantKind: ErrorKindForbidden},
201
+ {status: http.StatusNotFound, errorType: "not_found_error", code: "resource_not_found", wantKind: ErrorKindNotFound},
202
+ {status: http.StatusConflict, errorType: "invalid_request_error", code: "invalid_operation", wantKind: ErrorKindConflict},
203
+ {status: http.StatusInternalServerError, errorType: "api_error", code: "internal_error", wantKind: ErrorKindUnavailable},
204
+ }
205
+ for _, test := range tests {
206
+ t.Run(http.StatusText(test.status), func(t *testing.T) {
207
+ var attempts atomic.Int32
208
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
209
+ attempts.Add(1)
210
+ writer.Header().Set("Content-Type", "application/json")
211
+ writer.WriteHeader(test.status)
212
+ _, _ = writer.Write([]byte(`{"error":{"type":"` + test.errorType + `","code":"` + test.code + `","message":"Safe summary"}}`))
213
+ }))
214
+ t.Cleanup(server.Close)
215
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
216
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second,
217
+ SafeReadRetry: SafeReadRetryConfig{MaxRetries: 2, InitialBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond},
218
+ })
219
+ if err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ var output struct{ Value string }
223
+ err = client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}}, &output)
224
+ var typed *Error
225
+ if !errors.As(err, &typed) || typed.Kind != test.wantKind || typed.ErrorType != test.errorType || typed.Code != test.code {
226
+ t.Fatalf("error = %#v", err)
227
+ }
228
+ if attempts.Load() != 1 {
229
+ t.Fatalf("attempts = %d", attempts.Load())
230
+ }
231
+ })
232
+ }
233
+ }
234
+
235
+ func TestClientNeverRetriesPOSTAndMarksTransportResultUnknown(t *testing.T) {
236
+ var attempts atomic.Int32
237
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
238
+ attempts.Add(1)
239
+ hijacker, ok := writer.(http.Hijacker)
240
+ if !ok {
241
+ t.Fatal("response writer cannot be hijacked")
242
+ }
243
+ connection, _, err := hijacker.Hijack()
244
+ if err != nil {
245
+ t.Fatal(err)
246
+ }
247
+ _ = connection.Close()
248
+ }))
249
+ t.Cleanup(server.Close)
250
+
251
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
252
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second,
253
+ SafeReadRetry: SafeReadRetryConfig{MaxRetries: 2, InitialBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond},
254
+ })
255
+ if err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ var output struct {
259
+ Value string `json:"value"`
260
+ }
261
+ err = client.Do(context.Background(), Call{Method: http.MethodPost, Path: "/commands", Body: struct{}{}, ExpectedStatuses: []int{http.StatusOK}}, &output)
262
+ var typed *Error
263
+ if !errors.As(err, &typed) || typed.Kind != ErrorKindResultUnknown || typed.Code != "onex_result_unknown" || !typed.Retryable {
264
+ t.Fatalf("error = %#v", err)
265
+ }
266
+ if attempts.Load() != 1 {
267
+ t.Fatalf("POST attempts = %d", attempts.Load())
268
+ }
269
+ }
270
+
271
+ func TestClientOperationDeadlineIsTypedByMethod(t *testing.T) {
272
+ for _, method := range []string{http.MethodGet, http.MethodPost} {
273
+ t.Run(method, func(t *testing.T) {
274
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
275
+ time.Sleep(50 * time.Millisecond)
276
+ writer.Header().Set("Content-Type", "application/json")
277
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
278
+ }))
279
+ t.Cleanup(server.Close)
280
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second})
281
+ if err != nil {
282
+ t.Fatal(err)
283
+ }
284
+ var output struct {
285
+ Value string `json:"value"`
286
+ }
287
+ call := Call{Method: method, Path: "/capabilities/opaque", Timeout: 10 * time.Millisecond, ExpectedStatuses: []int{http.StatusOK}}
288
+ if method == http.MethodPost {
289
+ call.Body = struct{}{}
290
+ }
291
+ err = client.Do(context.Background(), call, &output)
292
+ var typed *Error
293
+ if !errors.As(err, &typed) {
294
+ t.Fatalf("error = %#v", err)
295
+ }
296
+ wantKind := ErrorKindUnavailable
297
+ if method == http.MethodPost {
298
+ wantKind = ErrorKindResultUnknown
299
+ }
300
+ if typed.Kind != wantKind || !errors.Is(err, context.DeadlineExceeded) {
301
+ t.Fatalf("error = %#v, want kind %s deadline", err, wantKind)
302
+ }
303
+ })
304
+ }
305
+ }
306
+
307
+ func TestClientRejectsNonCanonicalSignerHeaders(t *testing.T) {
308
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
309
+ t.Fatal("request must not be sent")
310
+ }))
311
+ t.Cleanup(server.Close)
312
+ for _, headers := range []signerHeadersFixture{
313
+ {"Authorization": "Basic admin-session"},
314
+ {"Authorization": "Bearer service-token", "Cookie": "admin_session=secret"},
315
+ {"Authorization": "Bearer service-token", types.HeaderSessionID: "99"},
316
+ } {
317
+ client, err := NewClient(httpclient.New(zap.NewNop()), headers, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product"})
318
+ if err != nil {
319
+ t.Fatal(err)
320
+ }
321
+ var output struct {
322
+ Value string `json:"value"`
323
+ }
324
+ err = client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}}, &output)
325
+ var typed *Error
326
+ if !errors.As(err, &typed) || typed.Kind != ErrorKindContract || typed.Code != "onex_authentication_contract_invalid" {
327
+ t.Fatalf("headers=%v error=%#v", headers, err)
328
+ }
329
+ }
330
+ }
331
+
332
+ func TestClientMapsSignerFailuresWithoutLeakingCredentials(t *testing.T) {
333
+ for _, test := range []struct {
334
+ name string
335
+ ctx context.Context
336
+ err error
337
+ wantKind ErrorKind
338
+ }{
339
+ {name: "unavailable", ctx: context.Background(), err: errors.New("credential secret"), wantKind: ErrorKindUnavailable},
340
+ {name: "canceled", ctx: canceledContext(), err: context.Canceled, wantKind: ErrorKindCanceled},
341
+ } {
342
+ t.Run(test.name, func(t *testing.T) {
343
+ client, err := NewClient(httpclient.New(zap.NewNop()), failingSignerFixture{err: test.err}, Config{BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product"})
344
+ if err != nil {
345
+ t.Fatal(err)
346
+ }
347
+ var output struct{ Value string }
348
+ err = client.Do(test.ctx, Call{Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}}, &output)
349
+ var typed *Error
350
+ if !errors.As(err, &typed) || typed.Kind != test.wantKind || typed.Error() == "credential secret" {
351
+ t.Fatalf("error = %#v", err)
352
+ }
353
+ })
354
+ }
355
+ }
356
+
357
+ func canceledContext() context.Context {
358
+ ctx, cancel := context.WithCancel(context.Background())
359
+ cancel()
360
+ return ctx
361
+ }
362
+
363
+ func TestNewClientRejectsUnboundedOrInvalidSafeReadRetry(t *testing.T) {
364
+ tests := []Config{
365
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", SafeReadRetry: SafeReadRetryConfig{MaxRetries: 1, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond}},
366
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", Timeout: time.Second, SafeReadRetry: SafeReadRetryConfig{MaxRetries: 11, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond}},
367
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", Timeout: time.Second, SafeReadRetry: SafeReadRetryConfig{MaxRetries: 1, InitialBackoff: time.Second, MaxBackoff: time.Millisecond}},
368
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", Timeout: time.Second, OperationRetries: map[string]int{"storage.get_asset": 2}},
369
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", Timeout: time.Second, OperationRetries: map[string]int{"storage.bad.operation": 1}},
370
+ }
371
+ for _, config := range tests {
372
+ if _, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, config); err == nil {
373
+ t.Fatalf("config %#v was accepted", config)
374
+ }
375
+ }
376
+ }
377
+
378
+ func TestSafeReadRetryPolicyUsesOnlyCanonicalStatusesAndDelays(t *testing.T) {
379
+ for _, status := range []int{http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout} {
380
+ if !shouldRetrySafeReadStatus(status) {
381
+ t.Fatalf("status %d is not retryable", status)
382
+ }
383
+ }
384
+ for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusConflict, http.StatusInternalServerError} {
385
+ if shouldRetrySafeReadStatus(status) {
386
+ t.Fatalf("status %d is retryable", status)
387
+ }
388
+ }
389
+ now := time.Date(2026, time.September, 14, 12, 0, 0, 0, time.UTC)
390
+ if delay := safeReadRetryDelay(SafeReadRetryConfig{InitialBackoff: time.Second, MaxBackoff: 2 * time.Second}, 3, "", now); delay != 2*time.Second {
391
+ t.Fatalf("capped delay = %s", delay)
392
+ }
393
+ if delay := safeReadRetryDelay(SafeReadRetryConfig{InitialBackoff: time.Second, MaxBackoff: time.Second}, 1, "3", now); delay != 3*time.Second {
394
+ t.Fatalf("seconds Retry-After = %s", delay)
395
+ }
396
+ if delay := safeReadRetryDelay(SafeReadRetryConfig{InitialBackoff: time.Second, MaxBackoff: time.Second}, 1, now.Add(4*time.Second).Format(http.TimeFormat), now); delay != 4*time.Second {
397
+ t.Fatalf("date Retry-After = %s", delay)
398
+ }
399
+ if _, ok := parseRetryAfter("invalid", now); ok {
400
+ t.Fatal("invalid Retry-After was accepted")
401
+ }
402
+ if delay, ok := parseRetryAfter("-1", now); ok || delay != 0 {
403
+ t.Fatalf("negative Retry-After = %s, %v", delay, ok)
404
+ }
405
+ jittered := safeReadRetryDelay(SafeReadRetryConfig{InitialBackoff: time.Second, MaxBackoff: time.Second, JitterRatio: 0.25}, 1, "", now)
406
+ if jittered < 750*time.Millisecond || jittered > time.Second {
407
+ t.Fatalf("jittered delay = %s", jittered)
408
+ }
409
+ }
410
+
411
+ func TestTimerRetryWaiterHonorsCompletionAndCancellation(t *testing.T) {
412
+ waiter := timerRetryWaiter{}
413
+ if err := waiter.Wait(context.Background(), time.Nanosecond); err != nil {
414
+ t.Fatal(err)
415
+ }
416
+ ctx, cancel := context.WithCancel(context.Background())
417
+ cancel()
418
+ if err := waiter.Wait(ctx, time.Second); !errors.Is(err, context.Canceled) {
419
+ t.Fatalf("error = %#v", err)
420
+ }
421
+ }
422
+
423
+ type dependencyExecutorFixture struct {
424
+ attempt bool
425
+ err error
426
+ key string
427
+ }
428
+
429
+ func (f *dependencyExecutorFixture) Execute(ctx context.Context, key string, operation func(context.Context) error) (bool, error) {
430
+ f.key = key
431
+ if !f.attempt {
432
+ return false, f.err
433
+ }
434
+ return true, operation(ctx)
435
+ }
436
+
437
+ func TestClientAppliesDependencyExecutorToCompleteResponseContract(t *testing.T) {
438
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
439
+ writer.Header().Set("Content-Type", "application/json")
440
+ writer.WriteHeader(http.StatusServiceUnavailable)
441
+ _, _ = writer.Write([]byte(`{"error":{"type":"api_error","code":"service_unavailable","message":"unavailable"}}`))
442
+ }))
443
+ t.Cleanup(server.Close)
444
+ executor := &dependencyExecutorFixture{attempt: true}
445
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
446
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second, Dependency: executor,
447
+ })
448
+ if err != nil {
449
+ t.Fatal(err)
450
+ }
451
+ var output struct{ Value string }
452
+ err = client.Do(context.Background(), Call{
453
+ Method: http.MethodGet, Path: "/assets/one", Dependency: "storage", Operation: "get_asset", ExpectedStatuses: []int{http.StatusOK},
454
+ }, &output)
455
+ var typed *Error
456
+ if !errors.As(err, &typed) || typed.Kind != ErrorKindUnavailable || executor.key != "storage.get_asset" {
457
+ t.Fatalf("error=%#v key=%q", err, executor.key)
458
+ }
459
+ }
460
+
461
+ func TestClientDefaultResponseLimitAcceptsMaximumConfirmedObjectEnvelope(t *testing.T) {
462
+ body := strings.Repeat("A", maximumConfirmedObjectEncodedBytes)
463
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
464
+ writer.Header().Set("Content-Type", "application/json")
465
+ _, _ = fmt.Fprintf(writer, `{"body":%q}`, body)
466
+ }))
467
+ t.Cleanup(server.Close)
468
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
469
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product",
470
+ })
471
+ if err != nil {
472
+ t.Fatal(err)
473
+ }
474
+ var output struct {
475
+ Body string `json:"body"`
476
+ }
477
+ if err := client.Do(context.Background(), Call{
478
+ Method: http.MethodGet, Path: "/storage/assets/test/content", ExpectedStatuses: []int{http.StatusOK},
479
+ }, &output); err != nil {
480
+ t.Fatal(err)
481
+ }
482
+ if output.Body != body {
483
+ t.Fatalf("decoded body length = %d, want %d", len(output.Body), len(body))
484
+ }
485
+ }
486
+
487
+ func TestClientMapsCircuitRejectionAsNotAttemptedForMutation(t *testing.T) {
488
+ executor := &dependencyExecutorFixture{err: errors.New("circuit open")}
489
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
490
+ BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", Timeout: time.Second, Dependency: executor,
491
+ })
492
+ if err != nil {
493
+ t.Fatal(err)
494
+ }
495
+ var output struct{ Value string }
496
+ err = client.Do(context.Background(), Call{
497
+ Method: http.MethodPost, Path: "/commands", Body: struct{}{}, Dependency: "mediagen", Operation: "submit_execution", ExpectedStatuses: []int{http.StatusOK},
498
+ }, &output)
499
+ var typed *Error
500
+ if !errors.As(err, &typed) || typed.Kind != ErrorKindUnavailable || typed.Code != "onex_dependency_unavailable" {
501
+ t.Fatalf("error=%#v", err)
502
+ }
503
+ }
504
+
505
+ func TestClientRejectsPartialDependencyOperation(t *testing.T) {
506
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product"})
507
+ if err != nil {
508
+ t.Fatal(err)
509
+ }
510
+ var output struct{ Value string }
511
+ err = client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/assets/one", Dependency: "storage", ExpectedStatuses: []int{http.StatusOK}}, &output)
512
+ var typed *Error
513
+ if !errors.As(err, &typed) || typed.Kind != ErrorKindValidation || typed.Code != "onex_dependency_operation_invalid" {
514
+ t.Fatalf("error=%#v", err)
515
+ }
516
+ }
517
+
518
+ func TestClientUsesOperationSpecificSafeReadRetryBudget(t *testing.T) {
519
+ var attempts atomic.Int32
520
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
521
+ current := attempts.Add(1)
522
+ writer.Header().Set("Content-Type", "application/json")
523
+ if current < 3 {
524
+ writer.WriteHeader(http.StatusServiceUnavailable)
525
+ _, _ = writer.Write([]byte(`{"error":{"type":"api_error","code":"service_unavailable","message":"unavailable"}}`))
526
+ return
527
+ }
528
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
529
+ }))
530
+ t.Cleanup(server.Close)
531
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{
532
+ BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product", Timeout: time.Second,
533
+ SafeReadRetry: SafeReadRetryConfig{InitialBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond},
534
+ OperationRetries: map[string]int{"storage.get_asset": 2},
535
+ })
536
+ if err != nil {
537
+ t.Fatal(err)
538
+ }
539
+ var output struct {
540
+ Value string `json:"value"`
541
+ }
542
+ err = client.Do(context.Background(), Call{
543
+ Method: http.MethodGet, Path: "/assets/one", Dependency: "storage", Operation: "get_asset", ExpectedStatuses: []int{http.StatusOK},
544
+ }, &output)
545
+ if err != nil || output.Value != "accepted" || attempts.Load() != 3 {
546
+ t.Fatalf("error=%#v output=%#v attempts=%d", err, output, attempts.Load())
547
+ }
548
+ }
549
+
550
+ func TestClientRejectsInvalidBoundaryConfigurationAndCalls(t *testing.T) {
551
+ baseConfig := Config{BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product"}
552
+ if _, err := NewClient(nil, signerFixture{}, baseConfig); err == nil {
553
+ t.Fatal("nil HTTP client was accepted")
554
+ }
555
+ if _, err := NewClient(httpclient.New(zap.NewNop()), nil, baseConfig); err == nil {
556
+ t.Fatal("nil signer was accepted")
557
+ }
558
+ for _, config := range []Config{
559
+ {BaseURL: "https://user@example.com/internal/v1", ProductCode: "sample_product"},
560
+ {BaseURL: "https://onex.example/internal/v2", ProductCode: "sample_product"},
561
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "SampleProduct"},
562
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", Timeout: -time.Second},
563
+ {BaseURL: "https://onex.example/internal/v1", ProductCode: "sample_product", MaxResponseBytes: 512},
564
+ } {
565
+ if _, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, config); err == nil {
566
+ t.Fatalf("config %#v was accepted", config)
567
+ }
568
+ }
569
+
570
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, baseConfig)
571
+ if err != nil {
572
+ t.Fatal(err)
573
+ }
574
+ var output struct{ Value string }
575
+ var nilClient *Client
576
+ if err := nilClient.Do(context.Background(), Call{}, &output); err == nil {
577
+ t.Fatal("nil client call was accepted")
578
+ }
579
+ invalidCalls := []Call{
580
+ {Method: http.MethodDelete, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}},
581
+ {Method: http.MethodGet, Path: "capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}},
582
+ {Method: http.MethodGet, Path: "/capabilities//opaque", ExpectedStatuses: []int{http.StatusOK}},
583
+ {Method: http.MethodGet, Path: "/capabilities/opaque", UserID: -1, ExpectedStatuses: []int{http.StatusOK}},
584
+ {Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: nil},
585
+ {Method: http.MethodGet, Path: "/capabilities/opaque", ExpectedStatuses: []int{http.StatusOK}, Timeout: -time.Second},
586
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Body: struct{}{}, ExpectedStatuses: []int{http.StatusOK}},
587
+ {Method: http.MethodGet, Path: "/capabilities/opaque", IdempotencyKey: "write-only", ExpectedStatuses: []int{http.StatusOK}},
588
+ {Method: http.MethodGet, Path: "/capabilities/opaque", RequestID: "embedded\nnewline", ExpectedStatuses: []int{http.StatusOK}},
589
+ {Method: http.MethodGet, Path: "/capabilities/opaque", RequestID: string(make([]byte, maximumRequestIDBytes+1)), ExpectedStatuses: []int{http.StatusOK}},
590
+ }
591
+ for _, call := range invalidCalls {
592
+ if err := client.Do(context.Background(), call, &output); err == nil {
593
+ t.Fatalf("call %#v was accepted", call)
594
+ }
595
+ }
596
+ encodingFailure := Call{
597
+ Method: http.MethodPost, Path: "/commands", Body: func() {}, ExpectedStatuses: []int{http.StatusOK},
598
+ }
599
+ if err := client.Do(context.Background(), encodingFailure, &output); err == nil {
600
+ t.Fatal("unencodable request was accepted")
601
+ }
602
+ }
603
+
604
+ func TestClientRejectsUnknownResponseFields(t *testing.T) {
605
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
606
+ writer.Header().Set("Content-Type", "application/json")
607
+ _, _ = writer.Write([]byte(`{"value":"accepted","provider":"must-not-leak"}`))
608
+ }))
609
+ defer server.Close()
610
+
611
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product"})
612
+ if err != nil {
613
+ t.Fatal(err)
614
+ }
615
+ var output struct {
616
+ Value string `json:"value"`
617
+ }
618
+ err = client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/mediagen/generations/gen_12345678", ExpectedStatuses: []int{http.StatusOK}}, &output)
619
+ var typed *Error
620
+ if !errors.As(err, &typed) || typed.Kind != ErrorKindContract || typed.Code != "onex_response_contract_invalid" {
621
+ t.Fatalf("error = %#v", err)
622
+ }
623
+ }
624
+
625
+ func TestClientEncodesClonedQueryValues(t *testing.T) {
626
+ requestStarted := make(chan struct{})
627
+ releaseSigner := make(chan struct{})
628
+ signer := blockingSignerFixture{started: requestStarted, release: releaseSigner}
629
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
630
+ if request.URL.RawQuery != "product_reference_id=attempt_01&tag=a&tag=b" {
631
+ t.Errorf("raw query = %q", request.URL.RawQuery)
632
+ }
633
+ writer.Header().Set("Content-Type", "application/json")
634
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
635
+ }))
636
+ t.Cleanup(server.Close)
637
+ client, err := NewClient(httpclient.New(zap.NewNop()), signer, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product"})
638
+ if err != nil {
639
+ t.Fatal(err)
640
+ }
641
+ query := url.Values{"tag": {"a", "b"}, "product_reference_id": {"attempt_01"}}
642
+ done := make(chan error, 1)
643
+ go func() {
644
+ var output struct {
645
+ Value string `json:"value"`
646
+ }
647
+ done <- client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", Query: query, ExpectedStatuses: []int{http.StatusOK}}, &output)
648
+ }()
649
+ <-requestStarted
650
+ query.Set("product_reference_id", "mutated")
651
+ query["tag"][0] = "mutated"
652
+ close(releaseSigner)
653
+ if err := <-done; err != nil {
654
+ t.Fatal(err)
655
+ }
656
+ }
657
+
658
+ func TestClientQueryValidation(t *testing.T) {
659
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
660
+ if request.URL.RawQuery != "" {
661
+ t.Errorf("empty query encoded as %q", request.URL.RawQuery)
662
+ }
663
+ writer.Header().Set("Content-Type", "application/json")
664
+ _, _ = writer.Write([]byte(`{"value":"accepted"}`))
665
+ }))
666
+ t.Cleanup(server.Close)
667
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product"})
668
+ if err != nil {
669
+ t.Fatal(err)
670
+ }
671
+ var output struct {
672
+ Value string `json:"value"`
673
+ }
674
+ if err := client.Do(context.Background(), Call{Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{}, ExpectedStatuses: []int{http.StatusOK}}, &output); err != nil {
675
+ t.Fatal(err)
676
+ }
677
+ tooManyKeys := make(url.Values, 17)
678
+ for index := range 17 {
679
+ tooManyKeys[fmt.Sprintf("key_%02d", index)] = []string{"value"}
680
+ }
681
+ tooManyValues := make([]string, 65)
682
+ for index := range tooManyValues {
683
+ tooManyValues[index] = "value"
684
+ }
685
+ for _, call := range []Call{
686
+ {Method: http.MethodGet, Path: "/capabilities/opaque?embedded=true", ExpectedStatuses: []int{http.StatusOK}},
687
+ {Method: http.MethodGet, Path: "/capabilities/opaque#fragment", ExpectedStatuses: []int{http.StatusOK}},
688
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{"": {"value"}}, ExpectedStatuses: []int{http.StatusOK}},
689
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{"key": {}}, ExpectedStatuses: []int{http.StatusOK}},
690
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{"key": {"line\nbreak"}}, ExpectedStatuses: []int{http.StatusOK}},
691
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{strings.Repeat("k", 129): {"value"}}, ExpectedStatuses: []int{http.StatusOK}},
692
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{"key": {strings.Repeat("v", 1025)}}, ExpectedStatuses: []int{http.StatusOK}},
693
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: tooManyKeys, ExpectedStatuses: []int{http.StatusOK}},
694
+ {Method: http.MethodGet, Path: "/capabilities/opaque", Query: url.Values{"key": tooManyValues}, ExpectedStatuses: []int{http.StatusOK}},
695
+ } {
696
+ if err := client.Do(context.Background(), call, &output); err == nil {
697
+ t.Fatalf("call %#v was accepted", call)
698
+ }
699
+ }
700
+ }
701
+
702
+ type blockingSignerFixture struct {
703
+ started chan<- struct{}
704
+ release <-chan struct{}
705
+ }
706
+
707
+ func (fixture blockingSignerFixture) AuthHeaders(context.Context) (map[string]string, error) {
708
+ close(fixture.started)
709
+ <-fixture.release
710
+ return map[string]string{"Authorization": "Bearer signed-service-token"}, nil
711
+ }
712
+
713
+ func (blockingSignerFixture) AuthorizeRequest(context.Context, *http.Request) error { return nil }
714
+
715
+ func TestClientValidatesRequiredResponseHeadersBeforeBodyDecode(t *testing.T) {
716
+ for _, test := range []struct {
717
+ name string
718
+ header string
719
+ body string
720
+ wantCode string
721
+ wantOutput string
722
+ }{
723
+ {name: "missing header", body: `{"value":"accepted","unknown":true}`, wantCode: "onex_response_headers_invalid"},
724
+ {name: "wrong header", header: "public, max-age=60", body: `{"value":"accepted"}`, wantCode: "onex_response_headers_invalid"},
725
+ {name: "body checked after header", header: "private, no-store", body: `{"value":"accepted","unknown":true}`, wantCode: "onex_response_contract_invalid"},
726
+ {name: "accepted", header: "private, no-store", body: `{"value":"accepted"}`, wantOutput: "accepted"},
727
+ } {
728
+ t.Run(test.name, func(t *testing.T) {
729
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
730
+ writer.Header().Set("Content-Type", "application/json")
731
+ if test.header != "" {
732
+ writer.Header().Set("Cache-Control", test.header)
733
+ }
734
+ _, _ = writer.Write([]byte(test.body))
735
+ }))
736
+ defer server.Close()
737
+
738
+ client, err := NewClient(httpclient.New(zap.NewNop()), signerFixture{}, Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_product"})
739
+ if err != nil {
740
+ t.Fatal(err)
741
+ }
742
+ var output struct {
743
+ Value string `json:"value"`
744
+ }
745
+ err = client.Do(context.Background(), Call{
746
+ Method: http.MethodGet, Path: "/capabilities/opaque",
747
+ ExpectedStatuses: []int{http.StatusOK}, RequiredResponseHeaders: map[string]string{"Cache-Control": "private, no-store"},
748
+ }, &output)
749
+ if test.wantCode == "" {
750
+ if err != nil || output.Value != test.wantOutput {
751
+ t.Fatalf("Do() = %#v, %v", output, err)
752
+ }
753
+ return
754
+ }
755
+ var typed *Error
756
+ if !errors.As(err, &typed) || typed.Code != test.wantCode {
757
+ t.Fatalf("error = %#v", err)
758
+ }
759
+ })
760
+ }
761
+ }
762
+
763
+ func TestDecodeStrictRejectsDuplicateFieldsAndTrailingValues(t *testing.T) {
764
+ for _, body := range [][]byte{[]byte(`{"value":1,"value":2}`), []byte(`{"value":1}{"value":2}`)} {
765
+ var output struct {
766
+ Value int `json:"value"`
767
+ }
768
+ if err := DecodeStrict(body, &output); err == nil {
769
+ t.Fatalf("DecodeStrict(%s) accepted malformed contract", body)
770
+ }
771
+ }
772
+ }