objstore: minio contract test pinning the delivered S3 contract
TestDeliveredContractMinio (internal/objstore/contract_test.go) builds an S3 client exactly the way a consuming app would from delivered grant env — static credentials, region us-east-1, BaseEndpoint, UsePathStyle, and the RequestChecksumCalculation/ResponseChecksumValidation WhenRequired pair mirroring aws.go's NewAWSProvisioner endpoint options — and asserts Put/Get/List/Delete round-trip plus cross-bucket denial (PutObject to an ungranted bucket fails with a smithy.APIError code "AccessDenied"). It skips unless CARLOS_MINIO_TEST=1, reading MINIO_ENDPOINT, MINIO_SCOPED_KEY, MINIO_SCOPED_SECRET, MINIO_BUCKET, MINIO_DENIED_BUCKET. New minio-contract job in .github/workflows/ci.yml starts a real Minio container, mints a scoped IAM user restricted to one bucket via mc, and runs the test with CARLOS_MINIO_TEST=1 set. This sandbox has no docker: the live path (skip-bypassed, against a real Minio) has only ever been exercised by go vet/build reasoning here, not run. Verified locally: the skip path runs green (`go test ./internal/objstore/ -run TestDeliveredContractMinio -v` -> SKIP), the full gate (go build/vet/test -count=1, gofmt -l, GOOS=darwin build) stays green across all 21 packages, and ci.yml parses as YAML (python3 + pyyaml). The first CI run on the PR is the real proof of the live path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+227
−0
.github/workflows/ci.yml+45 −0go.sum+5 −0internal/objstore/contract_test.go+177 −0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml| index b461422e5..46acb187c 100644 |
| --- a/.github/workflows/ci.yml |
| +++ b/.github/workflows/ci.yml |
| @@ -70,6 +70,51 @@ jobs: |
| GOARCH: ${{ matrix.goarch }} |
| CGO_ENABLED: "0" |
| + minio-contract: |
| + # Exercises internal/objstore's TestDeliveredContractMinio: the S3 |
| + # contract a member app actually gets from delivered grant env (static |
| + # creds, path-style, us-east-1, checksum WhenRequired), against a real |
| + # S3-compatible server plus a scoped user IAM never granted the second |
| + # bucket. This is the only place in the gate that runs that test live — |
| + # see internal/objstore/contract_test.go's doc comment. |
| + name: minio contract |
| + runs-on: ubuntu-latest |
| + steps: |
| + - uses: actions/checkout@v4 |
| + - uses: actions/setup-go@v5 |
| + with: |
| + go-version-file: go.mod |
| + cache: true |
| + - name: start minio |
| + run: | |
| + docker run -d --name minio -p 9000:9000 \ |
| + -e MINIO_ROOT_USER=root -e MINIO_ROOT_PASSWORD=rootsecret \ |
| + quay.io/minio/minio server /data |
| + for i in $(seq 1 30); do curl -sf http://127.0.0.1:9000/minio/health/live && break; sleep 1; done |
| + - name: mint scoped user |
| + run: | |
| + curl -sfL https://dl.min.io/client/mc/release/linux-amd64/mc -o mc && chmod +x mc |
| + ./mc alias set local http://127.0.0.1:9000 root rootsecret |
| + ./mc mb local/contract-bucket local/denied-bucket |
| + cat > /tmp/policy.json <<'EOF' |
| + {"Version": "2012-10-17", "Statement": [ |
| + {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]}, |
| + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]} |
| + ]} |
| + EOF |
| + ./mc admin policy create local contract-only /tmp/policy.json |
| + ./mc admin user add local scopeduser scopedsecret1 |
| + ./mc admin policy attach local contract-only --user scopeduser |
| + - name: contract test |
| + env: |
| + CARLOS_MINIO_TEST: "1" |
| + MINIO_ENDPOINT: http://127.0.0.1:9000 |
| + MINIO_SCOPED_KEY: scopeduser |
| + MINIO_SCOPED_SECRET: scopedsecret1 |
| + MINIO_BUCKET: contract-bucket |
| + MINIO_DENIED_BUCKET: denied-bucket |
| + run: go test ./internal/objstore/ -run TestDeliveredContractMinio -count=1 -v |
| + |
| gofmt: |
| name: gofmt (changed files) |
| runs-on: ubuntu-latest |
diff --git a/go.sum b/go.sum| index a6019d188..0a0183997 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -103,6 +103,7 @@ github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh |
| github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc= |
| github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= |
| github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= |
| @@ -119,6 +120,7 @@ github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2 |
| github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY= |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= |
| github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= |
| github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= |
| github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= |
| @@ -180,6 +182,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 |
| go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= |
| golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= |
| golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= |
| +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= |
| @@ -210,6 +213,8 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= |
| google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= |
| google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= |
| google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= |
| +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= |
| +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= |
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
diff --git a/internal/objstore/contract_test.go b/internal/objstore/contract_test.go| new file mode 100644 |
| index 000000000..6a9489a57 |
| --- /dev/null |
| +++ b/internal/objstore/contract_test.go |
| @@ -0,0 +1,177 @@ |
| +package objstore |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "io" |
| + "os" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| + "github.com/aws/smithy-go" |
| +) |
| + |
| +// TestDeliveredContractMinio pins spec §1's delivered contract from outside |
| +// the package: given exactly the four values a consuming app receives after |
| +// grant (endpoint, key, secret, bucket) plus a second bucket the scoped user |
| +// was never granted, build an S3 client the way that consuming app would — |
| +// not via NewAWSProvisioner or any objstore helper — and exercise Put/Get/ |
| +// List/Delete plus cross-bucket denial against a real (Minio) S3-compatible |
| +// server. |
| +// |
| +// This is deliberately not a unit test against a stub: internal/objstore's |
| +// own tests already cover the provisioner logic with fakes. What has never |
| +// been exercised anywhere in this tree is the actual wire contract a member |
| +// app's client.Options() produces from delivered env, against a real |
| +// S3-compatible implementation. Skipped by default because it needs a |
| +// running Minio; CI's minio-contract job (.github/workflows/ci.yml) sets |
| +// CARLOS_MINIO_TEST=1 and stands one up. There is no docker in this |
| +// sandbox, so the live path here has only ever been verified in CI — see |
| +// the commit body. |
| +func TestDeliveredContractMinio(t *testing.T) { |
| + if os.Getenv("CARLOS_MINIO_TEST") != "1" { |
| + t.Skip("set CARLOS_MINIO_TEST=1 (with a running Minio and the MINIO_* env vars) to run the delivered-contract test; see .github/workflows/ci.yml's minio-contract job") |
| + } |
| + |
| + endpoint := requireEnv(t, "MINIO_ENDPOINT") |
| + key := requireEnv(t, "MINIO_SCOPED_KEY") |
| + secret := requireEnv(t, "MINIO_SCOPED_SECRET") |
| + bucket := requireEnv(t, "MINIO_BUCKET") |
| + deniedBucket := requireEnv(t, "MINIO_DENIED_BUCKET") |
| + |
| + ctx := context.Background() |
| + |
| + // Built exactly the way a consuming app builds it from delivered env: |
| + // static credentials, region us-east-1 (spec §1's delivered region), |
| + // BaseEndpoint + UsePathStyle for addressing, and the checksum |
| + // WhenRequired pair — the same four option lines aws.go's |
| + // NewAWSProvisioner applies for a non-empty endpoint, mirrored here |
| + // independently rather than by calling into objstore's own client |
| + // construction, since the whole point is to pin what a member app gets, |
| + // not to test this package's internals against themselves. |
| + client := awss3.New(awss3.Options{ |
| + Region: "us-east-1", |
| + Credentials: credentials.NewStaticCredentialsProvider(key, secret, ""), |
| + BaseEndpoint: aws.String(endpoint), |
| + UsePathStyle: true, |
| + RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, |
| + ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, |
| + }) |
| + |
| + const objectKey = "contract-test/hello.txt" |
| + const body = "hello from the delivered contract test" |
| + |
| + t.Run("put", func(t *testing.T) { |
| + _, err := client.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(objectKey), |
| + Body: bytes.NewReader([]byte(body)), |
| + }) |
| + if err != nil { |
| + t.Fatalf("PutObject: %v", err) |
| + } |
| + }) |
| + |
| + t.Run("get round-trips the body", func(t *testing.T) { |
| + out, err := client.GetObject(ctx, &awss3.GetObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(objectKey), |
| + }) |
| + if err != nil { |
| + t.Fatalf("GetObject: %v", err) |
| + } |
| + defer out.Body.Close() |
| + got, err := io.ReadAll(out.Body) |
| + if err != nil { |
| + t.Fatalf("read GetObject body: %v", err) |
| + } |
| + if string(got) != body { |
| + t.Fatalf("GetObject body = %q, want %q", got, body) |
| + } |
| + }) |
| + |
| + t.Run("list shows the key", func(t *testing.T) { |
| + out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ |
| + Bucket: aws.String(bucket), |
| + }) |
| + if err != nil { |
| + t.Fatalf("ListObjectsV2: %v", err) |
| + } |
| + if !containsKey(out.Contents, objectKey) { |
| + t.Fatalf("ListObjectsV2 contents = %v, want to contain %q", keyNames(out.Contents), objectKey) |
| + } |
| + }) |
| + |
| + t.Run("delete removes it", func(t *testing.T) { |
| + _, err := client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(objectKey), |
| + }) |
| + if err != nil { |
| + t.Fatalf("DeleteObject: %v", err) |
| + } |
| + out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ |
| + Bucket: aws.String(bucket), |
| + }) |
| + if err != nil { |
| + t.Fatalf("ListObjectsV2 after delete: %v", err) |
| + } |
| + if containsKey(out.Contents, objectKey) { |
| + t.Fatalf("ListObjectsV2 after delete still contains %q", objectKey) |
| + } |
| + }) |
| + |
| + t.Run("cross-bucket denial", func(t *testing.T) { |
| + _, err := client.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String(deniedBucket), |
| + Key: aws.String(objectKey), |
| + Body: bytes.NewReader([]byte(body)), |
| + }) |
| + if err == nil { |
| + t.Fatalf("PutObject to %q: want AccessDenied, got success", deniedBucket) |
| + } |
| + var apiErr smithy.APIError |
| + if !errors.As(err, &apiErr) { |
| + t.Fatalf("PutObject to %q: err = %v (%T), want a smithy.APIError", deniedBucket, err, err) |
| + } |
| + if apiErr.ErrorCode() != "AccessDenied" { |
| + t.Fatalf("PutObject to %q: error code = %q, want %q (message: %s)", deniedBucket, apiErr.ErrorCode(), "AccessDenied", apiErr.ErrorMessage()) |
| + } |
| + }) |
| +} |
| + |
| +// requireEnv fetches name from the environment, failing the test fast with |
| +// a clear message if it is unset — a missing var here means the CI job's |
| +// env block and this test's expectations have drifted, not that the |
| +// contract itself is broken. |
| +func requireEnv(t *testing.T, name string) string { |
| + t.Helper() |
| + v := os.Getenv(name) |
| + if v == "" { |
| + t.Fatalf("%s is not set (required when CARLOS_MINIO_TEST=1)", name) |
| + } |
| + return v |
| +} |
| + |
| +// containsKey and keyNames are small helpers over ListObjectsV2's Contents so |
| +// the assertions above read as intent, not index arithmetic. |
| +func containsKey(objs []s3types.Object, key string) bool { |
| + for _, o := range objs { |
| + if aws.ToString(o.Key) == key { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func keyNames(objs []s3types.Object) []string { |
| + names := make([]string, len(objs)) |
| + for i, o := range objs { |
| + names[i] = aws.ToString(o.Key) |
| + } |
| + return names |
| +} |