569 lines
32 KiB
Gherkin
569 lines
32 KiB
Gherkin
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 <field> values are exactly <values>
|
|
|
|
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 <valid key>" 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 "<peer>" and X-Real-IP is "<header>"
|
|
Then the context ip is "<ip>"
|
|
|
|
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 <filter>
|
|
Then items and total contain exactly <expected>
|
|
|
|
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 <path> is sent <credentials>
|
|
Then the response is <status> with body "<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 <role>
|
|
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=<actor> is sent with user 3's cookie
|
|
Then the response is <status>
|
|
|
|
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 <path> 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 <type> "<id>" named "<name>"
|
|
And <state>
|
|
When the owner sends GET /v1/activity
|
|
Then the item's target.name is "<name>" and target.exists is <exists>
|
|
|
|
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?<query>
|
|
Then the response is 200 and items match <expected>
|
|
|
|
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=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: 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?<query>
|
|
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 <column> is "<value>"
|
|
Then the cell is "<cell>"
|
|
|
|
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": <days>}
|
|
Then the response is 200 and the body has "activity_retention_days": <days>
|
|
And a following GET /v1/settings returns <days>
|
|
|
|
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 <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 <credentials> with body {"activity_retention_days": 30}
|
|
Then the response is <status> with body "<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 <last>
|
|
When Tick runs at <now>
|
|
Then ran is <ran>
|
|
|
|
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 <trigger>
|
|
Then one event exists with action "<action>", category "auth", <actor>, source "web", outcome "<outcome>", security <security>, failure_reason <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 <trigger>
|
|
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": "<new 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
|