| 1 | Does timestamp in seconds set the stored time? | Yes. Event createdAt, session firstAt and lastAt equal the timestamp, and reports bucket by it. | TestTimestampSecondsStored | src/app/api/send/route.ts (new Date(timestamp * 1000)) |
| 2 | Does salt split sessions with ENABLE_CLIENT_SALT=1? | Yes. Different salts give two sessions; the same salt, user agent and website give one. | TestClientSaltSplitsSessions | src/lib/collection-session.ts (hash(baseSalt, salt)) |
| 3 | x-edge-client-ip: 192.0.2.1 with cf-ipcountry: DE? | Session country DE, region and city null. | TestCountryHeaderWithTestNetAddress | src/lib/detect.ts getLocation |
| 4 | 192.0.2.1 with no country header? | 200, country, region and city null. The stop condition of plan section 10 is not hit. The bundled GeoLite2-City.mmdb has no entry for TEST-NET-1. | TestNoCountryHeaderStoresNullCountry | src/lib/detect.ts, /app/geo/GeoLite2-City.mmdb in the image |
| 5 | ip in the body? | Country headers are then ignored (country null even with cf-ipcountry). The replayer must send the address only as a header. | TestPayloadIPIgnoresCountryHeader | getClientInfo passes skipHeaders = !!payload.ip |
| 6 | Bot user agent? | 200, body exactly {"beep":"boop"}, no session, no pageview (Googlebot, bingbot). | TestBotUserAgentStoresNothing | isbot check in route.ts |
| 7 | Visit id and x-umami-cache? | With the cache token the visit and session are kept, also across UTC hours. Without it the visit id is derived from session and UTC hour: two events 45 minutes apart in one hour share a visit, an event in the next hour starts a new one. A cache token of another website is ignored. | TestVisitIDCacheAndHourRule, TestCacheTokenBoundToWebsite | resolveCollectionSession: visitSalt = hash(startOfHour(createdAt)), expiry skipped when historical |
| 8 | DISABLE_LOGIN=1? | GET /login is 403; POST /api/auth/login still works. | TestDisableLoginBlocksOnlyThePage | docker/proxy.ts disableLogin |
| 9 | UI path of a team website | /websites/<id> renders the full overview for a team member. Umami’s own navigation inside a team uses /teams/<teamId>/websites/<id>, which next.config.ts rewrites to /websites/<id>; both show all seven metrics. The personal list /websites is empty for this role; team websites are listed at /teams/<teamId>/websites. The SSO target /websites/<id> of plan 4.8 works. | ui.spec.ts | next.config.ts rewrite /teams/:teamId/:path*, src/components/hooks/useNavigation.ts renderUrl |
| 10 | What can a view-only user with team-view-only open? | Overview, events, sessions, realtime, compare, breakdown and the other report pages of the team website; team settings (read-only member list, “Leave” button); the website settings page and /admin/users render their frame, but every write is refused by the API (401). The UI still shows Save, Add (share), Transfer, Reset and Delete buttons; pressing Save shows “Unauthorized” and changes nothing. | ui.spec.ts, TestViewOnlyTeamMemberReadsButCannotChange | src/lib/constants.ts ROLE_PERMISSIONS (view-only and team-view-only are empty) |
| 11 | Are all seven RFP metrics visible to that role? | Yes, on the overview: Visitors, Visits, Views, Bounce rate, Visit duration in the metrics bar; Referrers (Sources panel), Countries (Location panel), Browsers, OS and Devices (Environment panel). Via the API the role reads /stats, /metrics and /pageviews of its team’s websites and gets 401 for other teams. | ui.spec.ts, TestViewOnlyTeamMemberReadsButCannotChange, TestViewOnlyCannotReadOtherTeam | src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx, WebsitePanels.tsx |
| 12 | Does /sso replace the history entry? | No. SSOPage calls router.push(url). After SSO the tab history holds /sso?token=<token>&url=... followed by the target. Pressing Back lands on /sso, which stores the token again and pushes the target. | ui.spec.ts (Navigation API entries) | src/app/sso/SSOPage.tsx |
| 13 | Route list | 141 paths, 186 operations in /openapi.json of the image, pinned in tests/umami-compat/testdata/openapi-routes.txt. | TestOpenAPIRouteListUnchanged | /app/public/openapi.json in the image, generated by scripts/generate-openapi.ts |
| 14 | Do several umami processes on one database agree on session and visit ids? | Yes, when they share DATABASE_URL and APP_SECRET. The session id is uuid(website, ip, userAgent, hash(salt of the event's day, client salt), distinctId) and the visit id uuid(sessionId, hash(start of the event's utc hour)); uuid hashes with secret() = hash(APP_SECRET). The salt follows the event timestamp, not the clock (SALT_ROTATION=daily). The cache token is a jwt signed with the same secret, so a token from one process is accepted by another. Sessions are written with on conflict (session_id) do nothing. Without Redis nothing is cached in a process. Verified 2026-11-13: two processes gave the same session and visit for the same visitor, process 2 continued process 1’s visit across an hour with process 1’s token, and umami counted 1 visitor, 2 visits, 5 views. | TestUmamiProcessesAgreeOnSessionAndVisit, TestReplayerSpreadsVisitorsOverUmamiProcesses (internal/replayer) | src/lib/collection-session.ts resolveCollectionSession, src/lib/crypto.ts secret, uuid, getSalt, src/lib/jwt.ts, src/queries/sql/sessions/createSession.ts, src/lib/load.ts |
| 15 | What event data and names can umami store? | Numbers of 1e15 or more in absolute value, revenue of 1e14 or more with a currency, and names starting with =, +, - or @ are answered 400 (request-schema.ts). A string in umami’s date format dated before year 1 is answered 500 after the website_event row was inserted (postgres 22008 on event_data.date_value, numeric(19,4) and timestamptz(6) in prisma/schema.prisma). Tracker and gateway refuse all of these; the cases are packages/tracker/test/vectors/storable-v1.json. | TestStorableVectorsAgainstUmami (internal/replayer) | src/app/api/send/request-schema.ts, src/queries/sql/events/saveEvent.ts, saveEventData.ts, src/lib/data.ts |
| 16 | Does /api/heartbeat cover the database? | No. It answers {"ok":true} without a query, also while postgres is down. A send for a website id that does not exist reaches the database: 400 “Website not found.” when it is up, 500 when it is down. The replayer uses both (umami.Client.Healthy). | TestOutage/postgres_stopped, TestPoisonEventDoesNotStallOtherSites (internal/replayer) | src/app/api/heartbeat/route.ts, src/lib/load.ts fetchWebsite |