Files
seaweedfs/weed/s3api/s3_constants/header_test.go
Chris LuandGitHub c00aa90990 fix(s3/audit): populate requester for GET/HEAD/IAM operations (#9581)
Authentication records the identity with r.WithContext, which returns a
request copy. Handlers that log their own audit entry (PUT, DELETE,
tagging) see it, but GET/HEAD object and IAM operations rely on track()'s
fallback entry, which is built from the original request the auth copy
never reached - so requester came out empty.

Install a mutable identity holder on the request before authentication
and have SetIdentityNameInContext record into it. The holder is shared by
pointer across every request copy, so the fallback entry recovers the
authenticated requester. The per-request context value still takes
precedence, so nothing changes for handlers that see the auth copy.
2026-05-20 10:13:33 -07:00

186 lines
4.8 KiB
Go

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 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)
}
}