Files
RenewCMS/features/account_export_data.feature
T

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