Files
RenewCMS/features/s3_image_storage.feature

144 lines
7.9 KiB
Gherkin

Feature: Uploaded images can be stored in an S3-compatible bucket
As an operator whose host disk is ephemeral or who runs several hosts
I want to set STORAGE_DRIVER=s3 and bucket settings
So that uploads live in the bucket while images are still served by the app
at /static/uploadedImages/<name>, with unchanged URLs
Background:
Given STORAGE_DRIVER defaults to "local" and local disk behaves exactly as today
And an S3 storage implements image.Storage (Put, Open, Delete) in internal/infrastructure/storage
And storage.Open has a case for the driver "s3" and openImageStorage selects it from the settings
And no domain, use case, repository or staticimages handler file changes
And the bucket is faked by an in-process httptest S3 server listening on 127.0.0.1
And Open returns a Blob whose Content is an io.ReadSeeker, so http.ServeContent handles Range and conditional requests
And the Blob ModTime and Size come from the S3 object metadata
And no ETag is emitted and conditional requests are validated by Last-Modified only
Scenario Outline: Startup configuration validation
Given STORAGE_DRIVER is <driver> and the S3 variables are <vars>
When the storage settings are validated
Then the result is <result>
Examples:
| driver | vars | result |
| (unset) | none | driver local, no S3 variable required |
| local | S3_BUCKET set | driver local, S3 variables ignored |
| s3 | S3_BUCKET and S3_REGION set | valid, default AWS credential chain |
| s3 | S3_BUCKET set, S3_REGION unset | region defaults to us-east-1 |
| s3 | S3_REGION set, S3_BUCKET unset | error "Environment variable S3_BUCKET is not set" |
| s3 | key id set, secret unset | error naming S3_SECRET_ACCESS_KEY |
| s3 | secret set, key id unset | error naming S3_ACCESS_KEY_ID |
| s3 | UPLOAD_DIR set | valid, UPLOAD_DIR ignored without error |
| gcs | none | error unsupported storage driver "gcs" |
Scenario: The registry opens the S3 storage after a bucket check
Given the fake S3 answers HEAD bucket with 200
When storage.Open is called with driver "s3"
Then it returns a storage and no error
Scenario Outline: Startup fails fast with a clear secret-free error
Given the fake S3 answers HEAD bucket with <status>
When storage.Open is called with driver "s3" and secret access key "TOPSECRET"
Then it returns an error mentioning the bucket name
And the error text does not contain "TOPSECRET"
And openImageStorage panics with that same text
Examples:
| status |
| 404 |
| 403 |
| 500 |
Scenario: Put stores the object under the prefix with its content type
Given S3_KEY_PREFIX is "uploads/"
When Put is called with stored name "a1b2.png", content type "image/png" and 3 bytes
Then the fake S3 holds key "uploads/a1b2.png" with those bytes and Content-Type "image/png"
Scenario: Put failure is returned as an error
Given the fake S3 answers PUT with 500
When Put is called
Then Put returns a non-nil error that is not image.ErrBlobNotFound
Scenario: Open returns content, size and modification time
Given the fake S3 holds "uploads/a1b2.png" with 10 bytes and a known Last-Modified
When Open("a1b2.png") is called
Then the Blob has Size 10 and that ModTime
And its Content implements io.ReadSeeker and reads the 10 bytes
Scenario: Open of a missing key is not found
Given the fake S3 answers GET with 404 NoSuchKey
When Open("missing.png") is called
Then the result is image.ErrBlobNotFound, asserted with errors.Is
Scenario: Open with a backend failure is not a not-found
Given the fake S3 answers GET with 500
When Open is called
Then the error is non-nil and is not image.ErrBlobNotFound
Scenario Outline: Delete is idempotent
Given the fake S3 <state>
When Delete("a1b2.png") is called
Then it returns no error
Examples:
| state |
| holds the key |
| does not hold the key (404) |
Scenario: A backend failure on Delete is returned
Given the fake S3 answers DELETE with 500
When Delete is called
Then it returns a non-nil error
Scenario Outline: Serving /static/uploadedImages/<name> from the bucket
Given the bucket holds "a1b2.png" with 10 bytes and a known Last-Modified
When <method> /static/uploadedImages/a1b2.png is requested <with>
Then the status is <status>
And the response has <result>
Examples:
| method | with | status | result |
| GET | no extra header | 200 | Content-Type image/png, Content-Length 10, Last-Modified, Accept-Ranges bytes, Cache-Control "public, no-cache", X-Content-Type-Options nosniff |
| HEAD | no extra header | 200 | the same headers and an empty body |
| GET | If-Modified-Since = Last-Modified | 304 | an empty body |
| GET | Range bytes=0-3 | 206 | Content-Range "bytes 0-3/10" and Content-Length 4 |
| GET | Range bytes=50-60 | 416 | Content-Range "bytes */10" |
Scenario: SVG from the bucket keeps its security headers
Given the bucket holds "logo.svg"
When GET /static/uploadedImages/logo.svg is requested
Then Content-Type is image/svg+xml
And Content-Security-Policy is "default-src 'none'; style-src 'unsafe-inline'; sandbox"
Scenario: Missing object is 404 and a backend failure is 500
Given the bucket does not hold "missing.png"
When GET /static/uploadedImages/missing.png is requested
Then the status is 404
Given the fake S3 answers GET with 500
When GET /static/uploadedImages/a1b2.png is requested
Then the status is 500 and the plain text body is "Unable to read the file"
Scenario: Path traversal never reaches another key
When GET /static/uploadedImages/../secret.png is requested
Then the key requested from the fake S3 is the prefix plus "secret.png"
Scenario: Handler as-is for a non-seekable blob
Given a storage fake returning a Blob whose Content is not an io.ReadSeeker, with Size 10 and a ModTime
When GET and HEAD /static/uploadedImages/a1b2.png are requested
Then GET returns 200 with Content-Length 10, Last-Modified and the body
And HEAD returns 200 with the same headers and an empty body
And no Range or ETag behaviour is specified for this path
Scenario: Boot backfill logs how many images it read from the bucket
Given the driver is s3 and 3 stored images lack metadata
When InitContainer runs the boot backfill
Then a log line reports 3 images read from storage and contains no credential
And backfill behaviour and errors are unchanged
And no cap is applied and BackfillImageMetadataUseCase is not modified
Scenario: The local driver is unchanged
Given STORAGE_DRIVER is unset and UPLOAD_DIR is a temp directory
When an image is uploaded and requested
Then it is stored in UPLOAD_DIR and served exactly as before