diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b7e01..65030f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,16 @@ permissions: packages: read jobs: + frontend-fixtures: + name: Frontend image fixture contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - run: python -m unittest discover -s tests/frontend -p 'test_*.py' + java: name: ${{ matrix.service }} tests and coverage runs-on: ubuntu-latest diff --git a/compose.frontend-demo.yml b/compose.frontend-demo.yml index d75db14..50e7bae 100644 --- a/compose.frontend-demo.yml +++ b/compose.frontend-demo.yml @@ -16,6 +16,7 @@ services: context: ./web/admin args: NEXT_PUBLIC_APP_ENV: fixture + NEXT_PUBLIC_ENABLE_PRODUCT_IMAGE_UPLOADS: "true" gateway: image: python:3.13-slim command: diff --git a/docs/frontend/image-verification.md b/docs/frontend/image-verification.md new file mode 100644 index 0000000..bbac4c9 --- /dev/null +++ b/docs/frontend/image-verification.md @@ -0,0 +1,130 @@ +--- +icon: lucide/clipboard-check +--- + +# Product image verification + +The owner selected S3/CloudFront and approved the AWS SDK. Product is deliberately +deferred until its repository exists. This delivery implements the frontend and +records the remaining [backend work](../services/product/images.md). It does not +claim S3 persistence, cloud provisioning or real-service acceptance. + +## Executed checks + +| App | Passing workflow | Evidence | +| ------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Landing | [42603058404](https://github.com/Microsserv52/landing/actions/runs/42603058404) | Formatting, ESLint, types, six unit tests, static build and six desktop/mobile browser cases | +| Store | [42603060813](https://github.com/Microsserv52/store/actions/runs/42603060813) | Same checks, six unit tests and ten desktop/mobile browser cases | +| Admin | [42603940090](https://github.com/Microsserv52/admin/actions/runs/42603940090) | Same checks, six unit tests and six desktop/mobile browser cases, including light/dark screenshots | + +All three local pre-commit configurations passed. The static exports and local +Dockerfiles build successfully. No new frontend package or lockfile dependency +was required. The approved AWS SDK is reserved for the future Product backend, +where storage credentials and authorization belong. + +Three stdlib HTTP fixture tests passed with: + +```bash +python3 -m unittest discover -s tests/frontend -p 'test_*.py' +``` + +They exercise upload/replace/content reads, alt edits, removal, unchanged other +products, missing CSRF, non-admin access, invalid size/type/dimensions, expired +tickets, wrong-length files and replay/overwrite rejection. These are fixture +contract checks, not proof of real S3 or Product security. The fixture trusts +client dimensions and holds bytes/tickets only in memory. + +## Browser evidence + +The image suites run against exported files with explicit API fixtures on +Chromium desktop and Pixel 5 profiles. Browser control through the local app was +unavailable in this session, so no manual click-through is claimed. CI provides +the actual browser interactions and screenshot artifacts. + +| Element/state | Observed automated outcome | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Public image URL | Image decoded with nonzero natural width and the supplied alternative text | +| Failed image fetch | Honest unavailable-image fallback replaced the broken image | +| Legacy product | Optional image field preserved backward compatibility | +| Manage image | Opened the product's dialog for an administrator | +| Empty image dialog | Save disabled until a valid selection | +| Invalid file | Visible JPEG/PNG/size validation message | +| File picker and alternative text | Valid existing project-logo fixture selected and previewed; no invented product photography | +| Save | Requested upload authorization, sent raw PUT with the allowed conditional header and attached the resulting image | +| Replace | New versioned URL returned and the replacement rendered | +| Alt edit | Updated metadata without another file upload | +| Remove / Keep | Confirmation appeared; Keep canceled removal | +| Confirm removal | Image reference removed and dialog closed | +| Cancel / Escape | Dismissed the overlay without saving | +| Theme | Light and Dark dialogs rendered and were captured after settling transitions | +| Mobile | The page stayed within viewport width; dialog content scrolls within its bounded overlay | +| Runtime errors | New suites asserted no uncaught browser page errors | + +The first captured screenshots revealed poor visibility of a transparent white +fixture on the paper surface. A neutral gray image background now supports +transparent assets in both themes. Screenshots disable finite transitions to +capture the settled view, rather than a partially faded dialog. Cards use a +consistent crop; the product detail view contains the entire image. + +## antislop gate for the image extension + +This extends the [full application gate](verification.md#antislop-delivery-gate). +The existing palette, typography, navigation, landmarks and mock-store notices +remain in use. The following records each requirement against the changed UI. + +| Rule | Result and evidence | +| ---- | -------------------------------------------------------------------------------------------------------------------------------- | +| R-01 | PASS: existing paper/ink/rust palette retained; gray media background serves transparency visibility | +| R-02 | PASS: new copy scanned for the prohibited punctuation character | +| R-03 | PASS: mobile browser flow passes viewport-width assertion; native file button has a 44px minimum | +| R-04 | PASS: no new decorative icon collection | +| R-05 | PASS: catalog and image-editing tasks retain their existing compositions | +| R-06 | PASS: inherited system controls and editorial product typography | +| R-07 | PASS: no decorative background grid or pattern added | +| R-08 | PASS: no decorative arrows added | +| R-09 | PASS: no promotional badge added | +| R-10 | PASS: settled overlays use normal MUI surfaces, without a blur stack | +| R-11 | PASS: existing image/control radii retained | +| R-12 | PASS: overlay elevation identifies the active editing layer | +| R-13 | PASS: no glow added | +| R-14 | PASS: card repetition supports product comparison; image editing uses a dialog | +| R-15 | PASS: Manage/Save/Remove/Keep image actions name their behavior | +| R-16 | PASS: explanatory copy describes file limits and save behavior | +| R-17 | PASS: dimensions/size limits are implemented validation constraints, not marketing statistics | +| R-18 | PASS: no testimonials or fictional people added | +| R-19 | PASS: existing restrained overlay feedback; screenshot capture settles transitions | +| R-20 | PASS: established catalog identity retained | +| R-21 | PASS: existing working theme selector retained | +| R-22 | PASS: no unrelated illustration added | +| R-23 | PASS: tests reuse the existing project logo as a disclosed upload fixture | +| R-24 | PASS: no ghost navigation links added | +| R-25 | PASS: inherited text colors retain the documented contrast; neutral media surface improves transparent-image visibility | +| R-26 | PASS: browser record covers every new control; unavailable real uploads are disabled with an explicit message | +| R-27 | PASS: invalid file, broken image, pending/error feedback and missing-image states are implemented and exercised where listed | +| R-28 | PASS: no FAQ added | +| R-29 | PASS: only a functional neutral media background added to the existing palette | +| R-30 | PASS: no copied storefront/template branding | +| R-31 | PASS: image-frame, crop/contain and overlay reasons are recorded above | +| R-32 | PASS: native controls, inherited focus ring and Escape behavior verified in the browser suite | +| R-33 | PASS: features live directly in maintained TSX/CSS; no source-patching helper ships | +| R-34 | PASS: Light/Dark image dialogs captured in both desktop/mobile runs | +| R-35 | PASS: builds, actual automated interactions, image decoding and uncaught page-error checks passed | +| R-36 | PASS: no claim of cloud persistence or completed Product security testing | +| R-37 | PASS: existing authorized design direction and dials continue to govern the extension | +| R-38 | PASS: fixture content and future service boundary are explicitly identified | +| R-39 | PASS: labeled native file input, named buttons, alternative text, semantic modal and inherited reduced-motion rules | +| R-40 | PASS: dimensioned images, reserved aspect ratios, lazy catalog images and priority detail media; performance scores not measured | + +C-1 PASS: media and overlay choices have stated purposes. C-2 PASS: new control +outcomes are recorded. C-3 PASS: the dialog serves product image management. +C-4 PASS: the new failure, mobile and theme flows were exercised as listed. +C-5 PASS: the fixture/S3 distinction remains explicit. + +## Acceptance deferred to Product creation + +Implement and validate the saved S3/database contract, backend byte-level image +validation, IAM/CORS, lifecycle cleanup, concurrency protection and CDN caching. +Then run the same UI flows against real services and enable normal uploads. +CDN environment values will be requested at deployment time. Private CodeQL +remains deferred by the owner's earlier decision; the unchanged development-tool +audit findings remain documented in [dependencies](dependencies.md). diff --git a/docs/frontend/index.md b/docs/frontend/index.md index c0fe492..ca4fdfc 100644 --- a/docs/frontend/index.md +++ b/docs/frontend/index.md @@ -4,6 +4,11 @@ icon: lucide/panels-top-left # Frontend applications +The initial implementation is merged. The selected +[product image architecture](product-images.md) adds URL-based rendering and +admin image management, with S3 integration deferred to the future Product +service. Upload controls are enabled only in explicit fixture mode until then. + Three independent Next.js repositories are integrated as Git submodules and implemented for static CDN export. The local Docker setup serves them on one origin. **This is an educational mock store: no real payments or transactions diff --git a/docs/frontend/product-images.md b/docs/frontend/product-images.md new file mode 100644 index 0000000..6520ec1 --- /dev/null +++ b/docs/frontend/product-images.md @@ -0,0 +1,86 @@ +--- +icon: lucide/image +--- + +# Product image storage options + +Selected: **S3 + CloudFront**, with the AWS SDK approved by the owner. The Product +service will be created later. Frontend rendering and admin image management are +implemented against the [future Product contract](../services/product/images.md), +with a disposable local fixture for testing. Real S3 persistence is deferred to +that service. No cloud resource or paid feature has been provisioned, and no +unused AWS dependency has been installed in a frontend or Gateway. + +## Options + +| Option | Fit | Tradeoff | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Amazon S3 + CloudFront | Recommended for alignment with the existing AWS backend; short-lived authorized upload URLs and private bucket delivery through CloudFront OAC | Requires AWS storage/CDN configuration and approval of the backend SDK | +| Cloudflare R2 + Cloudflare cache | Alternative if Cloudflare will own image delivery; S3-compatible upload interface and cached custom-domain delivery | R2 presigned uploads use its S3 endpoint, separately from the public custom-domain delivery URL | +| Vercel Blob | Managed browser uploads if the owner chooses the Vercel ecosystem | Needs an authenticated token-issuing backend and approval of its SDK; static Next.js exports cannot host this handler | +| Azure Blob Storage | Managed object storage if Azure is preferred; supports HTTP image delivery | Adds Azure configuration and a provider SDK; CDN/edge delivery must be configured separately | +| PostgreSQL bytea + API + CDN | Viable for a small mock catalog without a separate object store | Images increase database/backup traffic; cache misses read through the API and database; CDN needs an HTTP endpoint | + +The recommendation is an architectural judgement for this project, not a price +comparison. Region, expected storage/traffic and account plans must be known +before comparing actual costs. + +PostgreSQL supports binary bytes through `bytea`, as well as a separate Large +Object facility. For small product images, `bytea` in a separate image table is +the simpler database option. Do not put base64 image strings in product JSON. +A CDN does not query PostgreSQL: an API must return the image bytes, content +type and caching headers over HTTP. + +## Implemented frontend and selected backend behavior + +Keep product records in their owning service. Persist an object key and image +metadata such as alternative text, content type, byte size and dimensions. +Expose a stable image URL to the frontend. Do not persist an expiring upload +URL as the product's display URL. With database storage, return a stable image +API URL instead. + +1. An administrator selects a file and sees a preview. +2. The backend checks administrator permission and CSRF, validates the upload + request and issues a narrowly scoped, expiring upload authorization. +3. After uploading, the backend verifies the actual stored file before attaching + it to the product. Client-declared filename and content type are insufficient. +4. Catalog/detail views render the returned URL with dimensions and alternative text. +5. Replacing an image creates a new versioned key/URL, then changes the product + reference. That allows long CDN caching without showing the previous image. +6. Removing/replacing images schedules safe cleanup after checking references; + failed uploads do not destroy the existing image. + +Storage credentials stay in backend configuration. Admin controls manage product +images, not cloud account secrets. The eventual CDN can route `/media/*` on the +shared domain to the image origin; the hosting/domain decision stays deferred. + +The initial defaults are one image per product, +JPEG/PNG, at most 5 MB, preview/upload/replace/remove, editable alternative text, +and visible loading/error feedback. File uploads are implemented; URL importing remains deferred. It +requires a controlled server-side download process, validation, and protection +against requests to internal services. Saving an arbitrary remote URL alone +does not copy its contents into owned blob storage. + +## Implementation prerequisites + +- Create Product in its own future repository and implement the saved + [backend handoff](../services/product/images.md), including migrations, the + approved AWS SDK, validation, object cleanup and rollback. +- Accept the real backend contract before enabling uploads in a normal + environment. `NEXT_PUBLIC_ENABLE_PRODUCT_IMAGE_UPLOADS` defaults false. +- Configure the exact HTTPS upload origins in admin's public build values; + credentials stay in the backend. Ask the owner for deployment values later. +- The Docker fixture enables uploads explicitly. Files/tickets live in memory + and reset on fixture restart; the fixture is not S3 or production validation. +- Preserve the mock-store notice and run real service/CDN acceptance before + claiming cloud image persistence. See [image verification](image-verification.md). + +## Official references + +- [S3 authorized uploads](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) +- [CloudFront S3 origin access control](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html) +- [R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/) +- [R2 CDN caching](https://developers.cloudflare.com/cache/interaction-cloudflare-products/r2/) +- [Vercel Blob browser uploads](https://vercel.com/docs/vercel-blob/client-upload) +- [Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction) +- [PostgreSQL binary storage](https://www.postgresql.org/docs/current/datatype-binary.html) diff --git a/docs/services/product/images.md b/docs/services/product/images.md new file mode 100644 index 0000000..d221ccb --- /dev/null +++ b/docs/services/product/images.md @@ -0,0 +1,151 @@ +--- +icon: lucide/image-plus +--- + +# Image implementation handoff for the future Product service + +The owner selected **S3 + CloudFront** and approved the AWS SDK. Product does +not exist yet, so this is the required backend contract, not a working S3 +integration. Do not move product ownership into Gateway or a frontend repository. +The initial UI supports file uploads only; importing external URLs is deferred. + +## Frontend contract + +Existing product fields remain unchanged. Add a nullable/optional `image`: + +```json +{ + "url": "/media/products/PRODUCT_UUID/ASSET_UUID/original.png", + "alt": "A description of the product image", + "width": 1200, + "height": 800 +} +``` + +The frontend accepts HTTPS image URLs and same-origin `/media/` paths. The local +fixture also uses `/api/products/{id}/image/content?version={uuid}`. That endpoint +is test infrastructure and is not required for S3 delivery. Old products without +`image` continue to render an explicit unavailable-image placeholder. + +All following mutation routes are below `/products/{id}/image`, with Gateway's +public `/api` prefix added by browser clients. Product must enforce ROLE_ADMIN +itself, in addition to Gateway's existing write restrictions. Cookie requests +also require the Gateway CSRF exchange. + +| Method / suffix | Request | Successful response | +| --------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| POST `/uploads` | `fileName`, `contentType`, `size`, `width`, `height` | 201: `uploadId` UUID, absolute `uploadUrl`, `uploadHeaders: {"If-None-Match":"*"}`; optional explicit UTC `expiresAt` | +| PUT to `uploadUrl` | Raw file bytes; `Content-Type` equals the approved type; no API cookies or Authorization header | 200 or 204 from S3 | +| PUT at image route | `uploadId`, `alt` | 200: complete updated product, including `image` | +| PATCH at image route | `alt` | 200: complete updated product | +| DELETE at image route | No body | 200: complete updated product with `image: null` | + +Do not return 204 for image attachment, alt editing or removal: the UI validates +the returned product. Use 400/422 for invalid metadata/file, 401/403 for access, +404 for missing products/assets, 409 for stale/consumed tickets or conflicts, +413 for oversize files, and 429 for upload limits. Mutations do not auto-retry. +The `uploadUrl` is a short-lived capability, never the saved display URL. + +## Required database changes + +- Add image metadata in the Product-owned migration system. No migration has + been executed or invented against an unknown Product schema. +- A product has at most one attached image. Track asset UUID, owning product, + object key, content type, byte count, measured dimensions, alternative text, + uploader identity, state and explicit UTC creation/expiry timestamps. +- Track pending upload tickets separately from the attached image. Generate keys + server-side under private `pending/products/{productId}/{assetId}/` for uploads + and `media/products/{productId}/{assetId}/` for validated display assets. Never + use a supplied filename as a storage path. +- Persist the immutable object key and metadata, then derive the public URL + from the configured media origin/path. Avoid storing provider-specific signed + URLs or image bytes/base64 in product JSON or PostgreSQL for this selected design. +- Product deletion detaches its reference. A retryable cleanup process deletes + unreferenced old assets and expired pending uploads. Account for rollback and + concurrent updates before deleting objects. Keep deletion/attachment idempotent + and use optimistic locking so concurrent saves cannot silently lose references. +- Existing products migrate with no image. Rollback must retain asset records + until recovery/retention decisions are made, not immediately destroy files. + +## S3 implementation requirements + +Use the official AWS SDK in Product's eventual implementation language. For a +Java service, use AWS SDK for Java v2's S3 client and S3 presigner with its +official BOM/setup. Select and lock a supported version when the service exists. +No AWS SDK belongs in the static frontend; none has been installed there. + +1. Authorize the administrator and confirm the product exists before creating a + ticket. Allow JPEG/PNG only, at most 5 MiB, up to 4096 pixels per side and + 16 million pixels. Frontend measurements are advisory, not proof. +2. Create a bounded-lifetime ticket with a unique immutable object key. Issue a + presigned PUT scoped to that key, content type and `If-None-Match: *`. + Enforce conditional writes in the bucket policy so a consumed object's bytes + cannot subsequently be overwritten with the same upload authorization. + The frontend forwards only that allowlisted conditional header, never arbitrary + backend-supplied headers. Rate-limit ticket issuance. +3. At completion, verify ticket owner/product/expiry/state, object existence, + actual length and content. Reject oversized, malformed or mislabeled files. + In Java, native ImageIO can validate the selected JPEG/PNG formats; inspect + dimensions before full decode. Do not trust S3 Content-Type metadata alone. +4. Write a validated display asset to a new immutable `media/` key using a + backend conditional write. A bounded JPEG/PNG display variant avoids delivering + full-size originals in every catalog card; native ImageIO can perform that + resizing for Java without adding an image library. CloudFront OAC must read + only validated `media/` assets, never the unvalidated `pending/` prefix. + Atomically attach the asset and mark the ticket consumed. Until that succeeds, + the old image remains the product's image. Prevent concurrent overwrite or + post-validation replacement using conditional S3 writes/object versions and + the database's attachment/version policy. Clean up unreferenced writes if the + database transaction fails. +5. Return the updated product. Replacements use a new key/URL. Failed or abandoned + uploads remain unattached and are removed by cleanup/lifecycle rules. + +Use private S3 storage, backend workload identity and least-privilege prefix +access. CloudFront OAC reads the image origin. Configure CORS for the selected +frontend origin and PUT/Content-Type/If-None-Match; the upload client sends no cookies and +uses no-referrer. Backend logs/telemetry must not record signed query strings, +file bytes, credentials or private account data. + +## CDN and environment configuration + +Route `/media/*` on the same domain to the S3 origin with matching `media/` +object keys. Versioned image responses can use public long-lived immutable +caching, whereas product metadata remains revalidated through the API. +Alt-only changes do not change image bytes. Immediate removal from all CDN edges +requires explicit invalidation; deletion in admin must not promise immediate +purging of previously cached public files. + +Ask the owner at deployment for AWS region, bucket, identity/IAM, domain, +CloudFront origin/behavior, CORS and lifecycle/retention values. Do not provision +cloud resources or introduce frontend Kubernetes deployment as part of this work. + +Admin build values: + +| Variable | Meaning | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_ENABLE_PRODUCT_IMAGE_UPLOADS` | Defaults false; enable only when the real contract is accepted. Explicitly true in local fixture mode | +| `NEXT_PUBLIC_IMAGE_UPLOAD_ORIGINS` | Comma-separated exact HTTPS S3 origins permitted for uploads; contains no credentials | + +An HTTPS upload URL outside the allowlist is rejected before file transmission. +Local fixture mode has a narrow same-origin `/api/fixture-media-upload/` exception. +That test route uses in-memory tickets/files, trusts fixture dimensions and +does not prove real image validation, S3 persistence or production security. + +## Acceptance when Product exists + +- Implement these routes and migrations in Product; integrate its real repository + and image into platform without replacing it with the fixture service. +- Verify public product/image reads and admin-only presign/attach/edit/remove, + including ordinary users, expired sessions and CSRF failures. +- Test malformed files, mismatched MIME/size/dimensions, forged/reused/expired + tickets, foreign product/account tickets, concurrent saves and cleanup failures. +- Verify actual S3 object validation, CORS, SDK credentials, retries, object-version + protection, CloudFront headers and versioned cache behavior. +- Run browser upload, replace, alt-edit, cancel and remove flows against the real + backend, not just UI fixtures. Only then enable uploads outside fixture mode. + +## Official implementation references + +- [AWS SDK for Java v2 setup](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/setup-project-maven.html) +- [Presigned S3 uploads](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) +- [CloudFront origin access control](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html) diff --git a/docs/services/product/index.md b/docs/services/product/index.md index da85994..7a796c7 100644 --- a/docs/services/product/index.md +++ b/docs/services/product/index.md @@ -4,6 +4,10 @@ icon: lucide/package # Product +The Product source repository will be created later. The +[image implementation handoff](images.md) records the approved S3/CloudFront +design, migrations, upload contract and acceptance work for that service. + RESTful API for an online store This service owns the product catalogue: product identity, display name, diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2707cf9..825b165 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -23,6 +23,12 @@ http { location = /admin { return 308 /admin/; } location /store/ { proxy_pass http://store; } location /admin/ { proxy_pass http://admin; } + location /api/fixture-media-upload/ { + client_max_body_size 5m; + limit_except PUT { deny all; } + proxy_pass http://gateway/fixture-media-upload/; + add_header Cache-Control "no-store" always; + } location /api/ { proxy_pass http://gateway/; proxy_http_version 1.1; diff --git a/tests/frontend/serve.py b/tests/frontend/serve.py index 488675a..988b323 100644 --- a/tests/frontend/serve.py +++ b/tests/frontend/serve.py @@ -9,6 +9,7 @@ from pathlib import Path from urllib.parse import urlsplit from uuid import uuid4 +from time import monotonic ROOT = Path(__file__).resolve().parent.parent.parent PRODUCTS = [ @@ -19,6 +20,8 @@ ], 1) ] ORDERS = [] +UPLOADS = {} +MEDIA = {} class Handler(BaseHTTPRequestHandler): @@ -43,7 +46,7 @@ def fixture(self): if role == "admin": profile.update(name="Fixture Administrator", email="admin@example.test", roles="ROLE_ADMIN") body = {} - if self.command == "POST": + if self.command in ("POST", "PUT", "PATCH"): length = int(self.headers.get("Content-Length", "0")) if length > 10000: return self.respond(413) @@ -53,7 +56,7 @@ def fixture(self): return self.respond(400) if self.command == "GET" and path == "/session/csrf": return self.respond(200, {"token": "fixture-csrf", "headerName": "X-XSRF-TOKEN"}) - if self.command in ("POST", "DELETE") and self.headers.get("X-XSRF-TOKEN") != "fixture-csrf": + if self.command in ("POST", "PUT", "PATCH", "DELETE") and self.headers.get("X-XSRF-TOKEN") != "fixture-csrf": return self.respond(403) if self.command == "POST" and path == "/session/login": if body.get("password") != "fixture-only": @@ -66,6 +69,19 @@ def fixture(self): return self.respond(201, {**profile, "name": body.get("name", "Fixture Visitor"), "email": body.get("email", "visitor@example.test")}) if self.command == "GET" and path == "/products": return self.respond(200, PRODUCTS) + if self.command == "GET" and path.endswith("/image/content"): + from urllib.parse import parse_qs + version = parse_qs(urlsplit(self.path).query).get("version", [""])[0] + media = MEDIA.get(version) + if not media or media["productId"] != path.split("/")[2]: + return self.respond(404) + self.send_response(200) + self.send_header("Content-Type", media["contentType"]) + self.send_header("Content-Length", str(len(media["bytes"]))) + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Cache-Control", "no-store") + self.end_headers() + return self.wfile.write(media["bytes"]) if self.command == "GET" and path.startswith("/products/"): product = next((p for p in PRODUCTS if p["id"] == path.split("/")[-1]), None) return self.respond(200 if product else 404, product) @@ -92,6 +108,37 @@ def fixture(self): return self.respond(201, order) if role != "admin": return self.respond(403) + if path.startswith("/products/") and "/image" in path: + product = next((p for p in PRODUCTS if p["id"] == path.split("/")[2]), None) + if not product: + return self.respond(404) + if self.command == "POST" and path.endswith("/image/uploads"): + if body.get("contentType") not in ("image/png", "image/jpeg") or not 0 < body.get("size", 0) <= 5 * 1024 * 1024: + return self.respond(400) + if not all(isinstance(body.get(key), int) and 0 < body[key] <= 4096 for key in ("width", "height")): + return self.respond(400) + if len(UPLOADS) >= 20: + return self.respond(429) + upload_id = str(uuid4()) + UPLOADS[upload_id] = {**body, "productId": product["id"], "created": monotonic()} + return self.respond(201, {"uploadId": upload_id, "uploadUrl": "http://" + self.headers["Host"] + "/api/fixture-media-upload/" + upload_id, "uploadHeaders": {"If-None-Match": "*"}}) + if self.command == "DELETE": + product["image"] = None + return self.respond(200, product) + alternative = body.get("alt", "").strip() + if not alternative or len(alternative) > 200: + return self.respond(400) + if self.command == "PATCH" and product.get("image"): + product["image"]["alt"] = alternative + return self.respond(200, product) + ticket = UPLOADS.get(body.get("uploadId")) + if self.command == "PUT" and ticket and ticket["productId"] == product["id"] and "bytes" in ticket and monotonic() - ticket["created"] < 600: + version = body["uploadId"] + MEDIA[version] = ticket + product["image"] = {"url": "/api/products/" + product["id"] + "/image/content?version=" + version, "alt": alternative, "width": ticket["width"], "height": ticket["height"]} + del UPLOADS[version] + return self.respond(200, product) + return self.respond(400) if self.command == "GET" and path == "/accounts": return self.respond(200, [profile]) if self.command == "POST" and path == "/products": @@ -145,6 +192,28 @@ def do_DELETE(self): return self.fixture() self.respond(503) + def do_PATCH(self): + if self.server.fixtures: + return self.fixture() + self.respond(503) + + def do_PUT(self): + path = urlsplit(self.path).path.removeprefix("/api") + if self.server.fixtures and path.startswith("/fixture-media-upload/"): + ticket = UPLOADS.get(path.split("/")[-1]) + length = int(self.headers.get("Content-Length", "0")) + if not ticket or monotonic() - ticket["created"] >= 600: + return self.respond(403) + if self.headers.get("If-None-Match") != "*" or "bytes" in ticket: + return self.respond(412) + if length != ticket["size"] or not 0 < length <= 5 * 1024 * 1024: + return self.respond(400) + ticket["bytes"] = self.rfile.read(length) + return self.respond(204) + if self.server.fixtures: + return self.fixture() + self.respond(503) + if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) diff --git a/tests/frontend/test_images.py b/tests/frontend/test_images.py new file mode 100644 index 0000000..585f776 --- /dev/null +++ b/tests/frontend/test_images.py @@ -0,0 +1,94 @@ +"""HTTP contract checks for disposable image fixtures, not AWS acceptance.""" +import base64 +import json +import threading +import unittest +from http.server import ThreadingHTTPServer +from urllib.error import HTTPError +from urllib.request import Request, urlopen +from serve import Handler, MEDIA, UPLOADS, PRODUCTS + + +class ImageFixtures(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + cls.server.fixtures, cls.server.api_only = True, True + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.base = "http://127.0.0.1:" + str(cls.server.server_port) + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def setUp(self): + UPLOADS.clear() + MEDIA.clear() + for product in PRODUCTS: + product.pop("image", None) + self.path = "/api/products/" + PRODUCTS[0]["id"] + "/image" + self.png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1kAAAAASUVORK5CYII=") + + def request(self, path, method="GET", body=None, role="admin", csrf=True, headers=None): + values = dict(headers or {}) + if role: + values["Cookie"] = "ms52-fixture=" + role + if csrf: + values["X-XSRF-TOKEN"] = "fixture-csrf" + if isinstance(body, dict): + body = json.dumps(body).encode() + values["Content-Type"] = "application/json" + request = Request(path if path.startswith("http") else self.base + path, data=body, method=method, headers=values) + try: + response = urlopen(request, timeout=5) + except HTTPError as error: + response = error + with response: + data = response.read() + parsed = json.loads(data) if data and response.headers.get_content_type() == "application/json" else data + return response.status, parsed + + def ticket(self, **changes): + body = {"fileName": "fixture.png", "contentType": "image/png", "size": len(self.png), "width": 1, "height": 1, **changes} + return self.request(self.path + "/uploads", "POST", body) + + def test_upload_replace_alt_edit_and_remove(self): + urls = [] + for alt in ("Initial fixture", "Replacement fixture"): + status, ticket = self.ticket() + self.assertEqual(status, 201) + self.assertEqual(self.request(ticket["uploadUrl"], "PUT", self.png, role=None, headers=ticket["uploadHeaders"])[0], 204) + self.assertEqual(self.request(ticket["uploadUrl"], "PUT", self.png, role=None, headers=ticket["uploadHeaders"])[0], 412) + status, product = self.request(self.path, "PUT", {"uploadId": ticket["uploadId"], "alt": alt}) + self.assertEqual(status, 200) + urls.append(product["image"]["url"]) + self.assertEqual(self.request(urls[-1], role=None)[1], self.png) + self.assertEqual(self.request(self.path, "PUT", {"uploadId": ticket["uploadId"], "alt": alt})[0], 400) + self.assertNotEqual(*urls) + self.assertEqual(self.request(self.path, "PATCH", {"alt": "Edited fixture"})[1]["image"]["alt"], "Edited fixture") + self.assertIsNone(self.request(self.path, "DELETE")[1]["image"]) + self.assertNotIn("image", {key: value for key, value in PRODUCTS[1].items()}) + + def test_access_and_csrf_rejections(self): + body = {"fileName": "fixture.png"} + for role, expected in (("user", 403), (None, 401)): + self.assertEqual(self.request(self.path + "/uploads", "POST", body, role=role)[0], expected) + self.assertEqual(self.request(self.path + "/uploads", "POST", body, csrf=False)[0], 403) + for method in ("PUT", "PATCH", "DELETE"): + self.assertEqual(self.request(self.path, method, {}, csrf=False)[0], 403) + + def test_invalid_size_type_and_expired_ticket(self): + for changes in ({"size": 0}, {"size": 6 * 1024 * 1024}, {"contentType": "image/svg+xml"}, {"width": 0}): + self.assertEqual(self.ticket(**changes)[0], 400) + status, ticket = self.ticket() + self.assertEqual(status, 201) + self.assertEqual(self.request(ticket["uploadUrl"], "PUT", b"wrong length", role=None, headers=ticket["uploadHeaders"])[0], 400) + UPLOADS[ticket["uploadId"]]["created"] -= 601 + self.assertEqual(self.request(ticket["uploadUrl"], "PUT", self.png, role=None, headers=ticket["uploadHeaders"])[0], 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/admin b/web/admin index 3aeeb18..0453c69 160000 --- a/web/admin +++ b/web/admin @@ -1 +1 @@ -Subproject commit 3aeeb1859e732601029009c0d172d88260cc4a6b +Subproject commit 0453c698e282ac1fa72c3eed6ef5f9f5ace20e71 diff --git a/web/landing b/web/landing index 18eb438..71870a7 160000 --- a/web/landing +++ b/web/landing @@ -1 +1 @@ -Subproject commit 18eb438f05ca2de0dfc867892b86aa90b9373635 +Subproject commit 71870a72bd77c0be6b3d54a9216b6901170bde76 diff --git a/web/store b/web/store index ecbf084..53dbfd9 160000 --- a/web/store +++ b/web/store @@ -1 +1 @@ -Subproject commit ecbf0846a34dc0eb709bb00f516c3953c6198e90 +Subproject commit 53dbfd9f883af91c590a58c22206eccb8c67b822