package s3_constants import ( "net/http" "net/http/httptest" "testing" ) func TestNormalizeObjectKey(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "simple key", input: "file.txt", expected: "file.txt", }, { name: "key with leading slash", input: "/file.txt", expected: "file.txt", }, { name: "key with directory", input: "folder/file.txt", expected: "folder/file.txt", }, { name: "key with leading slash and directory", input: "/folder/file.txt", expected: "folder/file.txt", }, { name: "key with duplicate slashes", input: "folder//subfolder///file.txt", expected: "folder/subfolder/file.txt", }, { name: "Windows backslash - simple", input: "folder\\file.txt", expected: "folder/file.txt", }, { name: "Windows backslash - nested", input: "folder\\subfolder\\file.txt", expected: "folder/subfolder/file.txt", }, { name: "Windows backslash - with leading slash", input: "/folder\\subfolder\\file.txt", expected: "folder/subfolder/file.txt", }, { name: "mixed slashes", input: "folder\\subfolder/another\\file.txt", expected: "folder/subfolder/another/file.txt", }, { name: "Windows full path style (edge case)", input: "C:\\Users\\test\\file.txt", expected: "C:/Users/test/file.txt", }, { name: "empty string", input: "", expected: "", }, { name: "just a slash", input: "/", expected: "", }, { name: "just a backslash", input: "\\", expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := NormalizeObjectKey(tt.input) if result != tt.expected { t.Errorf("NormalizeObjectKey(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestIsValidObjectKey(t *testing.T) { tests := []struct { name string input string want bool }{ {"empty", "", true}, {"plain", "folder/file.txt", true}, {"leading slash", "/folder/file.txt", true}, {"trailing slash", "folder/", true}, {"hidden file ok", ".hidden", true}, {"dotdot in name ok", "..hidden", true}, {"double dots inside name", "foo..bar/baz", true}, {"bare dotdot", "..", false}, {"bare dot", ".", false}, {"leading dotdot segment", "../evil-bucket/test.txt", false}, {"leading dot-slash", "./evil/test.txt", false}, {"nested dotdot segment", "good/../evil/test.txt", false}, {"trailing dotdot segment", "good/..", false}, {"backslash dotdot", "..\\evil\\test.txt", false}, {"mixed-slash dotdot", "good\\..\\evil/test.txt", false}, {"dotdot after duplicate slash", "good//../evil", false}, {"nul byte", "foo\x00bar", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsValidObjectKey(tt.input); got != tt.want { t.Errorf("IsValidObjectKey(%q) = %v, want %v", tt.input, got, tt.want) } }) } } func TestIsValidBucketName(t *testing.T) { tests := []struct { name string input string want bool }{ {"empty ok", "", true}, {"plain", "my-bucket", true}, {"name containing dots", "my.bucket.name", true}, {"bare dot", ".", false}, {"bare dotdot", "..", false}, {"with slash", "evil/bucket", false}, {"with backslash", "evil\\bucket", false}, {"with nul", "evil\x00bucket", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsValidBucketName(tt.input); got != tt.want { t.Errorf("IsValidBucketName(%q) = %v, want %v", tt.input, got, tt.want) } }) } } func TestIsValidPathSegment(t *testing.T) { tests := []struct { input string want bool }{ {"opaque-id_123", true}, {"..hidden", true}, {"", false}, {".", false}, {"..", false}, {"dir/value", false}, {"dir\\value", false}, {"nul\x00value", false}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { if got := IsValidPathSegment(tt.input); got != tt.want { t.Errorf("IsValidPathSegment(%q) = %v, want %v", tt.input, got, tt.want) } }) } } func TestRemoveDuplicateSlashes(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "no duplicates", input: "/folder/file.txt", expected: "/folder/file.txt", }, { name: "double slash", input: "/folder//file.txt", expected: "/folder/file.txt", }, { name: "triple slash", input: "/folder///file.txt", expected: "/folder/file.txt", }, { name: "multiple duplicate locations", input: "//folder//subfolder///file.txt", expected: "/folder/subfolder/file.txt", }, { name: "empty string", input: "", expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := removeDuplicateSlashes(tt.input) if result != tt.expected { t.Errorf("removeDuplicateSlashes(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } // TestIdentityHolderPropagation reproduces the audit-log middleware chain: an // outer handler installs the holder, an inner handler authenticates and records // the identity on a request copy, and the outer handler must still recover the // requester from its own (earlier) copy of the request. func TestIdentityHolderPropagation(t *testing.T) { outer := httptest.NewRequest(http.MethodGet, "/bucket/object", nil) // Before the holder is installed there is no identity to recover. if got := GetIdentityNameFromContext(outer); got != "" { t.Fatalf("unauthenticated request reports requester %q, want empty", got) } outer = EnsureIdentityHolder(outer) // The inner handler sets the identity on a copy, mirroring how auth wraps // the request with r.WithContext before invoking the next handler. innerCtx := SetIdentityNameInContext(outer.Context(), "admin") inner := outer.WithContext(innerCtx) if got := GetIdentityNameFromContext(inner); got != "admin" { t.Errorf("inner request requester = %q, want admin", got) } if got := GetIdentityNameFromContext(outer); got != "admin" { t.Errorf("outer request requester = %q, want admin (must propagate via holder)", got) } } func TestEnsureIdentityHolderIdempotent(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil) first := EnsureIdentityHolder(req) if first == req { t.Fatal("EnsureIdentityHolder should return a new request when no holder is present") } again := EnsureIdentityHolder(first) if again != first { t.Error("EnsureIdentityHolder should be idempotent when a holder is already present") } } func TestGetIdentityNameFromContextWithoutHolder(t *testing.T) { // Without a holder, the per-request context value is still honored so paths // that set identity without installing a holder keep working. req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil) req = req.WithContext(SetIdentityNameInContext(req.Context(), "admin")) if got := GetIdentityNameFromContext(req); got != "admin" { t.Errorf("requester = %q, want admin", got) } }