12 KiB
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
validator.InitValidator()once.jwtauth.New("HS256", []byte("test-secret"), nil)standing in forJWT_SECRET.- Fakes:
fakeAuditClock(mutable,Now()andSet(t), UTC),mockAuditRepo(in-memory slice,Append,List,Summary,DeleteOlderThan, failure and panic switches, call counters per method), existingmockUserRepoextended 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 withapikey.RawKeyPrefix. - Router: a fresh
chi.NewRouter()mirroringapi/router.go. Mount/activity(RequireSession,jwtauth.Verifier,jwtauth.Authenticator,RequireCurrentToken,RequireVerifiedUser, then the audit context middleware),/settingswith the new PATCH,/api-keyand/authover the real use cases and the fakes.httptest.NewRecorderper request. Cookies are realjwtcookies signed with the test token carryinguser_idandtoken_version. - Capture the standard log with
log.SetOutput(&buf)and restore witht.Cleanup. No parallel tests except where-raceis stated. - Seed helper
seedEvents(repo, n, spec)building events with explicitcreated_atfrom the clock.
A. Domain (no HTTP)
- 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
Errsentinel (errors.Is). - Append-only guard:
reflect.TypeOf((*audit.Repository)(nil)).Elem()has exactlyAppend,List,Summary,DeleteOlderThan. The persistence implementation's exported method set (viareflect) equals the same four. Router walk (chi.Walk) finds only GET under/v1/activity. - Recorder: with
mockAuditRepofailing (errors.New("disk full")),Recordreturns without error, andbufnames the failed write anddisk 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_atequalsfakeAuditClock.Now()in UTC.
B. Persistence (real in-memory SQLite, same model list as api/dependecyInjection.go and migrate.go)
- Migrate, then query
sqlite_masterand assert the table and three indexes:created_at;(actor_type, actor_id, created_at);(category, created_at). Assert the audit model is in themigrate.golist (migrate the full list, not just the audit model). - Round-trip: append an event with params, changes (text and body kinds) and a null failure_reason;
Listreturns equal fields. A system event round-trips with null ip and user agent. - Order: three events with identical
created_atcome back by id descending, older last. - Filter table from the feature (actor user, key, system, category, outcome, security,
qcase-insensitive,qcontaining%and_matched literally, date range inclusive of the wholetoday UTC). Assert items and total for each. - Paging: 60 events, page 2 size 25 gives items 26-50 and total 60; page 9 gives empty items and total 60.
- 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.
DeleteOlderThan: events at 100, 40, 31, 29, 1 days; cutoff 30 days; returns 3, remaining ids as expected.- 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)
- Table: valid cookie for user 3 gives
{type user, id 3, name, source web}; validAuthorization: Bearer <key>gives{api_key, 7, "Deploy bot", source api_key}; none gives anonymous and web. - IP table with
TrustedProxyRealIP([]string{"10.0.0.5"})in front: trusted peer plusX-Real-IPgives the header; untrusted peer ignores it; no header gives the peer. User agent of 400 characters is cut to 255.
D. Read endpoints (HTTP)
- Gate table: no cookie 401
You must be logged in to access this resource.; API key 403This action needs a real session.; unverified cookie 403You must verify your account to access this resource., each for list, summary and export, withmockAuditRepoList/Summary counters at 0. - Roles: owner and admin see all actors; user 3 sees only its own with
totalof its own count;actor=user:3is 200,actor=user:2,actor=key:7,actor=systemare 403 (bodyOnly owners and admins can do this.); summary and export for user 3 are 403 with the same body. - Shape: decode the first item into a map and compare with the JSON in the feature, including
"failure_reason": null,"params": {}, integeractor.id, stringtarget.id,exists. System event hasactor.idnull,ipnull,user_agentnull. Renamed and deleted user keeps the snapshot name.existstable with fake article repo (present, deleted) and user repo (deleted). Abodychange hasdeltaand nobeforeorafter. - 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
page1,size25. - 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. - 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.
- Export: status 200,
Content-Type: text/csv; charset=utf-8,Content-Dispositionbeginsattachment. Parse withencoding/csv: headerwhen,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 releaseanda=buntouched). A name with comma, quotes and a newline is one parseable record.outcome=failed&page=2&size=25returns only failed rows. 1200 events yield 1200 rows andmockAuditReposaw more than oneListcall with a bounded size. Invalid filter gives 400 with noContent-Type: text/csv.
E. Retention settings (HTTP)
GET /v1/settingswith no stored row:activity_retention_days90,activity_last_cleanup_atnull,activity_last_cleanup_removed0, existing fields (version) unchanged. With a stored cleanup: values echoed (RFC3339 UTC).PATCHaccepts 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.- Gate: admin 403
Only the owner can do this.; no cookie 401; owner API key 403This action needs a real session.; unverified owner 403 verification text. - Event: PATCH 90 to 30 records one
system.activity_retention_changedevent 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)
- 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
ranand theDeleteOlderThancall count. Retention 0 never calls it. - Cutoff: retention 30, events at 40, 31, 29, 1 days; run at 03:00; 31 and 40 removed;
last_cleanup_atequals the clock; removed count 2. A request for a cutoff inside 24 hours is clamped to now minus 24 hours. - Zero removed: fields updated, no
system.activity_cleanupevent. 142 removed: one event with actionsystem.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. DeleteOlderThanfailure: Tick returns the error, last-cleanup fields unchanged, the log buffer holds the error, the next Tick runs again after healing.- Runner: one tick runs one Tick; cancelling the context returns
Runwithin a timeout; a done channel proves no goroutine outlives it (-race). - Shortening retention from 90 to 30 deletes nothing at PATCH time (repo
DeleteOlderThancounter 0); the next due Tick deletes older than 30.
G. Producers (HTTP)
- 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 giveauth.login_failedwith actor anonymous named by the attempted username, outcome failed, security true, failure_reasonWrong username or password., identical for both; validation failure and a rate-limited 429 record nothing. No stored field or log line contains the password. - Logout with a valid cookie gives
auth.logout; without a cookie records nothing. - 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 exceptDate; confirm with a valid code givesauth.password_reset_completed; invalid code records nothing. Neither the raw code, its hash nor the new password appears in any stored field or inbuf. - API key:
POST /v1/api-keyrecordsapi_key.createwith the target{api_key, <id>, name};DELETE /v1/api-key/7recordsapi_key.revoke;DELETE /v1/api-key/99is 404 and records nothing. Marshal every stored event and every/v1/activityresponse and assert the raw key and its hash are absent. - Recorder failure on each producer: the HTTP status and body equal those of the same request with a healthy repository.
- End to end: record a failed login, a key creation and a retention change, then
GET /v1/activitylists them newest first, and?security=truereturns only the failed login.
H. Layering and hygiene guards
- Grep-style test:
internal/domain/auditimports only the stdlib and otherinternal/domain/*; the audit use cases import nogorm,persistence,mailer,net/http, chi orapi/...; handlers import nopersistence,modelsorgorm; the persistence repository assertsvar _ audit.Repository = &X{}; the GORM model lives inpersistence/modelswith a mapper. - Grep the new and edited files, Go and docs, for comments: none; only tool directives.
- Docs read:
docs/api.mdlists the three activity routes, the query parameters, response shapes, the 400/401/403 answers and the PATCH/v1/settingscontract with the three new GET fields;docs/architecture.mddescribesdomain/audit, the recorder and context, the persistence model and indexes, and the retention scheduler;docs/configuration.mdstates that retention is stored in the database (default 90) and that no env var was added.
Manual (not automated)
- 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/activityand/v1/activity/exportand open the CSV in a spreadsheet to confirm injected cells show as text. - Behind a reverse proxy with
TRUSTED_PROXIEScontaining the proxy: the stored ip is the client's, not the proxy's. Without it: the proxy's. - 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. - Run the persistence suite against a MySQL/MariaDB and a PostgreSQL instance through the documented database settings of
docs/configuration.md: index creation, case-insensitiveq, literal%and_, date-range boundaries, andDeleteOlderThancounts. - SIGINT during a cleanup: the process exits after the run ends, with no database error on close.