Feature: The back-office keeps an append-only activity log that owners can read and retention can prune As the owner of a RenewCMS site I want every important action recorded with who, what, when and from where So that I can review activity, spot failed logins and keep the log from growing forever Background: Given the spec of record is docs/design/screens/activity.md ("Roles", "API contract", "What is recorded") and docs/design/screens/settings.md ("Activity retention") And this slice is backend only: no SPA, no new env var, no change to the design And error responses are plain text from http.Error and success responses are JSON with snake_case fields And the layering rule holds: internal/domain/audit imports only the stdlib and other domain packages, use cases see only domain interfaces, handlers see only use cases, persistence owns GORM, and no comment is added anywhere And validation of query and body goes through validate tags and validator.Validate.Struct where a struct exists And the error text for a malformed query or body is "The request cannot be processed due to a mismatch in the format of the body." Rule: The domain event is a complete snapshot and the repository is append-only Scenario: An event carries everything the log needs Given an event is built for the action "article.publish" in category "articles" Then it holds an actor snapshot (type, id, name), the action code and its params, the category, a target snapshot (type, id, name), an outcome, a failure_reason, a security flag, a source, an ip, a user_agent, changes and a created_at Scenario Outline: The vocabulary is closed Then the allowed values are exactly Examples: | field | values | | actor.type | user, api_key, system, anonymous | | category | articles, media, users, api_keys, auth, system | | outcome | success, failed | | source | web, api_key, system | | change.kind | text, body | Scenario: The repository exposes no way to edit or delete one event Then the audit Repository interface has exactly the methods Append, List, Summary, DeleteOlderThan, DeleteOldest and SizeBytes And the persistence implementation exposes no other exported method than those and its constructor And the HTTP surface has no POST, PUT, PATCH or DELETE under /v1/activity Scenario: The sentinel errors are matchable Then the audit package declares Err sentinels for an invalid filter and an invalid retention period And each is asserted with errors.Is Rule: The recorder never fails or blocks the caller Scenario: A failing repository does not change the caller's result Given the audit repository fails on Append with the error "disk full" When a user logs in with valid credentials through POST /v1/auth/login Then the response is 200 with a jwt cookie and the usual JSON message And the process log contains a line naming the failed audit write and "disk full" Scenario: A panicking repository does not crash the request Given the audit repository panics on Append When a user logs out through POST /v1/auth/logout Then the response is 200 and the process continues serving Scenario: The recorder fills the event from the audit context Given a context holding actor user 3 "marta", source "web", ip "203.0.113.12" and user agent "Mozilla/5.0 test" When the recorder records an event that has no actor, source, ip or user agent Then the stored event has those values And created_at is the injected clock's time in UTC Scenario: Secrets never reach the log or the stored event Given actions that handle a password, a raw API key, a reset code and a jwt When they are recorded Then no stored field, no params and no process log line contains any of those values or their hashes Rule: The audit context is set by middleware from the credentials and the trusted-proxy IP Scenario: A session sets a user actor with source web Given a request with a valid jwt cookie for user 3 "marta" When it passes the audit context middleware Then the context holds actor type "user", id 3, name "marta" and source "web" Scenario: An API key sets an api_key actor with source api_key Given a request with "Authorization: Bearer " for the key 7 named "Deploy bot" When it passes the audit context middleware Then the context holds actor type "api_key", id 7, name "Deploy bot" and source "api_key" Scenario: No credentials sets an anonymous actor Given a request without a cookie or key Then the context holds actor type "anonymous" and source "web" Scenario Outline: The ip comes from the trusted-proxy real IP only Given the trusted proxies are "10.0.0.5" And the request peer is "" and X-Real-IP is "
" Then the context ip is "" Examples: | peer | header | ip | | 10.0.0.5:443 | 203.0.113.12 | 203.0.113.12 | | 198.51.100.9:443 | 203.0.113.12 | 198.51.100.9 | | 198.51.100.9:443 | | 198.51.100.9 | Scenario: The user agent is stored as sent and bounded Given the User-Agent header is 400 characters long Then the context user agent is its first 255 characters Rule: Persistence stores events portably and indexed Background: Given the persistence tests run against a real in-memory SQLite database migrated with the model list of migrate.go And the SQL stays portable across SQLite, MySQL/MariaDB and PostgreSQL Scenario: The model is migrated with its indexes Then the audit events table exists And it has an index on created_at, an index on (actor_type, actor_id, created_at) and an index on (category, created_at) Scenario: An appended event round-trips When an event with params {"merged": 3}, changes and a null failure_reason is appended Then listing returns it with every field equal, params and changes included Scenario: List sorts by created_at descending then id descending Given three events with the same created_at and one older Then List returns the three by id descending, then the older one Scenario Outline: Filters narrow the list and the total Given events across actors, categories, outcomes and days When List runs with Then items and total contain exactly Examples: | filter | expected | | actor user:3 | events of user 3 only | | actor key:7 | events of api key 7 only | | actor system | events with actor type system only | | category auth | auth events only | | outcome failed | failed events only | | outcome success | succeeded events only | | security true | events whose security flag is true | | q "SPRING" | events whose actor or target name contains "spring", case-insensitive | | q "50%_" | only names containing the literal text 50%_ | | from 2026-10-01 to 2026-10-03 | events from 2026-10-01T00:00:00Z to the end of 2026-10-03 | Scenario: Paging slices the sorted list and total is the unpaged count Given 60 events When List runs with page 2 and size 25 Then 25 items are returned, the 26th to 50th of the sorted list, and total is 60 Scenario: A page past the end is empty When List runs with page 9 and size 25 over 60 events Then items is empty and total is 60 Scenario: The summary counts the range and the previous period Given events in the last 7 days and in the 7 days before When Summary runs for range 7d Then total, previous_total, failed and security are the counts of those periods And active_people.users counts distinct user actors and active_people.api_keys counts distinct key actors, with anonymous and system excluded And series holds seven daily counts for total, active, failed and security, oldest first Scenario: DeleteOlderThan removes only events before the cutoff Given events at 100, 40, 31, 29 and 1 days ago When DeleteOlderThan runs with the cutoff 30 days ago Then it returns 3 and only the 29-day and 1-day events remain Rule: Reading the log is session only and scoped by role Background: Given the handler is mounted at /v1/activity behind jwtauth.Verifier, jwtauth.Authenticator, the current-token check and the verified-user check And the routes sit behind RequireSession, so no API key reaches them Scenario Outline: The credentials gate answers before any data When GET is sent Then the response is with body "" And the audit repository was not read Examples: | path | credentials | status | body | | /v1/activity | without a jwt cookie | 401 | You must be logged in to access this resource. | | /v1/activity/summary | without a jwt cookie | 401 | You must be logged in to access this resource. | | /v1/activity/export | without a jwt cookie | 401 | You must be logged in to access this resource. | | /v1/activity | with a valid API key | 403 | This action needs a real session. | | /v1/activity/summary | with a valid API key | 403 | This action needs a real session. | | /v1/activity/export | with a valid API key | 403 | This action needs a real session. | | /v1/activity | with an unverified user's cookie | 403 | You must verify your account to access this resource. | Scenario Outline: Owner and admin see every event Given events by users 1, 2 and 3, an api key and the system When GET /v1/activity is sent with the cookie of the Then the response is 200 and total counts the events of every actor Examples: | role | | owner | | admin | Scenario: Another role is scoped to its own events Given a verified user 3 whose role is neither owner nor admin And events by users 1, 2 and 3 When GET /v1/activity is sent with user 3's cookie Then every item has actor type "user" and id 3 And total counts only user 3's events Scenario Outline: A scoped caller cannot ask for another actor Given a verified user 3 whose role is neither owner nor admin When GET /v1/activity?actor= is sent with user 3's cookie Then the response is Examples: | actor | status | | user:3 | 200 | | user:2 | 403 | | key:7 | 403 | | system | 403 | Scenario Outline: A scoped caller cannot read the summary or the export Given a verified user 3 whose role is neither owner nor admin When GET is sent with user 3's cookie Then the response is 403 with body "Only owners and admins can do this." Examples: | path | | /v1/activity/summary | | /v1/activity/export | Rule: GET /v1/activity lists filtered, paged events Scenario: The default request Given 30 events over the last 3 days When the owner sends GET /v1/activity Then the response is 200 with {"items": [...], "total": 30, "page": 1, "size": 25} And items has 25 entries, newest first Scenario: An item has the documented shape Given user 3 "Marta Alves" published the article 8 "Spring release" at 2026-10-06T10:42:18Z from 203.0.113.12 with "Chrome 129 on Windows" When the owner sends GET /v1/activity Then the first item is """ {"id": 4821, "created_at": "2026-10-06T10:42:18Z", "action": "article.publish", "category": "articles", "actor": {"type": "user", "id": 3, "name": "Marta Alves"}, "target": {"type": "article", "id": "8", "name": "Spring release", "exists": true}, "outcome": "success", "failure_reason": null, "security": false, "source": "web", "ip": "203.0.113.12", "user_agent": "Chrome 129 on Windows", "params": {}, "changes": [{"field": "online", "kind": "text", "before": "false", "after": "true"}]} """ Scenario: A system event has no ip or user agent Given a system event "system.activity_cleanup" When the owner sends GET /v1/activity Then its item has actor {"type": "system", "id": null, "name": "System"}, source "system", ip null and user_agent null Scenario: A renamed or deleted user still reads correctly Given an event recorded when user 3 was named "Marta Alves" And user 3 has since been renamed "M. Alves" and then deleted When the owner sends GET /v1/activity Then the item's actor.name is still "Marta Alves" Scenario Outline: target.exists is resolved when the list is read Given an event whose target is "" named "" And When the owner sends GET /v1/activity Then the item's target.name is "" and target.exists is Examples: | type | id | name | state | exists | | article | 8 | Spring release | the article still exists | true | | article | 8 | Spring release | the article was deleted | false | | user | 3 | Marta Alves | the user was deleted | false | Scenario: A body change carries a delta and never text Given an event with a body change of +312 characters Then its change is {"field": "body", "kind": "body", "delta": 312} with no before and no after Scenario Outline: The filters are applied through the query When the owner sends GET /v1/activity? Then the response is 200 and items match Examples: | query | expected | | q=spring | the actor or target name contains spring | | actor=user:3 | events of user 3 | | actor=key:7 | events of api key 7 | | actor=system | system events | | category=auth | auth events | | outcome=failed | failed events | | outcome=success | succeeded events | | outcome=all | both outcomes | | security=true | events with the security flag | | range=today | events since 00:00 UTC today | | range=7d | events of the last 7 days | | range=30d | events of the last 30 days | | range=all | every event, whatever its age | | range=custom&from=2026-09-01&to=2026-09-30 | events from 1 to 30 September 2026 UTC | | page=2&size=50 | the second page of 50 | | size=5 | the first page of 5 | Scenario: range=all covers every event Given events 2 days old and 90 days old When the owner sends GET /v1/activity?range=all Then both events are listed And the summary and the export with range=all cover both events too Scenario: Omitting range means 7 days Given events 2 days old and 10 days old When the owner sends GET /v1/activity Then only the 2-day-old event is listed Scenario Outline: Invalid values answer 400 When the owner sends GET /v1/activity? Then the response is 400 with body "The request cannot be processed due to a mismatch in the format of the body." And the audit repository was not read Examples: | query | | actor=user:abc | | actor=user:-1 | | actor=group:3 | | category=billing | | outcome=maybe | | security=false | | range=year | | range=custom | | range=custom&from=2026-09-30&to=2026-09-01 | | range=custom&from=yesterday&to=today | | page=0 | | page=abc | | size=10 | | size=200 | Rule: GET /v1/activity/summary answers the strip Scenario: The summary of the last seven days Given 12 events in the last 7 days, 3 failed, 2 with the security flag, by 2 users and 1 api key And 10 events in the 7 days before When the owner sends GET /v1/activity/summary?range=7d Then the response is 200 and the body is """ {"total": 12, "previous_total": 10, "active_people": {"users": 2, "api_keys": 1}, "failed": 3, "security": 2, "series": {"total": [...7 numbers], "active": [...7], "failed": [...7], "security": [...7]}} """ And each series array has 7 entries, oldest first, and the total series sums to 12 Scenario: An empty log answers zeros Given no events When the owner sends GET /v1/activity/summary Then every number is 0 and every series array is seven zeros Scenario: The summary uses the same filters as the range When the owner sends GET /v1/activity/summary?range=custom&from=2026-09-01&to=2026-09-07 Then the counts cover exactly that range and previous_total covers the 7 days immediately before 2026-09-01 Scenario: An invalid range answers 400 When the owner sends GET /v1/activity/summary?range=year Then the response is 400 with the body-mismatch message Rule: GET /v1/activity/export streams a safe CSV Scenario: The response is a CSV attachment Given 3 events When the owner sends GET /v1/activity/export Then the response is 200 with Content-Type "text/csv; charset=utf-8" and Content-Disposition starting with "attachment" And the first row is "when,actor,action,target,outcome,source,ip" And there are 3 data rows, newest first Scenario: A row holds the documented columns only Given user 3 "Marta Alves" published "Spring release" at 2026-10-06T10:42:18Z from 203.0.113.12 with changes and a user agent Then the row is "2026-10-06T10:42:18Z,Marta Alves,article.publish,Spring release,success,web,203.0.113.12" And the file contains no changes and no user agent Scenario Outline: Cells that a spreadsheet would evaluate are neutralised Given an event whose is "" Then the cell is "" Examples: | column | value | cell | | actor | =cmd | '=cmd | | target | +1+1 | '+1+1 | | target | -2 | '-2 | | actor | @SUM(A1) | '@SUM(A1) | | target | Spring release | Spring release | | target | a=b | a=b | Scenario: Commas, quotes and newlines are quoted Given a target whose name contains a comma, double quotes and a newline Then the cell is quoted with doubled inner quotes and the file still has one row for the event Scenario: The export uses the list filters without paging Given 60 events of which 8 failed When the owner sends GET /v1/activity/export?outcome=failed&page=2&size=25 Then the file has the 8 failed rows and page and size are ignored Scenario: A system event has an empty ip cell Given a system event Then its ip cell is empty Scenario: An invalid filter answers 400 before any byte is streamed When the owner sends GET /v1/activity/export?outcome=maybe Then the response is 400 with the body-mismatch message and no CSV header Scenario: A large export is read in batches Given 1200 events When the owner exports them Then the file has 1200 rows and the repository was read through List in pages, not all at once Rule: The retention period is a setting only the owner can change Scenario: The default is 90 days Given no retention row exists When a verified user sends GET /v1/settings Then the body has "activity_retention_days": 90, "activity_last_cleanup_at": null and "activity_last_cleanup_removed": 0 And the existing fields of the response are unchanged Scenario: The last cleanup is reported Given a cleanup ran at 2026-10-06T03:00:00Z and removed 142 events When a verified user sends GET /v1/settings Then "activity_last_cleanup_at" is "2026-10-06T03:00:00Z" and "activity_last_cleanup_removed" is 142 Scenario Outline: The owner sets a valid period When the owner sends PATCH /v1/settings with body {"activity_retention_days": } Then the response is 200 and the body has "activity_retention_days": And a following GET /v1/settings returns Examples: | days | | 30 | | 90 | | 365 | | 0 | Scenario Outline: Any other value answers 400 and changes nothing Given the stored retention is 90 When the owner sends PATCH /v1/settings with body Then the response is 400 with body "The request cannot be processed due to a mismatch in the format of the body." And the stored retention is still 90 Examples: | body | | {"activity_retention_days": 7} | | {"activity_retention_days": -1} | | {"activity_retention_days": 366} | | {"activity_retention_days": 30.5} | | {"activity_retention_days": "30"} | | {"activity_retention_days": null} | | {} | | not json | Scenario: An admin cannot change it When an admin sends PATCH /v1/settings with body {"activity_retention_days": 30} Then the response is 403 with body "Only the owner can do this." And the stored retention is unchanged Scenario Outline: The gate answers before the body is read When PATCH /v1/settings is sent with body {"activity_retention_days": 30} Then the response is with body "" Examples: | credentials | status | body | | without a jwt cookie | 401 | You must be logged in to access this resource. | | with an owner's API key | 403 | This action needs a real session. | | with an unverified owner | 403 | You must verify your account to access this resource. | Scenario: A change is recorded as a system event by the owner Given the stored retention is 90 When the owner sends PATCH /v1/settings with body {"activity_retention_days": 30} Then one event exists with action "system.activity_retention_changed", category "system", actor the owner, source "web", outcome "success", security false and params {"from": 90, "to": 30} Scenario: Setting the same value records nothing Given the stored retention is 90 When the owner sends PATCH /v1/settings with body {"activity_retention_days": 90} Then the response is 200 and no event is recorded Rule: A daily cleanup removes events past the retention period Background: Given the cleanup is a use case with a Tick(now) like PurgeScheduler, driven by the scheduler runner with hand-fed channels and an injected clock And the server time zone is the injected clock's location Scenario Outline: The cleanup is due once a day at or after 03:00 Given the retention is 90 and the last cleanup was at When Tick runs at Then ran is Examples: | last | now | ran | | never | 2026-10-06T02:59:00 | false | | never | 2026-10-06T03:00:00 | true | | never | 2026-10-06T14:00:00 | true | | 2026-10-05T03:00:00 | 2026-10-06T02:59:00 | false | | 2026-10-05T03:00:00 | 2026-10-06T03:00:00 | true | | 2026-10-06T03:00:00 | 2026-10-06T04:00:00 | false | | 2026-10-06T03:00:00 | 2026-10-07T03:00:00 | true | Scenario: Forever with no size limit never runs Given the retention is 0 When Tick runs at 2026-10-06T03:00:00 Then ran is false and DeleteOlderThan is not called Scenario: The cutoff is now minus the retention days Given the retention is 30 and events at 40, 31, 29 and 1 days ago When the cleanup runs at 2026-10-06T03:00:00 Then the events 31 and 40 days old are removed and the others remain And the last cleanup time is the clock's time and the removed count is 2 Scenario: The last 24 hours are never removed Given the cleanup is asked for a cutoff newer than 24 hours ago Then the effective cutoff is 24 hours before now Scenario: A cleanup that removes nothing still records the run Given the retention is 90 and no event is older than 90 days When the cleanup runs Then the last cleanup time is the clock's time, the removed count is 0 and no system.activity_cleanup event is recorded Scenario: A cleanup that removes events records a system event after deleting Given 142 events older than the retention When the cleanup runs Then one event exists with action "system.activity_cleanup", category "system", actor type "system", source "system", ip null, user_agent null, outcome "success" and params {"removed": 142} And that event is not among the removed ones Scenario: A failing delete is retried at the next tick Given DeleteOlderThan fails When Tick runs Then the error is returned and logged, the last cleanup fields are unchanged and the next tick runs the cleanup again Scenario: A shortened period applies at the next cleanup Given the retention was 90 and the owner sets 30 Then no event is removed by the change itself And the next cleanup removes events older than 30 days Scenario: The scheduler stops with the process When the context is cancelled Then the runner returns and no goroutine outlives it Rule: The first producers record the right events Scenario Outline: Authentication events When Then one event exists with action "", category "auth", , source "web", outcome "", security , failure_reason and the request ip and user agent Examples: | trigger | action | actor | outcome | security | reason | | POST /v1/auth/login with the right username and password | auth.login | actor user with the account's name | success | false | null | | POST /v1/auth/login with the right username and a wrong password | auth.login_failed | actor anonymous named by the attempted username | failed | true | "Wrong username or password." | | POST /v1/auth/login with an unknown username | auth.login_failed | actor anonymous named by the attempted username | failed | true | "Wrong username or password." | | POST /v1/auth/logout with a valid jwt cookie | auth.logout | actor user | success | false | null | | POST /v1/auth/password-reset for an existing account | auth.password_reset_requested | actor user | success | true | null | | POST /v1/auth/password-reset/confirm with a valid code | auth.password_reset_completed | actor user | success | true | null | Scenario: A failed login reveals no more than the response does Given a wrong password for an existing user and an unknown username Then both events have the same failure_reason, actor type anonymous and no target And neither event holds the password Scenario Outline: Nothing is recorded when nothing happened When Then no event is recorded and the response is unchanged Examples: | trigger | | POST /v1/auth/logout without a jwt cookie | | POST /v1/auth/password-reset for an unknown email | | POST /v1/auth/password-reset/confirm with an invalid code | | POST /v1/auth/login with a body that fails validation | | POST /v1/auth/login answered 429 by the rate limiter | Scenario: A reset event never holds the code When a password reset is requested and completed Then neither event nor the process log contains the raw code, its hash or the new password Scenario: Creating an API key is recorded When the owner sends POST /v1/api-key with a name "Deploy bot" Then one event exists with action "api_key.create", category "api_keys", actor the owner, source "web", target {"type": "api_key", "id": "", "name": "Deploy bot"}, outcome "success" and security false And no stored field and no response of the activity API contains the raw key or its hash Scenario: Revoking an API key is recorded Given the API key 7 named "Deploy bot" When the owner sends DELETE /v1/api-key/7 Then one event exists with action "api_key.revoke", category "api_keys", actor the owner, target {"type": "api_key", "id": "7", "name": "Deploy bot"} and outcome "success" Scenario: A revoke of an unknown key records nothing When the owner sends DELETE /v1/api-key/99 Then the response is 404 and no event is recorded Scenario: The recorded events are readable through the API Given a failed login, an API key creation and a retention change were recorded When the owner sends GET /v1/activity Then the three events are listed newest first with their documented items And GET /v1/activity?security=true lists only the failed login Scenario: The size limit extension amends the retention rules Given features/activity_home_card_and_retention.feature, rule "Retention size limit" Then the settings default also has activity_retention_max_gb 0 and activity_log_size_bytes 0 And PATCH /v1/settings also accepts activity_retention_max_gb validated against audit.SizeOptions() And the last 24 hours are never removed by DeleteOldest either And a retention change is recorded with the max_gb_from and max_gb_to params when the size changes