Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,25 @@ url-routes-check:
# Drift detection
#------------------------------------------------------------------------------

.PHONY: drift-check drift-check-mvp drift-check-full drift-regen
.PHONY: drift-check drift-check-mvp drift-check-full drift-regen route-coverage route-coverage-check

route-coverage:
@echo "==> Generating route coverage..."
./scripts/generate-route-coverage

route-coverage-check:
@echo "==> Checking route coverage freshness..."
@tmpfile=$$(mktemp) || exit 1; \
if ! ./scripts/generate-route-coverage openapi.json "$$tmpfile" spec/route-coverage-scope.json > /dev/null; then \
rm -f "$$tmpfile"; \
exit 1; \
fi; \
if ! diff -q spec/route-coverage.json "$$tmpfile" > /dev/null 2>&1; then \
rm -f "$$tmpfile"; \
echo "ERROR: spec/route-coverage.json is out of date. Run 'make route-coverage'"; \
exit 1; \
fi; \
rm -f "$$tmpfile"

# Forward-only: every modeled operation has a matching route
drift-check-forward:
Expand Down Expand Up @@ -304,12 +322,12 @@ audit-check:
#------------------------------------------------------------------------------

# Phase 0-1: Smithy model validation
check-mvp: smithy-check behavior-model-check drift-check-mvp \
check-mvp: smithy-check behavior-model-check route-coverage-check drift-check-mvp \
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
url-routes-check go-check go-check-drift conformance-mvp
@echo "==> MVP gate passed"

# Phase 3: Full surface, all languages
check-full: smithy-check behavior-model-check drift-check-full \
check-full: smithy-check behavior-model-check route-coverage-check drift-check-full \
sync-api-version-check provenance-check \
go-check-drift kt-check-drift \
go-check ts-check rb-check swift-check kt-check \
Expand Down
20 changes: 20 additions & 0 deletions behavior-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
]
}
},
"CreateReplyDraft": {
"idempotent": false,
"readonly": false,
"retry": {
"backoff": "constant",
"base_delay_ms": 1000,
"max": 1,
"retry_on": []
}
},
"CreateTopicMessage": {
"idempotent": false,
"readonly": false,
Expand All @@ -93,6 +103,16 @@
]
}
},
"DeleteDraft": {
"idempotent": false,
"readonly": false,
"retry": {
"backoff": "constant",
"base_delay_ms": 1000,
"max": 1,
"retry_on": []
}
},
"GetAsidebox": {
"idempotent": false,
"readonly": true,
Expand Down
143 changes: 143 additions & 0 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,27 @@ func runTest(tc TestCase) TestResult {
var requestTimes []time.Time
var requestPaths []string
var requestHeaders []http.Header
var requestBodies [][]byte
var requestBodyReadErr error
var responseStatuses []int

// Create mock server that serves responses in sequence
responseIndex := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requestBody []byte
var readErr error
if r.Body != nil {
requestBody, readErr = readRequestBody(r.Body)
}
mu.Lock()
if readErr != nil && requestBodyReadErr == nil {
requestBodyReadErr = readErr
}
requestCount++
requestTimes = append(requestTimes, time.Now())
requestPaths = append(requestPaths, r.URL.Path)
requestHeaders = append(requestHeaders, r.Header.Clone())
requestBodies = append(requestBodies, requestBody)
idx := responseIndex
responseIndex++
mu.Unlock()
Expand Down Expand Up @@ -220,6 +231,15 @@ func runTest(tc TestCase) TestResult {
// Execute the operation
ctx := context.Background()
sdkResp, sdkErr := executeOperation(client, ctx, tc)
mu.Lock()
capturedRequestBodyReadErr := requestBodyReadErr
mu.Unlock()
if capturedRequestBodyReadErr != nil {
if sdkResp != nil && sdkResp.Body != nil {
_ = sdkResp.Body.Close()
}
return fail(tc.Name, "Failed to read captured request body: %v", capturedRequestBodyReadErr)
}

// Capture response body for responseBody assertions
var responseBodyBytes []byte
Expand Down Expand Up @@ -265,6 +285,7 @@ func runTest(tc TestCase) TestResult {
requestTimes: requestTimes,
requestPaths: requestPaths,
requestHeaders: requestHeaders,
requestBodies: requestBodies,
lastStatus: lastStatus,
sdkErr: sdkErr,
sdkError: sdkError,
Expand All @@ -283,6 +304,15 @@ func runTest(tc TestCase) TestResult {
}
}

func readRequestBody(body io.ReadCloser) ([]byte, error) {
requestBody, readErr := io.ReadAll(body)
closeErr := body.Close()
if readErr != nil {
return requestBody, readErr
}
return requestBody, closeErr
}

// runConfigOverrideTest handles tests that override client configuration
// (e.g. HTTPS enforcement with a non-localhost HTTP URL).
func runConfigOverrideTest(tc TestCase, baseURL string) TestResult {
Expand Down Expand Up @@ -351,6 +381,7 @@ type checkState struct {
requestTimes []time.Time
requestPaths []string
requestHeaders []http.Header
requestBodies [][]byte
lastStatus int
sdkErr error
sdkError *hey.Error
Expand Down Expand Up @@ -481,6 +512,58 @@ func checkAssertion(testName string, a Assertion, s checkState) TestResult {
return fail(testName, "Expected header %q to be present, but it was not", headerName)
}

case "requestHeader":
expected, ok := a.Expected.(string)
if !ok {
return fail(testName, "requestHeader: expected a string value, got %T", a.Expected)
}
if len(s.requestHeaders) == 0 {
return fail(testName, "Expected request with header %q, but no requests were recorded", a.Path)
}
if actual := s.requestHeaders[0].Get(a.Path); actual != expected {
return fail(testName, "Expected request header %s=%q, got %q", a.Path, expected, actual)
}

case "requestForm":
if len(s.requestBodies) == 0 {
return fail(testName, "Expected a form request, but no request bodies were recorded")
}
values, err := url.ParseQuery(string(s.requestBodies[0]))
if err != nil {
return fail(testName, "Failed to decode form request: %v", err)
}
actual := values[a.Path]
switch expected := a.Expected.(type) {
case string:
if len(actual) != 1 || actual[0] != expected {
return fail(testName, "Expected form field %s=%q, got %v", a.Path, expected, actual)
}
case []interface{}:
if len(actual) != len(expected) {
return fail(testName, "Expected %d values for form field %s, got %v", len(expected), a.Path, actual)
}
for i := range expected {
want, ok := expected[i].(string)
if !ok || actual[i] != want {
return fail(testName, "Expected form field %s[%d]=%q, got %q", a.Path, i, want, actual[i])
}
}
default:
return fail(testName, "requestForm: unsupported expected type %T", a.Expected)
}

case "responseHeader":
expected, ok := a.Expected.(string)
if !ok {
return fail(testName, "responseHeader: expected a string value, got %T", a.Expected)
}
if s.sdkResp == nil {
return fail(testName, "No HTTP response to check header %s", a.Path)
}
if actual := s.sdkResp.Header.Get(a.Path); actual != expected {
return fail(testName, "Expected response header %s=%q, got %q", a.Path, expected, actual)
}

case "responseMeta":
switch a.Path {
case "totalCount":
Expand Down Expand Up @@ -805,6 +888,39 @@ func executeOperation(client *generated.Client, ctx context.Context, tc TestCase
},
}
return client.CreateReply(ctx, entryId, body)
case "CreateReplyDraft":
entryId := getInt64Param(tc.PathParams, "entryId")
body := generated.CreateReplyDraftFormdataRequestBody{
ActingSenderId: getInt64Param(tc.RequestBody, "acting_sender_id"),
AuthenticityToken: getStringParam(tc.RequestBody, "authenticity_token"),
EntryAddressedBlindcopied: getStringSliceParam(tc.RequestBody, "bcc"),
EntryAddressedCopied: getStringSliceParam(tc.RequestBody, "cc"),
EntryAddressedDirectly: getStringSliceParam(tc.RequestBody, "to"),
EntryStatus: "drafted",
MessageAutoQuoting: getBoolPtrParam(tc.RequestBody, "auto_quoting"),
MessageContent: getStringParam(tc.RequestBody, "content"),
MessageSubject: getStringParam(tc.RequestBody, "subject"),
}
csrf := body.AuthenticityToken
return client.CreateReplyDraftWithFormdataBody(ctx, entryId, body, func(_ context.Context, req *http.Request) error {
req.Header.Set("Accept", "*/*")
if csrf != "" {
req.Header.Set("X-CSRF-Token", csrf)
}
return nil
})
case "DeleteDraft":
messageId := getInt64Param(tc.PathParams, "messageId")
csrf := getStringParam(tc.RequestBody, "authenticity_token")
params := &generated.DeleteDraftParams{XCSRFToken: csrf}
body := generated.DeleteDraftFormdataRequestBody{
UnderscoreMethod: "delete",
Status: "drafted",
}
return client.DeleteDraftWithFormdataBody(ctx, messageId, params, body, func(_ context.Context, req *http.Request) error {
req.Header.Set("Accept", "*/*")
return nil
})

// Contacts
case "ListContacts":
Expand Down Expand Up @@ -965,6 +1081,33 @@ func getStringParam(params map[string]interface{}, key string) string {
return ""
}

func getStringSliceParam(params map[string]interface{}, key string) []string {
val, ok := params[key]
if !ok {
return nil
}
values, ok := val.([]interface{})
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
if stringValue, ok := value.(string); ok {
result = append(result, stringValue)
}
}
return result
}

func getBoolPtrParam(params map[string]interface{}, key string) *bool {
if val, ok := params[key]; ok {
if boolValue, ok := val.(bool); ok {
return &boolValue
}
}
return nil
}

// getStringPtrParam extracts a *string parameter from a map.
func getStringPtrParam(params map[string]interface{}, key string) *string {
if val, ok := params[key]; ok {
Expand Down
63 changes: 63 additions & 0 deletions conformance/runner/go/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"errors"
"io"
"testing"
)

func TestReadRequestBodyPropagatesPartialReadError(t *testing.T) {
sentinel := errors.New("request body read failed")
reader := &partialErrorReadCloser{
contents: []byte("status=drafted"),
err: sentinel,
}

body, err := readRequestBody(reader)
if !errors.Is(err, sentinel) {
t.Fatalf("readRequestBody() error = %v, want %v", err, sentinel)
}
if got, want := string(body), "status=drafted"; got != want {
t.Fatalf("readRequestBody() body = %q, want %q", got, want)
}
if !reader.closed {
t.Fatal("readRequestBody() did not close the request body")
}
}

func TestReadRequestBodyPropagatesCloseError(t *testing.T) {
sentinel := errors.New("request body close failed")
reader := &partialErrorReadCloser{
contents: []byte("status=drafted"),
closeErr: sentinel,
}

body, err := readRequestBody(reader)
if !errors.Is(err, sentinel) {
t.Fatalf("readRequestBody() error = %v, want %v", err, sentinel)
}
if got, want := string(body), "status=drafted"; got != want {
t.Fatalf("readRequestBody() body = %q, want %q", got, want)
}
}

type partialErrorReadCloser struct {
contents []byte
err error
closeErr error
read bool
closed bool
}

func (r *partialErrorReadCloser) Read(p []byte) (int, error) {
if r.read {
return 0, io.EOF
}
r.read = true
return copy(p, r.contents), r.err
}

func (r *partialErrorReadCloser) Close() error {
r.closed = true
return r.closeErr
}
31 changes: 31 additions & 0 deletions conformance/tests/draft-deletion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[
{
"name": "DeleteDraft uses the browser discard form without following redirects",
"description": "Verifies the Rails method override, drafted guard, CSRF header, redirect preservation, and single-attempt browser form transport",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"operation": "DeleteDraft",
"method": "POST",
"path": "/messages/{messageId}",
"pathParams": {"messageId": 987},
"requestBody": {
"authenticity_token": "conformance-delete-csrf"
},
"mockResponses": [
{
"status": 303,
"headers": {"Location": "/topics/123"}
}
],
"assertions": [
{"type": "requestCount", "expected": 1},
{"type": "requestPath", "expected": "/messages/987"},
{"type": "requestHeader", "path": "Content-Type", "expected": "application/x-www-form-urlencoded"},
{"type": "requestHeader", "path": "X-CSRF-Token", "expected": "conformance-delete-csrf"},
{"type": "requestForm", "path": "_method", "expected": "delete"},
{"type": "requestForm", "path": "status", "expected": "drafted"},
{"type": "responseHeader", "path": "Location", "expected": "/topics/123"},
{"type": "statusCode", "expected": 303},
{"type": "noError"}
],
"tags": ["entries", "drafts", "delete", "form", "csrf"]
}
]
Loading
Loading