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

12 KiB

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)

  1. 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).
  2. 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.
  3. 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)

  1. 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).
  2. 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.
  3. Order: three events with identical created_at come back by id descending, older last.
  4. 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.
  5. Paging: 60 events, page 2 size 25 gives items 26-50 and total 60; page 9 gives empty items and total 60.
  6. 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.
  7. DeleteOlderThan: events at 100, 40, 31, 29, 1 days; cutoff 30 days; returns 3, remaining ids as expected.
  8. 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)

  1. 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.
  2. 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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)

  1. 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).
  2. 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.
  3. 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.
  4. 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)

  1. 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.
  2. 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.
  3. 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.
  4. DeleteOlderThan failure: Tick returns the error, last-cleanup fields unchanged, the log buffer holds the error, the next Tick runs again after healing.
  5. Runner: one tick runs one Tick; cancelling the context returns Run within a timeout; a done channel proves no goroutine outlives it (-race).
  6. 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)

  1. 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.
  2. Logout with a valid cookie gives auth.logout; without a cookie records nothing.
  3. 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.
  4. 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.
  5. Recorder failure on each producer: the HTTP status and body equal those of the same request with a healthy repository.
  6. 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

  1. 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.
  2. Grep the new and edited files, Go and docs, for comments: none; only tool directives.
  3. 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. SIGINT during a cleanup: the process exits after the run ends, with no database error on close.