Files
RenewCMS/features/activity-log-backend.qa.md
T

73 lines
12 KiB
Markdown

# 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.