136 lines
9.5 KiB
Gherkin
136 lines
9.5 KiB
Gherkin
Feature: Download a copy of my personal data as a ZIP of JSON files
|
|
|
|
As a verified RenewCMS user
|
|
I want one click on Profile to download the personal data RenewCMS holds about me
|
|
So that I can exercise my right of access and portability (GDPR art. 15 and 20) without asking an admin
|
|
|
|
Background:
|
|
Given the design of record is docs/design/screens/account.md "Your data", direct flow only, and the
|
|
board "RenewCMS Screens Account" group "Your data"
|
|
And the emailed-link variant, export-ready.html and /account/export/ are out of scope
|
|
And GET /v1/user/me/export is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
|
|
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, so an API key gets
|
|
403 "This action needs a real session." and no cookie gets 401
|
|
And the route allows one successful export per user per 15 minutes; a second attempt answers 429
|
|
"Too many requests. Please try again later." with a Retry-After header in seconds; an export that
|
|
fails with a 500 does not consume the slot
|
|
And the success response is 200, Content-Type application/zip, Cache-Control no-store and
|
|
Content-Disposition attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip", with the
|
|
username reduced to [a-z0-9-] and the date in UTC from the use case's clock
|
|
And the ZIP holds exactly four UTF-8 JSON files, snake_case fields, pretty-printed:
|
|
| file | content |
|
|
| profile.json | id, username, display_name, email, pending_email, role {id, name}, is_verified, created_at, updated_at, author (the author profile or null), api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}] |
|
|
| authored-content.json | articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}] and entries [{id, content_type, data, created_at, updated_at}] created by the user or credited to the user's author |
|
|
| uploaded-media.json | images [{id, filename, mime, size, hash, uploaded_at}] uploaded by the user: references only, no bytes |
|
|
| account-activity.json | events recorded with the user as actor: id, created_at, action, category, target, outcome, security, source, ip, user_agent, params |
|
|
And the ZIP never contains the password hash, any verification, reset or email-change code, the token
|
|
version, an API-key hash, or other people's data
|
|
And soft-deleted articles and images are excluded
|
|
And a use case (ExportUserData) reads through domain repositories (user, role, author, apikey, article,
|
|
entry, image, audit) and writes the archive with the stdlib; the handler streams the result; the
|
|
archive is built in memory first so a failure never sends a half file
|
|
And one audit event user.export_data is recorded on success (actor and target the user, category users)
|
|
And the layering rule holds and no comment is added anywhere
|
|
|
|
Rule: The endpoint
|
|
|
|
Background:
|
|
Given the user "marta" (id 7, verified) with 2 articles, 1 entry, 3 uploaded images and 5 audit events as actor
|
|
|
|
Scenario: A first export returns the archive
|
|
When marta sends GET /v1/user/me/export on 2026-10-29
|
|
Then the response is 200 with Content-Disposition attachment; filename="renewcms-export-marta-2026-10-29.zip"
|
|
And the ZIP has exactly the four files above
|
|
And profile.json has username "marta", email, role name and no key named password, code or token_version
|
|
And authored-content.json has 2 articles and 1 entry
|
|
And uploaded-media.json has 3 references and no file bytes
|
|
And account-activity.json has the 5 events plus nothing recorded for anonymous failed logins
|
|
And one audit event user.export_data is recorded
|
|
|
|
Scenario: A second export within 15 minutes is refused
|
|
Given marta exported 3 minutes ago
|
|
When she sends GET /v1/user/me/export
|
|
Then the response is 429 "Too many requests. Please try again later." with a numeric Retry-After of about 720
|
|
And no archive is built and no audit event is recorded
|
|
|
|
Scenario: The slot is per user and expires
|
|
Given marta exported 16 minutes ago, and ines exported 1 minute ago
|
|
Then marta's request succeeds and ines's request answers 429
|
|
|
|
Scenario: A failed export does not consume the slot
|
|
Given a repository fails while marta's data is being read
|
|
When she sends GET /v1/user/me/export
|
|
Then the response is 500 with a plain text body, no partial archive is sent and no audit event is recorded
|
|
When the failure is gone and she sends the request again
|
|
Then the response is 200
|
|
|
|
Scenario Outline: Only a real verified session may export
|
|
When <caller> sends GET /v1/user/me/export
|
|
Then the response is <status> with the body "<text>"
|
|
|
|
Examples:
|
|
| caller | status | text |
|
|
| nobody (no cookie) | 401 | You must be logged in to access this resource. |
|
|
| an API key bearer | 403 | This action needs a real session. |
|
|
| an unverified user | 403 | You must verify your account to access this resource. |
|
|
|
|
Scenario: Someone else's data is never included
|
|
Given ines has articles, entries, images and events
|
|
When marta exports
|
|
Then no record of ines appears in any file, including events where ines is the target of marta's action
|
|
|
|
Scenario: A user with a linked author profile
|
|
Given marta has an author profile credited on article 4 which marta did not create
|
|
When she exports
|
|
Then profile.json has the author profile and authored-content.json includes article 4
|
|
|
|
Rule: Your data card (SPA)
|
|
|
|
Background:
|
|
Given the Your data card follows the Account and Password cards, with the title "Your data", the text
|
|
"Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP." and the
|
|
four content rows Profile, Authored content, Uploaded media, Account activity with the design's
|
|
one-line descriptions
|
|
And the download is a fetch of GET /v1/user/me/export read as a blob and saved with the filename
|
|
from Content-Disposition
|
|
And every new SPA string of the card (title, text, content rows, button, states, toasts and errors) is
|
|
added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in English and
|
|
in French under the same keys, with the design's English wording verbatim
|
|
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
|
|
flags it for the user's review
|
|
And the ZIP file names and JSON keys are not translated
|
|
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
|
|
|
|
Scenario Outline: States
|
|
Given the user is on /profile
|
|
When <trigger>
|
|
Then the state "<state>" shows <copy>
|
|
|
|
Examples:
|
|
| state | trigger | copy |
|
|
| Direct (recommended): default | the page loads | "Download my data", "Ready in a few seconds." |
|
|
| Direct: preparing | "Download my data" is activated, GET unresolved | "Preparing download…", button disabled with a spinner |
|
|
| Direct: started | the GET answers 200 with the attachment | the browser download starts, then a toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads."; the card returns to the default |
|
|
| Direct: rate limited (429) | the GET answers 429 with Retry-After | "You can export once every 15 minutes", "Your last export was at 09:41. Try again in 12 minutes.", "Available in 12:00" |
|
|
| Direct: error | the GET answers 500 or fails | "Couldn't prepare your data", "Nothing was changed. Try again in a moment.", button enabled to retry |
|
|
|
|
Scenario: The rate limited state is clock-sensitive and is skipped
|
|
Then web/visual/states.ts marks "Direct: rate limited (429)" with skip 'clock-sensitive', no visual
|
|
check exists for it, and no Vitest spec freezes the system date; the Vitest spec asserts only the
|
|
copy driven by a mocked Retry-After header
|
|
|
|
Scenario: The async states are out of scope and carry exception entries
|
|
Then the board states "Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress"
|
|
and "Alternative: error" are not built in the SPA
|
|
And web/visual/states.ts lists each of them with an exception entry so `task visual:check -- account`
|
|
does not fail on a missing setup
|
|
And each exception needs the user's explicit approval recorded as approvedBy
|
|
|
|
Rule: Documentation and privacy
|
|
|
|
Scenario: The docs describe the feature
|
|
Then docs/api.md documents GET /v1/user/me/export with the headers, the four files and the 429
|
|
And docs/privacy.md replaces "there is no self-service export yet" with the Profile download, lists
|
|
the four files, notes that anonymous failed-login events and soft-deleted rows are not included,
|
|
and removes the export half of the "No self-service export or email change" gap
|