feat: activity log backend foundation
This commit is contained in:
1 parent
8907bb49fe
commit
50b1649965
74 files changed
+5949
-239
No files matched your search
@@ -0,0 +1,560 @@
|
||||
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 and DeleteOlderThan
|
||||
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 |
|
||||
|
||||
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 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
|
||||
@@ -0,0 +1,72 @@
|
||||
# QA procedure: activity-log-backend
|
||||
|
||||
Go only, no SPA. Package `test` (black-box), new files `test/activity_log_domain_test.go`, `test/activity_log_persistence_test.go`, `test/activity_log_http_test.go` and `test/activity_cleanup_test.go`. Fake and helper type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no real sleeping, no `time.Ticker`. No env var is added. Never put a secret value in a test name or log assertion helper beyond the fixed test strings.
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` standing in for `JWT_SECRET`.
|
||||
2. Fakes: `fakeAuditClock` (mutable, `Now()` and `Set(t)`, UTC), `mockAuditRepo` (in-memory slice, `Append`, `List`, `Summary`, `DeleteOlderThan`, failure and panic switches, call counters per method), existing `mockUserRepo` extended with users: owner 1, admin 2, a verified user 3 with role `"editor"` (domain has only owner and admin, so the role is a plain string in the fake), an unverified user 4. Fake API key repo with key 7 "Deploy bot" and a raw key built with `apikey.RawKeyPrefix`.
|
||||
3. Router: a fresh `chi.NewRouter()` mirroring `api/router.go`. Mount `/activity` (RequireSession, `jwtauth.Verifier`, `jwtauth.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, then the audit context middleware), `/settings` with the new PATCH, `/api-key` and `/auth` over the real use cases and the fakes. `httptest.NewRecorder` per request. Cookies are real `jwt` cookies signed with the test token carrying `user_id` and `token_version`.
|
||||
4. Capture the standard log with `log.SetOutput(&buf)` and restore with `t.Cleanup`. No parallel tests except where `-race` is stated.
|
||||
5. Seed helper `seedEvents(repo, n, spec)` building events with explicit `created_at` from the clock.
|
||||
|
||||
## A. Domain (no HTTP)
|
||||
6. Vocabulary table: for each of actor type, category, outcome, source and change kind, the exported constants equal the allowed set from the feature; an invalid value is rejected by the domain constructor or validator with the matching `Err` sentinel (`errors.Is`).
|
||||
7. Append-only guard: `reflect.TypeOf((*audit.Repository)(nil)).Elem()` has exactly `Append`, `List`, `Summary`, `DeleteOlderThan`. The persistence implementation's exported method set (via `reflect`) equals the same four. Router walk (`chi.Walk`) finds only GET under `/v1/activity`.
|
||||
8. Recorder: with `mockAuditRepo` failing (`errors.New("disk full")`), `Record` returns without error, and `buf` names the failed write and `disk full`. A panicking repo is recovered. A context holding actor, source, ip and user agent fills an event that lacks them; an event that already has an actor keeps it. `created_at` equals `fakeAuditClock.Now()` in UTC.
|
||||
|
||||
## B. Persistence (real in-memory SQLite, same model list as `api/dependecyInjection.go` and `migrate.go`)
|
||||
9. Migrate, then query `sqlite_master` and assert the table and three indexes: `created_at`; `(actor_type, actor_id, created_at)`; `(category, created_at)`. Assert the audit model is in the `migrate.go` list (migrate the full list, not just the audit model).
|
||||
10. Round-trip: append an event with params, changes (text and body kinds) and a null failure_reason; `List` returns equal fields. A system event round-trips with null ip and user agent.
|
||||
11. Order: three events with identical `created_at` come back by id descending, older last.
|
||||
12. Filter table from the feature (actor user, key, system, category, outcome, security, `q` case-insensitive, `q` containing `%` and `_` matched literally, date range inclusive of the whole `to` day UTC). Assert items and total for each.
|
||||
13. Paging: 60 events, page 2 size 25 gives items 26-50 and total 60; page 9 gives empty items and total 60.
|
||||
14. Summary: events in the last 7 days and the 7 days before; assert total, previous_total, failed, security, distinct user and key counts (anonymous and system excluded) and the four seven-element series, oldest first.
|
||||
15. `DeleteOlderThan`: events at 100, 40, 31, 29, 1 days; cutoff 30 days; returns 3, remaining ids as expected.
|
||||
16. Portability: assert no raw dialect-specific SQL (`ILIKE`, `strftime`, `date_trunc`, backticks) appears in the repository source. Cross-database run is manual (see below).
|
||||
|
||||
## C. Audit context middleware (probe handler echoing the context as JSON)
|
||||
17. Table: valid cookie for user 3 gives `{type user, id 3, name, source web}`; valid `Authorization: Bearer <key>` gives `{api_key, 7, "Deploy bot", source api_key}`; none gives anonymous and web.
|
||||
18. IP table with `TrustedProxyRealIP([]string{"10.0.0.5"})` in front: trusted peer plus `X-Real-IP` gives the header; untrusted peer ignores it; no header gives the peer. User agent of 400 characters is cut to 255.
|
||||
|
||||
## D. Read endpoints (HTTP)
|
||||
19. Gate table: no cookie 401 `You must be logged in to access this resource.`; API key 403 `This action needs a real session.`; unverified cookie 403 `You must verify your account to access this resource.`, each for list, summary and export, with `mockAuditRepo` List/Summary counters at 0.
|
||||
20. Roles: owner and admin see all actors; user 3 sees only its own with `total` of its own count; `actor=user:3` is 200, `actor=user:2`, `actor=key:7`, `actor=system` are 403 (body `Only owners and admins can do this.`); summary and export for user 3 are 403 with the same body.
|
||||
21. Shape: decode the first item into a map and compare with the JSON in the feature, including `"failure_reason": null`, `"params": {}`, integer `actor.id`, string `target.id`, `exists`. System event has `actor.id` null, `ip` null, `user_agent` null. Renamed and deleted user keeps the snapshot name. `exists` table with fake article repo (present, deleted) and user repo (deleted). A `body` change has `delta` and no `before` or `after`.
|
||||
22. Query table: every row of the filter outline, asserted against the seeded set; default range 7d (2-day event in, 10-day event out); default `page` 1, `size` 25.
|
||||
23. 400 table: every invalid query row gives exactly `The request cannot be processed due to a mismatch in the format of the body.` with the repo read counter at 0.
|
||||
24. Summary: the 7d example (12 / 10 / 2 users + 1 key / 3 / 2 and seven-length series that sum to the total); empty log all zeros; custom range previous period is the 7 days immediately before; invalid range 400.
|
||||
25. Export: status 200, `Content-Type: text/csv; charset=utf-8`, `Content-Disposition` begins `attachment`. Parse with `encoding/csv`: header `when,actor,action,target,outcome,source,ip`, row values as in the feature, no changes and no user agent column, system ip cell empty. Formula table (`=cmd`, `+1+1`, `-2`, `@SUM(A1)` get a leading `'`; `Spring release` and `a=b` untouched). A name with comma, quotes and a newline is one parseable record. `outcome=failed&page=2&size=25` returns only failed rows. 1200 events yield 1200 rows and `mockAuditRepo` saw more than one `List` call with a bounded size. Invalid filter gives 400 with no `Content-Type: text/csv`.
|
||||
|
||||
## E. Retention settings (HTTP)
|
||||
26. `GET /v1/settings` with no stored row: `activity_retention_days` 90, `activity_last_cleanup_at` null, `activity_last_cleanup_removed` 0, existing fields (`version`) unchanged. With a stored cleanup: values echoed (RFC3339 UTC).
|
||||
27. `PATCH` accepts 30, 90, 365, 0 (200, body echoes, a following GET agrees). 400 table: 7, -1, 366, 30.5, `"30"`, null, `{}`, non-JSON, each with the body-mismatch message and the stored value unchanged.
|
||||
28. Gate: admin 403 `Only the owner can do this.`; no cookie 401; owner API key 403 `This action needs a real session.`; unverified owner 403 verification text.
|
||||
29. Event: PATCH 90 to 30 records one `system.activity_retention_changed` event with actor owner, source web and params `{"from":90,"to":30}`; PATCH to the same value records none.
|
||||
|
||||
## F. Cleanup scheduler (no HTTP, hand-fed channels, `fakeAuditClock`)
|
||||
30. Tick table from the feature (never/last cleanup versus now, boundary at exactly 03:00, same-day second tick not due, next-day tick due). Assert `ran` and the `DeleteOlderThan` call count. Retention 0 never calls it.
|
||||
31. Cutoff: retention 30, events at 40, 31, 29, 1 days; run at 03:00; 31 and 40 removed; `last_cleanup_at` equals the clock; removed count 2. A request for a cutoff inside 24 hours is clamped to now minus 24 hours.
|
||||
32. Zero removed: fields updated, no `system.activity_cleanup` event. 142 removed: one event with action `system.activity_cleanup`, actor type system, source system, ip and user agent null, params `{"removed":142}`, and it is not deleted by the run that created it.
|
||||
33. `DeleteOlderThan` failure: Tick returns the error, last-cleanup fields unchanged, the log buffer holds the error, the next Tick runs again after healing.
|
||||
34. Runner: one tick runs one Tick; cancelling the context returns `Run` within a timeout; a done channel proves no goroutine outlives it (`-race`).
|
||||
35. Shortening retention from 90 to 30 deletes nothing at PATCH time (repo `DeleteOlderThan` counter 0); the next due Tick deletes older than 30.
|
||||
|
||||
## G. Producers (HTTP)
|
||||
36. Login: right credentials gives `auth.login` (actor user, source web, security false, failure_reason null, ip and user agent from the request); wrong password and unknown username give `auth.login_failed` with actor anonymous named by the attempted username, outcome failed, security true, failure_reason `Wrong username or password.`, identical for both; validation failure and a rate-limited 429 record nothing. No stored field or log line contains the password.
|
||||
37. Logout with a valid cookie gives `auth.logout`; without a cookie records nothing.
|
||||
38. Password reset: request for an existing account gives `auth.password_reset_requested` (security true); unknown email records nothing and the response is byte-identical to the existing account's response except `Date`; confirm with a valid code gives `auth.password_reset_completed`; invalid code records nothing. Neither the raw code, its hash nor the new password appears in any stored field or in `buf`.
|
||||
39. API key: `POST /v1/api-key` records `api_key.create` with the target `{api_key, <id>, name}`; `DELETE /v1/api-key/7` records `api_key.revoke`; `DELETE /v1/api-key/99` is 404 and records nothing. Marshal every stored event and every `/v1/activity` response and assert the raw key and its hash are absent.
|
||||
40. Recorder failure on each producer: the HTTP status and body equal those of the same request with a healthy repository.
|
||||
41. End to end: record a failed login, a key creation and a retention change, then `GET /v1/activity` lists them newest first, and `?security=true` returns only the failed login.
|
||||
|
||||
## H. Layering and hygiene guards
|
||||
42. Grep-style test: `internal/domain/audit` imports only the stdlib and other `internal/domain/*`; the audit use cases import no `gorm`, `persistence`, `mailer`, `net/http`, chi or `api/...`; handlers import no `persistence`, `models` or `gorm`; the persistence repository asserts `var _ audit.Repository = &X{}`; the GORM model lives in `persistence/models` with a mapper.
|
||||
43. Grep the new and edited files, Go and docs, for comments: none; only tool directives.
|
||||
44. Docs read: `docs/api.md` lists the three activity routes, the query parameters, response shapes, the 400/401/403 answers and the PATCH `/v1/settings` contract with the three new GET fields; `docs/architecture.md` describes `domain/audit`, the recorder and context, the persistence model and indexes, and the retention scheduler; `docs/configuration.md` states that retention is stored in the database (default 90) and that no env var was added.
|
||||
|
||||
## Manual (not automated)
|
||||
45. Real boot with `task dev:api`: log in with a wrong password, log in correctly, create and revoke an API key, PATCH retention as owner (with the session cookie), then read `/v1/activity` and `/v1/activity/export` and open the CSV in a spreadsheet to confirm injected cells show as text.
|
||||
46. Behind a reverse proxy with `TRUSTED_PROXIES` containing the proxy: the stored ip is the client's, not the proxy's. Without it: the proxy's.
|
||||
47. Backdate events in the SQLite file, set retention to 30 and either wait for 03:00 or move the system clock; confirm removed rows and the new Last cleanup values in `GET /v1/settings`.
|
||||
48. Run the persistence suite against a MySQL/MariaDB and a PostgreSQL instance through the documented database settings of `docs/configuration.md`: index creation, case-insensitive `q`, literal `%` and `_`, date-range boundaries, and `DeleteOlderThan` counts.
|
||||
49. SIGINT during a cleanup: the process exits after the run ends, with no database error on close.
|
||||
Reference in new issue
Block a user