Files
versitygw/website/handler_test.go
niksis02 4d391cabc8 feat: migrate Fiber to v3.3.0
Fixes #2180
Fixes #2181

Migrate the gateway from Fiber v2 to Fiber v3.3.0 and update the affected server, middleware, handler, controller, and test code for the new APIs.

Replace the deprecated Fiber filesystem middleware used by the WebUI with the Fiber v3 static middleware, serving the embedded WebUI assets from an fs.Sub filesystem.

Fix the request header limit handling regression by adding a temporary handler for Fiber v3/fasthttp small-buffer errors so oversized request headers return the expected regulated S3 error response.

Fix the debuglogger panic by reworking the boxed key/value formatter used for debug request and response dumps. The formatter now handles long header keys and values without producing invalid wrap widths, negative padding, or out-of-range string slices.
2026-06-15 14:48:31 +04:00

1067 lines
33 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package website
import (
"context"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
type websiteTestBackend struct {
backend.BackendUnsupported
websiteConfig []byte
corsConfig []byte
corsErr error
objects map[string]string
objectRedirects map[string]string
objectErrors map[string]error
public bool
calls []string
}
func (b *websiteTestBackend) record(call string) {
b.calls = append(b.calls, call)
}
func (b *websiteTestBackend) GetBucketWebsite(_ context.Context, _ string) ([]byte, error) {
b.record("GetBucketWebsite")
return b.websiteConfig, nil
}
func (b *websiteTestBackend) GetBucketCors(_ context.Context, _ string) ([]byte, error) {
b.record("GetBucketCors")
if b.corsErr != nil {
return nil, b.corsErr
}
if b.corsConfig == nil {
return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
}
return b.corsConfig, nil
}
func (b *websiteTestBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
b.record("GetBucketPolicy")
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
}
func (b *websiteTestBackend) GetBucketAcl(_ context.Context, _ *s3.GetBucketAclInput) ([]byte, error) {
b.record("GetBucketAcl")
acl := auth.ACL{Owner: "owner"}
if b.public {
acl.Grantees = []auth.Grantee{
{
Permission: auth.PermissionRead,
Access: "all-users",
Type: types.TypeGroup,
},
}
}
data, err := json.Marshal(acl)
if err != nil {
return nil, err
}
return data, nil
}
func (b *websiteTestBackend) HeadObject(_ context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
b.record("HeadObject")
if input == nil || input.Key == nil {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if err, ok := b.objectErrors[*input.Key]; ok {
return nil, err
}
body, ok := b.objects[*input.Key]
if !ok {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
length := int64(len(body))
contentType := "text/html"
redirectLocation := redirectPtr(b.objectRedirects[*input.Key])
return &s3.HeadObjectOutput{
ContentLength: &length,
ContentType: &contentType,
WebsiteRedirectLocation: redirectLocation,
}, nil
}
func (b *websiteTestBackend) GetObject(_ context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
b.record("GetObject")
if input == nil || input.Key == nil {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if err, ok := b.objectErrors[*input.Key]; ok {
return nil, err
}
body, ok := b.objects[*input.Key]
if !ok {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
length := int64(len(body))
contentType := "text/html"
redirectLocation := redirectPtr(b.objectRedirects[*input.Key])
return &s3.GetObjectOutput{
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: &length,
ContentType: &contentType,
WebsiteRedirectLocation: redirectLocation,
}, nil
}
func redirectPtr(location string) *string {
if location == "" {
return nil
}
return &location
}
func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) {
tests := []struct {
name string
rules []s3response.RoutingRule
wantStatus int
wantLocation string
}{
{
name: "key prefix rule before 404 rule wins",
rules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "new/",
HttpRedirectCode: "301",
},
},
{
Condition: &s3response.RoutingRuleCondition{
HttpErrorCodeReturnedEquals: "404",
},
Redirect: &s3response.Redirect{
ReplaceKeyWith: "error.html",
HttpRedirectCode: "302",
},
},
},
wantStatus: http.StatusMovedPermanently,
wantLocation: "http://site.test/new/missing.html",
},
{
name: "key prefix rule wins pre-fetch even when 404 rule comes first",
rules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
HttpErrorCodeReturnedEquals: "404",
},
Redirect: &s3response.Redirect{
ReplaceKeyWith: "error.html",
HttpRedirectCode: "302",
},
},
{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "new/",
HttpRedirectCode: "301",
},
},
},
wantStatus: http.StatusMovedPermanently,
wantLocation: "http://site.test/new/missing.html",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
RoutingRules: tt.rules,
}, nil, true)
resp := websiteRequest(t, be, "/old/missing.html")
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
if got := resp.Header.Get("Location"); got != tt.wantLocation {
t.Fatalf("Location = %q, want %q", got, tt.wantLocation)
}
if containsCall(be.calls, "GetObject") {
t.Fatal("GetObject was called for a redirect response")
}
})
}
}
func TestWebsiteHandlerRoutingRuleBothConditions(t *testing.T) {
config := s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
RoutingRules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
HttpErrorCodeReturnedEquals: "404",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "new/",
HttpRedirectCode: "302",
},
},
},
}
t.Run("missing object with matching prefix redirects", func(t *testing.T) {
be := newWebsiteTestBackend(t, config, nil, true)
resp := websiteRequest(t, be, "/old/missing.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got := resp.Header.Get("Location"); got != "http://site.test/new/missing.html" {
t.Fatalf("Location = %q", got)
}
})
t.Run("existing object with matching prefix does not redirect", func(t *testing.T) {
be := newWebsiteTestBackend(t, config, map[string]string{
"old/existing.html": "served",
}, true)
resp := websiteRequest(t, be, "/old/existing.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := readBody(t, resp); got != "served" {
t.Fatalf("body = %q, want %q", got, "served")
}
if got := resp.Header.Get("Location"); got != "" {
t.Fatalf("unexpected Location header %q", got)
}
})
t.Run("missing object with wrong prefix does not redirect", func(t *testing.T) {
be := newWebsiteTestBackend(t, config, nil, true)
resp := websiteRequest(t, be, "/other/missing.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
}
if got := resp.Header.Get("Location"); got != "" {
t.Fatalf("unexpected Location header %q", got)
}
})
}
func TestWebsiteHandlerObjectRedirectLocation(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, map[string]string{
"old.html": "old",
}, true)
be.objectRedirects["old.html"] = "/new.html"
resp := websiteRequest(t, be, "/old.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
}
if got := resp.Header.Get("Location"); got != "/new.html" {
t.Fatalf("Location = %q, want %q", got, "/new.html")
}
if got := readBody(t, resp); got != "" {
t.Fatalf("body = %q, want empty body", got)
}
}
func TestWebsiteHandlerPrefetchRoutingPrecedesObjectRedirect(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
RoutingRules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "new/",
HttpRedirectCode: "302",
},
},
},
}, map[string]string{
"old/page.html": "old",
}, true)
be.objectRedirects["old/page.html"] = "/object-redirect.html"
resp := websiteRequest(t, be, "/old/page.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got := resp.Header.Get("Location"); got != "http://site.test/new/page.html" {
t.Fatalf("Location = %q", got)
}
if containsCall(be.calls, "GetObject") {
t.Fatal("GetObject was called before pre-fetch routing completed")
}
}
func TestWebsiteHandlerRedirectConstruction(t *testing.T) {
tests := []struct {
name string
rule s3response.RoutingRule
path string
wantLocation string
}{
{
name: "ReplaceKeyWith replaces full key",
rule: s3response.RoutingRule{
Condition: &s3response.RoutingRuleCondition{
HttpErrorCodeReturnedEquals: "404",
},
Redirect: &s3response.Redirect{
ReplaceKeyWith: "error.html",
},
},
path: "/a/b/c.html",
wantLocation: "http://site.test/error.html",
},
{
name: "ReplaceKeyPrefixWith replaces matching prefix",
rule: s3response.RoutingRule{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "new/",
},
},
path: "/old/a/b.html",
wantLocation: "http://site.test/new/a/b.html",
},
{
name: "HostName Protocol and query string are preserved",
rule: s3response.RoutingRule{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
},
Redirect: &s3response.Redirect{
HostName: "example.com",
Protocol: "https",
ReplaceKeyPrefixWith: "new/",
},
},
path: "/old/page.html?x=1&y=2",
wantLocation: "https://example.com/new/page.html?x=1&y=2",
},
{
name: "query string is preserved with current endpoint host",
rule: s3response.RoutingRule{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "old/",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "new/",
},
},
path: "/old/page.html?x=1&y=2",
wantLocation: "http://site.test/new/page.html?x=1&y=2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
RoutingRules: []s3response.RoutingRule{tt.rule},
}, nil, true)
resp := websiteRequest(t, be, tt.path)
defer resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
}
if got := resp.Header.Get("Location"); got != tt.wantLocation {
t.Fatalf("Location = %q, want %q", got, tt.wantLocation)
}
})
}
}
func TestWebsiteHandlerPostErrorRoutingUsesOriginalKeyBeforeIndexExpansion(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
RoutingRules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
KeyPrefixEquals: "blog/",
HttpErrorCodeReturnedEquals: "404",
},
Redirect: &s3response.Redirect{
ReplaceKeyPrefixWith: "archive/",
HttpRedirectCode: "302",
},
},
},
}, nil, true)
resp := websiteRequest(t, be, "/blog/")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got := resp.Header.Get("Location"); got != "http://site.test/archive/" {
t.Fatalf("Location = %q, want %q", got, "http://site.test/archive/")
}
if countCalls(be.calls, "GetObject") != 1 {
t.Fatalf("GetObject calls = %d, want 1; calls: %v", countCalls(be.calls, "GetObject"), be.calls)
}
}
func TestWebsiteHandlerObjectStore5xxBypassesRoutingAndErrorDocument(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
ErrorDocument: &s3response.ErrorDocument{Key: "error.html"},
RoutingRules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
HttpErrorCodeReturnedEquals: "500",
},
Redirect: &s3response.Redirect{
ReplaceKeyWith: "elsewhere.html",
HttpRedirectCode: "302",
},
},
},
}, map[string]string{
"error.html": "custom error document",
}, true)
be.objectErrors = map[string]error{
"boom.html": s3err.GetAPIError(s3err.ErrInternalError),
}
resp := websiteRequest(t, be, "/boom.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusInternalServerError)
}
if got := resp.Header.Get("Location"); got != "" {
t.Fatalf("unexpected Location header %q", got)
}
if got := resp.Header.Get("x-amz-error-code"); got != "InternalError" {
t.Fatalf("x-amz-error-code = %q, want %q", got, "InternalError")
}
if got := countCalls(be.calls, "GetObject"); got != 1 {
t.Fatalf("GetObject calls = %d, want 1; calls: %v", got, be.calls)
}
}
func TestWebsiteHandlerPublicAccessDeniedPreventsObjectReadAndCanRoute(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
RoutingRules: []s3response.RoutingRule{
{
Condition: &s3response.RoutingRuleCondition{
HttpErrorCodeReturnedEquals: "403",
},
Redirect: &s3response.Redirect{
ReplaceKeyWith: "denied.html",
HttpRedirectCode: "302",
},
},
},
}, map[string]string{
"private.html": "secret",
}, false)
resp := websiteRequest(t, be, "/private.html")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got := resp.Header.Get("Location"); got != "http://site.test/denied.html" {
t.Fatalf("Location = %q", got)
}
if containsCall(be.calls, "HeadObject") {
t.Fatal("HeadObject was called after public access was denied")
}
if containsCall(be.calls, "GetObject") {
t.Fatal("GetObject was called after public access was denied")
}
}
func TestWebsiteHandlerVerifiesPublicAccessBeforeGetObject(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, map[string]string{
"index.html": "home",
}, true)
resp := websiteRequest(t, be, "/")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := readBody(t, resp); got != "home" {
t.Fatalf("body = %q, want %q", got, "home")
}
verifyIdx := firstCallIndex(be.calls, "GetBucketAcl")
getObjectIdx := firstCallIndex(be.calls, "GetObject")
if verifyIdx == -1 {
t.Fatal("expected public access verification to read bucket ACL")
}
if getObjectIdx == -1 {
t.Fatal("expected GetObject call")
}
if verifyIdx > getObjectIdx {
t.Fatalf("GetObject happened before public access verification: %v", be.calls)
}
}
func TestWebsiteHandlerHeadUsesHeadObjectAndReturnsHeadersOnly(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, map[string]string{
"index.html": "home",
}, true)
resp := websiteRequestWithMethod(t, be, http.MethodHead, "/")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Content-Length"); got != "4" {
t.Fatalf("Content-Length = %q, want %q", got, "4")
}
if got := resp.Header.Get("Content-Type"); got != "text/html" {
t.Fatalf("Content-Type = %q, want %q", got, "text/html")
}
if got := readBody(t, resp); got != "" {
t.Fatalf("body = %q, want empty body", got)
}
if containsCall(be.calls, "GetObject") {
t.Fatalf("GetObject was called for HEAD request: %v", be.calls)
}
if !containsCall(be.calls, "HeadObject") {
t.Fatalf("HeadObject was not called for HEAD request: %v", be.calls)
}
}
func TestWebsiteHandlerGetValidatesBucketName(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
resp := websiteRequestWithHostAndHeaders(t, be, http.MethodGet, "bad_bucket", "/", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
if got := resp.Header.Get("x-amz-error-code"); got != "InvalidBucketName" {
t.Fatalf("x-amz-error-code = %q", got)
}
if len(be.calls) != 0 {
t.Fatalf("invalid bucket should not call backend, got calls: %v", be.calls)
}
}
func TestWebsiteHandlerNoBucketInRequestSetsLocation(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
resp := websiteRequestWithDomainHostAndHeaders(t, be, "site.test", http.MethodGet, "wrong.test:8080", "/", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
}
if got := resp.Header.Get("Location"); got != "http://site.test:8080/" {
t.Fatalf("Location = %q, want %q", got, "http://site.test:8080/")
}
if got := resp.Header.Get("x-amz-error-code"); got != "WebsiteRedirect" {
t.Fatalf("x-amz-error-code = %q, want %q", got, "WebsiteRedirect")
}
if len(be.calls) != 0 {
t.Fatalf("request without bucket should not call backend, got calls: %v", be.calls)
}
}
func TestWebsiteHandlerHeadValidatesObjectName(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
resp := websiteRequestWithHeaders(t, be, http.MethodHead, "/../../private.html", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
if got := resp.Header.Get("x-amz-error-code"); got != "400" {
t.Fatalf("x-amz-error-code = %q", got)
}
if len(be.calls) != 0 {
t.Fatalf("invalid object should not call backend, got calls: %v", be.calls)
}
}
func TestWebsiteHandlerGetAppliesBucketCORS(t *testing.T) {
corsConfig, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodHead},
ExposeHeaders: []auth.CORSHeader{"Content-Length"},
},
},
})
if err != nil {
t.Fatalf("marshal cors config: %v", err)
}
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, map[string]string{
"index.html": "home",
}, true)
be.corsConfig = corsConfig
resp := websiteRequestWithHeaders(t, be, http.MethodGet, "/", map[string]string{
"Origin": "https://client.example",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, HEAD" {
t.Fatalf("Access-Control-Allow-Methods = %q", got)
}
if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag, x-amz-storage-class" {
t.Fatalf("Access-Control-Expose-Headers = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("Access-Control-Allow-Credentials = %q", got)
}
if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" {
t.Fatalf("Vary = %q", got)
}
if got := readBody(t, resp); got != "home" {
t.Fatalf("body = %q, want %q", got, "home")
}
if !containsCall(be.calls, "GetBucketCors") {
t.Fatalf("GetBucketCors was not called: %v", be.calls)
}
}
func TestWebsiteHandlerHeadAppliesBucketCORS(t *testing.T) {
corsConfig, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead},
ExposeHeaders: []auth.CORSHeader{"Content-Length"},
},
},
})
if err != nil {
t.Fatalf("marshal cors config: %v", err)
}
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, map[string]string{
"index.html": "home",
}, true)
be.corsConfig = corsConfig
resp := websiteRequestWithHeaders(t, be, http.MethodHead, "/", map[string]string{
"Origin": "https://client.example",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "HEAD" {
t.Fatalf("Access-Control-Allow-Methods = %q", got)
}
if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag, x-amz-storage-class" {
t.Fatalf("Access-Control-Expose-Headers = %q", got)
}
if got := readBody(t, resp); got != "" {
t.Fatalf("body = %q, want empty body", got)
}
if !containsCall(be.calls, "GetBucketCors") {
t.Fatalf("GetBucketCors was not called: %v", be.calls)
}
if containsCall(be.calls, "GetObject") {
t.Fatalf("GetObject was called for HEAD request: %v", be.calls)
}
}
func TestWebsiteHandlerOptionsAccessGranted(t *testing.T) {
maxAge := int32(42)
corsConfig, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodHead},
AllowedHeaders: []auth.CORSHeader{"Content-Type", "X-Amz-Date"},
ExposeHeaders: []auth.CORSHeader{"Content-Length"},
MaxAgeSeconds: &maxAge,
},
},
})
if err != nil {
t.Fatalf("marshal cors config: %v", err)
}
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, map[string]string{
"index.html": "home",
}, true)
be.corsConfig = corsConfig
resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{
"Origin": "https://client.example",
"Access-Control-Request-Method": http.MethodGet,
"Access-Control-Request-Headers": "content-type, X-Amz-Date",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, HEAD" {
t.Fatalf("Access-Control-Allow-Methods = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "content-type, x-amz-date" {
t.Fatalf("Access-Control-Allow-Headers = %q", got)
}
if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length" {
t.Fatalf("Access-Control-Expose-Headers = %q", got)
}
if got := resp.Header.Get("Access-Control-Max-Age"); got != "42" {
t.Fatalf("Access-Control-Max-Age = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("Access-Control-Allow-Credentials = %q", got)
}
if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" {
t.Fatalf("Vary = %q", got)
}
if got := readBody(t, resp); got != "" {
t.Fatalf("body = %q, want empty body", got)
}
if !containsCall(be.calls, "GetBucketCors") {
t.Fatalf("GetBucketCors was not called: %v", be.calls)
}
for _, unexpected := range []string{"GetBucketWebsite", "GetObject", "HeadObject", "GetBucketAcl"} {
if containsCall(be.calls, unexpected) {
t.Fatalf("%s was called for OPTIONS request: %v", unexpected, be.calls)
}
}
}
func TestWebsiteHandlerOptionsMissingOrigin(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{
"Access-Control-Request-Method": http.MethodGet,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" {
t.Fatalf("x-amz-error-code = %q", got)
}
if containsCall(be.calls, "GetBucketCors") {
t.Fatalf("GetBucketCors was called despite missing origin: %v", be.calls)
}
}
func TestWebsiteHandlerOptionsInvalidRequestMethod(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{
"Origin": "https://client.example",
"Access-Control-Request-Method": http.MethodOptions,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" {
t.Fatalf("x-amz-error-code = %q", got)
}
if containsCall(be.calls, "GetBucketCors") {
t.Fatalf("GetBucketCors was called despite invalid request method: %v", be.calls)
}
}
func TestWebsiteHandlerOptionsUnsetBucketCORS(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
be.corsErr = s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{
"Origin": "https://client.example",
"Access-Control-Request-Method": http.MethodGet,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" {
t.Fatalf("x-amz-error-code = %q", got)
}
body := readBody(t, resp)
for _, want := range []string{
"<li>Method: OPTIONS</li>",
"<li>ResourceType: BUCKET</li>",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %s", want, body)
}
}
}
func TestWebsiteHandlerOptionsAccessForbidden(t *testing.T) {
corsConfig, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead},
},
},
})
if err != nil {
t.Fatalf("marshal cors config: %v", err)
}
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
be.corsConfig = corsConfig
resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{
"Origin": "https://client.example",
"Access-Control-Request-Method": http.MethodGet,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" {
t.Fatalf("x-amz-error-code = %q", got)
}
body := readBody(t, resp)
for _, want := range []string{
"<li>Method: OPTIONS</li>",
"<li>ResourceType: OBJECT</li>",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %s", want, body)
}
}
}
func TestWebsiteHandlerMethodNotAllowed(t *testing.T) {
be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
}, nil, true)
resp := websiteRequestWithMethod(t, be, http.MethodPut, "/some-key")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
}
if got := resp.Header.Get("Allow"); got != "GET, HEAD, OPTIONS" {
t.Fatalf("Allow = %q, want %q", got, "GET, HEAD, OPTIONS")
}
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", got)
}
if got := resp.Header.Get("Server"); got != "VERSITYGW" {
t.Fatalf("Server = %q, want %q", got, "VERSITYGW")
}
body := readBody(t, resp)
for _, want := range []string{
"<li>Code: MethodNotAllowed</li>",
"<li>Method: PUT</li>",
"<li>ResourceType: OBJECT</li>",
} {
if !strings.Contains(body, want) {
t.Fatalf("method not allowed body missing %q: %s", want, body)
}
}
if containsCall(be.calls, "GetBucketWebsite") {
t.Fatalf("unmatched method should not load website config: %v", be.calls)
}
}
func newWebsiteTestBackend(t *testing.T, config s3response.WebsiteConfiguration, objects map[string]string, public bool) *websiteTestBackend {
t.Helper()
data, err := xml.Marshal(config)
if err != nil {
t.Fatalf("marshal website config: %v", err)
}
if objects == nil {
objects = map[string]string{}
}
return &websiteTestBackend{
websiteConfig: data,
objects: objects,
objectRedirects: map[string]string{},
public: public,
}
}
func websiteRequest(t *testing.T, be backend.Backend, path string) *http.Response {
t.Helper()
return websiteRequestWithMethod(t, be, http.MethodGet, path)
}
func websiteRequestWithMethod(t *testing.T, be backend.Backend, method, path string) *http.Response {
t.Helper()
return websiteRequestWithHeaders(t, be, method, path, nil)
}
func websiteRequestWithHeaders(t *testing.T, be backend.Backend, method, path string, headers map[string]string) *http.Response {
t.Helper()
return websiteRequestWithHostAndHeaders(t, be, method, "site.test", path, headers)
}
func websiteRequestWithHostAndHeaders(t *testing.T, be backend.Backend, method, host, path string, headers map[string]string) *http.Response {
t.Helper()
return websiteRequestWithDomainHostAndHeaders(t, be, "", method, host, path, headers)
}
func websiteRequestWithDomainHostAndHeaders(t *testing.T, be backend.Backend, domain, method, host, path string, headers map[string]string) *http.Response {
t.Helper()
app := fiber.New(fiber.Config{ServerHeader: "VERSITYGW"})
registerWebsiteRoutes(app, be, domain)
req := httptest.NewRequest(method, path, nil)
req.Host = host
req.Header.Set("Host", host)
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := app.Test(req, fiber.TestConfig{Timeout: 0, FailOnTimeout: false})
if err != nil {
t.Fatalf("website request failed: %v", err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
return string(body)
}
func containsCall(calls []string, want string) bool {
return firstCallIndex(calls, want) != -1
}
func countCalls(calls []string, want string) int {
var count int
for _, call := range calls {
if call == want {
count++
}
}
return count
}
func firstCallIndex(calls []string, want string) int {
for i, call := range calls {
if call == want {
return i
}
}
return -1
}