@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.
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +14 -2
- package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +3 -1
- package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useWebPush.ts +4 -0
- package/dist/resources/components/nuxt/site-source/app/plugins/web-push.ts +52 -0
- package/dist/resources/components/nuxt/site-source/app/theme/button.ts +18 -22
- package/dist/resources/components/nuxt/site-source/app/theme/index.ts +24 -0
- package/dist/resources/components/nuxt/site-source/app/theme/input.ts +32 -17
- package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +1 -1
- package/dist/resources/components/nuxt/site-source/app/theme/select.ts +62 -18
- package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +73 -1
- package/dist/resources/components/nuxt/site-source/app/web-push/worker.ts +8 -0
- package/dist/resources/components/registry.json +11 -1
- package/dist/resources/registry.json +15 -3
- package/dist/resources/templates/base/{.claude → .codex}/docs/architecture-template.md +1 -1
- package/dist/resources/templates/base/{.claude → .codex}/docs/architecture.md +1 -1
- package/dist/resources/templates/base/{.claude → .codex}/docs/index.md +3 -3
- package/dist/resources/templates/base/{.claude → .codex}/docs/verification.md +1 -1
- package/dist/resources/templates/base/AGENTS.md +24 -24
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/api-design.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/architecture-template.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/check-list.md +3 -3
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/database.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/error-handling.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/index.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/logging.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-onboarding-sop.md +2 -2
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-quick-reference.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/module-standards.md +1 -1
- package/dist/resources/templates/go-backend/.dockerignore +1 -2
- package/dist/resources/templates/go-backend/AGENTS.md +19 -19
- package/dist/resources/templates/go-backend/README.md +5 -1
- package/dist/resources/templates/go-backend/deployments/onex/product-onboarding.example.json +201 -0
- package/dist/resources/templates/go-backend/docs/onex-notifications.md +85 -0
- package/dist/resources/templates/go-backend/docs/openapi-provenance.json +1 -1
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +23 -2
- package/dist/resources/templates/go-backend/docs/source-provenance.json +124 -6
- package/dist/resources/templates/go-backend/docs/standards-provenance.json +30 -30
- package/dist/resources/templates/go-backend/internal/common/middleware/cors.go +1 -0
- package/dist/resources/templates/go-backend/internal/common/middleware/middleware_test.go +1 -0
- package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post.go +28 -0
- package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post_test.go +56 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client.go +453 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client_test.go +772 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/codec.go +33 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error.go +146 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error_notification_test.go +29 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client.go +132 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client_test.go +222 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/contracts.go +219 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/resilience.go +23 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/transport_policy.go +96 -0
- package/dist/resources/templates/go-backend/internal/products/README.md +1 -1
- package/dist/resources/templates/go-backend/internal/sharedevents/README.md +1 -1
- package/dist/resources/templates/go-backend/scripts/check-openapi-clean-deletion.sh +3 -3
- package/dist/resources/templates/mcp-server/.prettierignore +1 -0
- package/dist/resources/templates/nuxt/{.claude → .codex}/docs/stack.md +17 -1
- package/dist/resources/templates/nuxt/.dockerignore +1 -1
- package/dist/resources/templates/nuxt/.env.example +6 -0
- package/dist/resources/templates/nuxt/.prettierignore +2 -1
- package/dist/resources/templates/nuxt/DESIGN.md +55 -10
- package/dist/resources/templates/nuxt/README.md +8 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +13 -1
- package/dist/resources/templates/nuxt/package.json +4 -2
- package/package.json +1 -1
- package/dist/resources/templates/base/CLAUDE.md +0 -437
- package/dist/resources/templates/go-backend/CLAUDE.md +0 -1139
- /package/dist/resources/templates/base/{.claude → .codex}/docs/security-lifecycle.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/e2e-standards.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/event-runtime-boundary.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/identifier-contract.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/openapi-contract.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/payment-gateway-onboarding.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/platform-boundary.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/quality-gates.md +0 -0
- /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
|
+
}
|