package api import ( "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/http/httptest" "net/url" "testing" "time" apiErrors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" s3middleware "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/metrics" engineiam "git.frostfs.info/TrueCloudLab/policy-engine/iam" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory" "git.frostfs.info/TrueCloudLab/policy-engine/schema/s3" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) type routerMock struct { router *chi.Mux cfg Config middlewareSettings *middlewareSettingsMock policyChecker engine.LocalOverrideEngine } func (m *routerMock) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.router.ServeHTTP(w, r) } func prepareRouter(t *testing.T) *routerMock { middlewareSettings := &middlewareSettingsMock{} policyChecker := inmemory.NewInMemoryLocalOverrides() cfg := Config{ Throttle: middleware.ThrottleOpts{ Limit: 10, BacklogTimeout: 30 * time.Second, }, Handler: &handlerMock{t: t}, Center: ¢erMock{}, Log: zaptest.NewLogger(t), Metrics: &metrics.AppMetrics{}, MiddlewareSettings: middlewareSettings, PolicyChecker: policyChecker, Domains: []string{"domain1", "domain2"}, } return &routerMock{ router: NewRouter(cfg), cfg: cfg, middlewareSettings: middlewareSettings, policyChecker: policyChecker, } } func TestRouterUploadPart(t *testing.T) { chiRouter := prepareRouter(t) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPut, "/dkirillov/fix-object", nil) query := make(url.Values) query.Set("uploadId", "some-id") query.Set("partNumber", "1") r.URL.RawQuery = query.Encode() chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, "UploadPart", resp.Method) } func TestRouterListMultipartUploads(t *testing.T) { chiRouter := prepareRouter(t) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/test-bucket", nil) query := make(url.Values) query.Set("uploads", "") r.URL.RawQuery = query.Encode() chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, "ListMultipartUploads", resp.Method) } func TestRouterObjectWithSlashes(t *testing.T) { chiRouter := prepareRouter(t) bktName, objName := "dkirillov", "/fix/object" target := fmt.Sprintf("/%s/%s", bktName, objName) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, "PutObject", resp.Method) require.Equal(t, objName, resp.ReqInfo.ObjectName) } func TestRouterObjectEscaping(t *testing.T) { chiRouter := prepareRouter(t) bktName := "dkirillov" for _, tc := range []struct { name string expectedObjName string objName string }{ { name: "simple", expectedObjName: "object", objName: "object", }, { name: "with slashes", expectedObjName: "fix/object", objName: "fix/object", }, { name: "with slash escaped", expectedObjName: "/foo/bar", objName: "/foo%2fbar", }, { name: "with percentage escaped", expectedObjName: "fix/object%ac", objName: "fix/object%25ac", }, { name: "with awful mint name", expectedObjName: "äöüex ®©µÄÆÐÕæŒƕƩDž 01000000 0x40 \u0040 amȡȹɆple&0a!-_.*'()&$@=;:+,?<>.pdf", objName: "%C3%A4%C3%B6%C3%BCex%20%C2%AE%C2%A9%C2%B5%C3%84%C3%86%C3%90%C3%95%C3%A6%C5%92%C6%95%C6%A9%C7%85%2001000000%200x40%20%40%20am%C8%A1%C8%B9%C9%86ple%260a%21-_.%2A%27%28%29%26%24%40%3D%3B%3A%2B%2C%3F%3C%3E.pdf", }, } { t.Run(tc.name, func(t *testing.T) { target := fmt.Sprintf("/%s/%s", bktName, tc.objName) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, "PutObject", resp.Method) require.Equal(t, tc.expectedObjName, resp.ReqInfo.ObjectName) }) } } func TestPolicyChecker(t *testing.T) { chiRouter := prepareRouter(t) namespace := "custom-ns" bktName, objName := "bucket", "object" target := fmt.Sprintf("/%s/%s", bktName, objName) ruleChain := &chain.Chain{ ID: "id", Rules: []chain.Rule{{ Status: chain.AccessDenied, Actions: chain.Actions{Names: []string{"*"}}, Resources: chain.Resources{Names: []string{fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName)}}, }}, } _, _, err := chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(namespace), ruleChain) require.NoError(t, err) // check we can access 'bucket' in default namespace w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.PutObjectOperation, resp.Method) // check we can access 'other-bucket' in custom namespace w, r = httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, "/other-bucket/object", nil) r.Header.Set(FrostfsNamespaceHeader, namespace) chiRouter.ServeHTTP(w, r) resp = readResponse(t, w) require.Equal(t, s3middleware.PutObjectOperation, resp.Method) // check we cannot access 'bucket' in custom namespace w, r = httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil) r.Header.Set(FrostfsNamespaceHeader, namespace) chiRouter.ServeHTTP(w, r) assertAPIError(t, w, apiErrors.ErrAccessDenied) } func TestPolicyCheckerReqTypeDetermination(t *testing.T) { chiRouter := prepareRouter(t) bktName, objName := "bucket", "object" policy := engineiam.Policy{ Statement: []engineiam.Statement{{ Principal: map[engineiam.PrincipalType][]string{engineiam.Wildcard: {}}, Effect: engineiam.AllowEffect, Action: engineiam.Action{"s3:*"}, Resource: engineiam.Resource{fmt.Sprintf(s3.ResourceFormatS3All)}, }}, } ruleChain, err := engineiam.ConvertToS3Chain(policy, nil) require.NoError(t, err) _, _, err = chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(""), ruleChain) require.NoError(t, err) chiRouter.middlewareSettings.denyByDefault = true t.Run("can list buckets", func(t *testing.T) { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.ListBucketsOperation, resp.Method) }) t.Run("can head 'bucket'", func(t *testing.T) { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodHead, "/"+bktName, nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.HeadBucketOperation, resp.Method) }) t.Run("can put object into 'bucket'", func(t *testing.T) { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, fmt.Sprintf("/%s/%s", bktName, objName), nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.PutObjectOperation, resp.Method) }) } func TestDefaultBehaviorPolicyChecker(t *testing.T) { chiRouter := prepareRouter(t) bktName, objName := "bucket", "object" target := fmt.Sprintf("/%s/%s", bktName, objName) // check we can access bucket if rules not found w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.PutObjectOperation, resp.Method) // check we cannot access if rules not found when settings is enabled chiRouter.middlewareSettings.denyByDefault = true w, r = httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) assertAPIError(t, w, apiErrors.ErrAccessDenied) } func readResponse(t *testing.T, w *httptest.ResponseRecorder) handlerResult { var res handlerResult resData, err := io.ReadAll(w.Result().Body) require.NoError(t, err) err = json.Unmarshal(resData, &res) require.NoErrorf(t, err, "actual body: '%s'", string(resData)) return res } func assertAPIError(t *testing.T, w *httptest.ResponseRecorder, expectedErrorCode apiErrors.ErrorCode) { actualErrorResponse := &s3middleware.ErrorResponse{} err := xml.NewDecoder(w.Result().Body).Decode(actualErrorResponse) require.NoError(t, err) expectedError := apiErrors.GetAPIError(expectedErrorCode) require.Equal(t, expectedError.HTTPStatusCode, w.Code) require.Equal(t, expectedError.Code, actualErrorResponse.Code) if expectedError.ErrCode != apiErrors.ErrInternalError { require.Contains(t, actualErrorResponse.Message, expectedError.Description) } }