Skip to content
Merged
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
37 changes: 37 additions & 0 deletions internal/orca/inttest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package inttest

import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -36,6 +37,32 @@ type GetResponse struct {
Body []byte
}

// S3Error is the parsed shape of an S3 <Error> envelope as returned
// by orca's edge handler on non-2xx responses. Field names mirror the
// AWS S3 REST API.
type S3Error struct {
XMLName xml.Name `xml:"Error"`
Code string `xml:"Code"`
Message string `xml:"Message"`
Resource string `xml:"Resource,omitempty"`
BucketName string `xml:"BucketName,omitempty"`
Key string `xml:"Key,omitempty"`
}

// ParseS3Error decodes r.Body as an S3 <Error> envelope. Fails the
// test on parse error; callers should already know the response was
// non-2xx.
func (r GetResponse) ParseS3Error(t *testing.T) S3Error {
t.Helper()

var e S3Error
if err := xml.Unmarshal(r.Body, &e); err != nil {
t.Fatalf("parse S3 error: %v; body=%q", err, string(r.Body))
}

return e
}

// Get fetches the full body of /bucket/key.
func (c *Client) Get(ctx context.Context, t *testing.T, bucket, key string) GetResponse {
t.Helper()
Expand All @@ -60,6 +87,16 @@ func (c *Client) Head(ctx context.Context, t *testing.T, bucket, key string) Get
return c.do(ctx, t, http.MethodHead, fmt.Sprintf("/%s/%s", bucket, key), nil)
}

// Do issues a raw request against an arbitrary path. Used by tests
// that exercise non-object surfaces (e.g. GET /bucket/ -> 501) or
// that need to send headers (e.g. an out-of-range Range) the typed
// helpers do not provide.
func (c *Client) Do(ctx context.Context, t *testing.T, method, path string, hdr http.Header) GetResponse {
t.Helper()

return c.do(ctx, t, method, path, hdr)
}

func (c *Client) do(ctx context.Context, t *testing.T, method, path string, hdr http.Header) GetResponse {
t.Helper()

Expand Down
119 changes: 119 additions & 0 deletions internal/orca/inttest/error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//go:build integrationtest

package inttest

import (
"context"
"net/http"
"testing"
"time"
)

// TestS3Errors exercises orca's edge error surface on the wire (raw
// net/http requests). It verifies that the S3-compatible <Error> XML
// envelope, status codes, and HEAD-no-body behavior reach a real
// HTTP client correctly. Companion suite TestS3SDK (s3sdk_test.go)
// re-checks the same surface through an actual S3 SDK so we know
// SDKs unmarshal the envelope into typed errors.
//
// A single cluster is shared across all subtests; subtests are
// independent and run in parallel.
func TestS3Errors(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second)
// Parallel subtests are paused until the parent returns, then
// resumed; a `defer cancel()` would cancel the context before
// they ever run. Use t.Cleanup so the cancel fires after all
// subtests finish instead.
t.Cleanup(cancel)

bucket := pkgLocalStack.NewBucket(ctx, t, "orca-origin")
blob := SmallBlob()
SeedS3(ctx, t, pkgLocalStack.NewS3Client(ctx, t), bucket, []SeedBlob{blob})

cl := StartCluster(ctx, t, ClusterOptions{
LocalStack: pkgLocalStack,
OriginBucket: bucket,
})

httpClient := cl.Get(1).HTTP

// NoSuchKey_XML: GET against a missing object returns 404 with
// the standard S3 <Error> envelope and Code "NoSuchKey".
t.Run("NoSuchKey_XML", func(t *testing.T) {
t.Parallel()

resp := httpClient.Get(ctx, t, bucket, "does-not-exist")
if resp.Status != http.StatusNotFound {
t.Fatalf("status=%d want 404; body=%s", resp.Status, string(resp.Body))
}

if got := resp.Header.Get("Content-Type"); got != "application/xml" {
t.Errorf("Content-Type=%q want application/xml", got)
}

e := resp.ParseS3Error(t)
if e.Code != "NoSuchKey" {
t.Errorf("Code=%q want NoSuchKey", e.Code)
}

if e.Message == "" {
t.Error("Message is empty")
}
})

// HeadNoSuchKey_NoBody: HEAD against a missing object returns
// 404 with an empty body, mirroring real S3 (HEAD must not have
// a body per RFC 9110; SDKs key off the status code).
t.Run("HeadNoSuchKey_NoBody", func(t *testing.T) {
t.Parallel()

resp := httpClient.Head(ctx, t, bucket, "does-not-exist")
if resp.Status != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.Status)
}

if len(resp.Body) != 0 {
t.Errorf("HEAD body must be empty; got %d bytes: %q", len(resp.Body), string(resp.Body))
}
})

// InvalidRange_XML: an unsatisfiable Range returns 416 with
// Code "InvalidRange".
t.Run("InvalidRange_XML", func(t *testing.T) {
t.Parallel()

hdr := http.Header{}
// Start position past EOF: unambiguously unsatisfiable per RFC.
hdr.Set("Range", "bytes=99999999-")

resp := httpClient.Do(ctx, t, http.MethodGet, "/"+bucket+"/"+blob.Key, hdr)
if resp.Status != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("status=%d want 416; body=%s", resp.Status, string(resp.Body))
}

e := resp.ParseS3Error(t)
if e.Code != "InvalidRange" {
t.Errorf("Code=%q want InvalidRange", e.Code)
}
})

// ListObjectsV2_NotImplemented: GET against a bucket root
// returns 501 with Code "NotImplemented" and a message naming
// ListObjectsV2 (per the routing split in EdgeHandler.ServeHTTP).
t.Run("ListObjectsV2_NotImplemented", func(t *testing.T) {
t.Parallel()

resp := httpClient.Do(ctx, t, http.MethodGet, "/"+bucket+"/", nil)
if resp.Status != http.StatusNotImplemented {
t.Fatalf("status=%d want 501; body=%s", resp.Status, string(resp.Body))
}

e := resp.ParseS3Error(t)
if e.Code != "NotImplemented" {
t.Errorf("Code=%q want NotImplemented", e.Code)
}
})
}
Loading
Loading