diff --git a/cmd/object-attributes-read-error_test.go b/cmd/object-attributes-read-error_test.go new file mode 100644 index 000000000..7b00495fa --- /dev/null +++ b/cmd/object-attributes-read-error_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Denis Babchuk +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "bytes" + "context" + "encoding/xml" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/minio/minio/internal/auth" + xhttp "github.com/minio/minio/internal/http" +) + +// GetObjectAttributes answers NoSuchKey with AccessDenied to a caller without +// s3:ListBucket on the bucket, as S3 does, so that a missing object cannot be +// told apart from a forbidden one. A server error, such as lost read quorum, +// must reach a caller allowed to read the object as the error it is. +func TestGetObjectAttributesReadQuorumLost(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + obj, dirs, err := prepareErasure16(ctx) + if err != nil { + t.Fatal(err) + } + z := obj.(*erasureServerPools) + previous := newObjectLayerFn() + setObjectLayer(z) + defer func() { + z.Shutdown(context.Background()) + removeRoots(dirs) + setObjectLayer(previous) + }() + bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{}) + if err != nil { + t.Fatal(err) + } + const object, missing = "object", "missing" + if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{}); err != nil { + t.Fatal(err) + } + + read := `{"Effect": "Allow", "Action": ["s3:GetObject", "s3:GetObjectAttributes"], "Resource": ["arn:aws:s3:::` + bucket + `/*"]}` + withoutList := newDeleteAuthzPolicyUser(t, "", bucket, "["+read+"]") + withList := newDeleteAuthzPolicyUser(t, "", bucket, "["+read+ + `, {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::`+bucket+`"]}]`) + + check := func(stage, user string, creds auth.Credentials, key string, want int) { + t.Helper() + target := makeTestTargetURL("", bucket, key, url.Values{"attributes": []string{""}}) + req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, creds.AccessKey, creds.SecretKey, + map[string]string{xhttp.AmzObjectAttributes: "ETag"}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != want { + var apiErr APIErrorResponse + _ = xml.Unmarshal(rec.Body.Bytes(), &apiErr) + t.Errorf("%s: GetObjectAttributes %s by %s: status %d %s, want %d", stage, key, user, rec.Code, apiErr.Code, want) + } + } + + check("healthy", "withoutList", withoutList, object, http.StatusOK) + check("healthy", "withList", withList, object, http.StatusOK) + check("healthy", "withoutList", withoutList, missing, http.StatusForbidden) + check("healthy", "withList", withList, missing, http.StatusNotFound) + + defer objectReadFault(z, bucket, object, 10)() + check("read quorum lost", "withoutList", withoutList, object, http.StatusServiceUnavailable) + check("read quorum lost", "withList", withList, object, http.StatusServiceUnavailable) +}