docs: add comprehensive test behavior documentation for all pages
Add behavior documentation covering all SSR and legacy SPA pages: - Testing strategy and type definitions (unit/integration/UI) - Dashboard, Invoice, Payment, Transaction, Ledger pages - Company/Settings, POS, Admin, Search, Auth pages - Legacy SPA behavior docs (no UI tests until migrated) - Edge cases, test data requirements, and dependencies per subsystem Total: 3,600+ lines of behavior documentation to guide test authorship.
This commit is contained in:
166
docs/testing/behaviors/auth.md
Normal file
166
docs/testing/behaviors/auth.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Authentication Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
Authentication in Integreat uses Google OAuth 2.0 as the primary identity provider. Users authenticate via Google, receive a JWT token, and establish a server-side session. The system supports role-based access control (`admin`, `user`, `read-only`), client-scoped permissions, session versioning for SSR rollout, and admin impersonation via signed JWT tokens.
|
||||
|
||||
## Routes & Pages
|
||||
|
||||
| Route | Method | Handler | Purpose |
|
||||
|-------|--------|---------|---------|
|
||||
| `/login` | GET | `:login` (SPA) | Legacy login page |
|
||||
| `/needs-activation` | GET | `:needs-activation` (SPA) | Account pending activation page |
|
||||
| `/api/oauth` | GET | `:oauth` | Google OAuth callback, creates/updates user, establishes session |
|
||||
| `/logout` | GET | `:logout` | Clears session, redirects to `/login` |
|
||||
| `/impersonate` | GET | `:impersonate` | Admin-only: assumes another user's identity via JWT |
|
||||
|
||||
## Behaviors
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `user->jwt` returns nil when user or oauth-token is nil
|
||||
- `user->jwt` includes `:user`, `:exp` (30 days), `:db/id`, `:user/role`, `:user/name` for all users
|
||||
- `user->jwt` includes `:gz-clients` (gzip-compressed client list with code, id, locations) for admin and read-only users
|
||||
- `user->jwt` includes `:user/clients` (plain client list) for non-admin, non-read-only users
|
||||
- `user->jwt` excludes `:exp` from the returned JWT payload (expiration is handled by buddy.sign.jwt)
|
||||
- `gzip` compresses and base64-encodes a Clojure data structure
|
||||
- `gunzip` reverses `gzip`, restoring the original data structure
|
||||
- `make-api-token` creates a JWT with `:user "API"`, `:user/role "admin"`, `:user/name "API"`, expiring in 1000 days
|
||||
- `logout` returns 301 redirect to `/login` with empty session map
|
||||
- `impersonate` unsigns JWT from query param, dissoc's `:exp`, and returns 200 with new session
|
||||
- `impersonate` requires `wrap-secure` and `wrap-admin` middleware (enforced in route registration)
|
||||
- `wrap-secure` allows authenticated requests to proceed
|
||||
- `wrap-secure` returns 401 with `hx-redirect` to `/login` for HTMX requests without authentication
|
||||
- `wrap-secure` returns 302 redirect to `/login` with `redirect-to` param for normal requests without authentication
|
||||
- `wrap-admin` allows admin requests to proceed
|
||||
- `wrap-admin` returns 302 redirect to `/login` for non-admin requests
|
||||
- `wrap-client-redirect-unauthenticated` converts 401 responses to HTMX redirects for unauthenticated users
|
||||
- `wrap-session-version` invalidates sessions with mismatched version, redirecting to `/login`
|
||||
- `wrap-session-version` allows valid sessions to proceed
|
||||
- `wrap-session-version` returns 401 for GraphQL requests with invalid session version
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- `GET /api/oauth` with valid Google `code` exchanges code for access token
|
||||
- OAuth handler fetches Google user profile using access token
|
||||
- `users/find-or-insert!` creates new user if not exists (Google provider, id, email, name, picture)
|
||||
- `users/find-or-insert!` returns existing user if already present
|
||||
- Successful OAuth redirects to `state` param (or `/`) with JWT in query string
|
||||
- Successful OAuth establishes server session with `:identity` and `:version`
|
||||
- Failed OAuth (invalid code, network error) returns 401 with error message
|
||||
- Failed OAuth logs warning via mulog
|
||||
- `GET /logout` clears session and redirects to `/login`
|
||||
- `GET /impersonate?jwt=<token>` with admin session unsigns JWT and sets `:identity` in session
|
||||
- Impersonation JWT must be signed with `:jwt-secret` and `:hs512` algorithm
|
||||
- Non-admin requests to `/impersonate` receive 302 redirect to `/login`
|
||||
- Unauthenticated requests to `/impersonate` receive redirect to `/login`
|
||||
- Session includes `:version` matching `session-version/current-session-version`
|
||||
- Outdated session version triggers redirect to `/login` on normal routes
|
||||
- Outdated session version triggers `hx-redirect` to `/login` on HTMX routes
|
||||
- Outdated session version returns 401 for GraphQL routes
|
||||
|
||||
### UI Test Behaviors (SSR)
|
||||
|
||||
#### Happy Path: OAuth Login
|
||||
1. Unauthenticated user navigates to `/login`
|
||||
2. User clicks "Sign in with Google" button
|
||||
3. Browser redirects to Google OAuth consent screen
|
||||
4. User consents and Google redirects to `/api/oauth?code=<code>&state=<state>`
|
||||
5. Server exchanges code for token, fetches profile, finds/creates user
|
||||
6. Server redirects to original page (or `/`) with `?jwt=<token>`
|
||||
7. Client page reads JWT and establishes session
|
||||
8. User is authenticated and sees their clients/data
|
||||
|
||||
#### Logout
|
||||
1. Authenticated user clicks logout link
|
||||
2. Browser navigates to `/logout`
|
||||
3. Session is cleared
|
||||
4. Browser redirects to `/login`
|
||||
5. User is unauthenticated; subsequent requests redirect to login
|
||||
|
||||
#### Impersonation (Admin)
|
||||
1. Admin user navigates to `/impersonate?jwt=<signed-jwt>`
|
||||
2. Server validates JWT signature and extracts identity
|
||||
3. Session is updated to impersonated user's identity
|
||||
4. Admin sees the application as the impersonated user
|
||||
5. Admin's original session is replaced (no stack)
|
||||
|
||||
#### Unauthenticated Access Attempt
|
||||
1. Unauthenticated user navigates to a protected page (e.g., `/`)
|
||||
2. `wrap-secure` detects missing authentication
|
||||
3. Browser redirects to `/login?redirect-to=<original-path>`
|
||||
4. After successful OAuth, user is redirected back to original page
|
||||
|
||||
#### HTMX Unauthenticated Request
|
||||
1. Authenticated user's session expires
|
||||
2. HTMX request fires (e.g., card refresh)
|
||||
3. `wrap-secure` detects missing authentication
|
||||
4. Response includes `hx-redirect: /login`
|
||||
5. HTMX redirects the browser to login page
|
||||
|
||||
#### Session Version Mismatch
|
||||
1. User has old session (version != current)
|
||||
2. User navigates to any SSR route
|
||||
3. `wrap-session-version` detects mismatch
|
||||
4. Session is invalidated
|
||||
5. User is redirected to `/login`
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### OAuth
|
||||
- **Missing code parameter**: Returns 401 "Couldn't authenticate"
|
||||
- **Invalid/expired code**: Google token endpoint returns error; handler catches exception and returns 401
|
||||
- **Network failure to Google**: Exception caught, logged, 401 returned
|
||||
- **Missing state parameter**: Redirects to `/` (fallback)
|
||||
- **User without email**: Google profile may lack email; `find-or-insert!` handles via provider-id
|
||||
- **Large client list for admin**: `:gz-clients` compresses client data to fit in JWT
|
||||
|
||||
### Session & Permissions
|
||||
- **Session without version**: Treated as outdated by `wrap-session-version`
|
||||
- **Session with nil identity**: Treated as unauthenticated by `wrap-secure`
|
||||
- **Admin with no clients**: Still gets `:gz-clients` (empty list compressed)
|
||||
- **User role with no clients**: Gets `:user/clients` as empty vector
|
||||
- **Read-only user**: Gets `:gz-clients` like admin, but role is `"read-only"`
|
||||
- **JWT tampering**: `jwt/unsign` fails with invalid signature; impersonation fails
|
||||
- **Expired impersonation JWT**: `jwt/unsign` rejects expired token
|
||||
|
||||
### Logout
|
||||
- **Logout without active session**: Still clears session and redirects to login
|
||||
- **Double logout**: Idempotent; session remains empty
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
### Users
|
||||
- User with `:user/role :user.role/admin`, multiple clients
|
||||
- User with `:user/role :user.role/user`, subset of clients
|
||||
- User with `:user/role :user.role/read-only`, multiple clients
|
||||
- New user (not yet in database) with Google provider details
|
||||
- Existing user with Google provider details
|
||||
|
||||
### Clients
|
||||
- Multiple clients with `:client/code`, `:client/name`, `:client/locations`
|
||||
- Client associations on users
|
||||
|
||||
### OAuth Mock
|
||||
- Mock Google token endpoint responses (success and failure)
|
||||
- Mock Google userinfo endpoint responses
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Services
|
||||
- **Google OAuth 2.0**: Authorization code exchange and userinfo retrieval
|
||||
- **Buddy (JWT)**: Token signing/unsigning with HS512
|
||||
- **Datomic**: User lookup and creation via `users/find-or-insert!`
|
||||
|
||||
### Frontend Libraries
|
||||
- **Legacy SPA**: Login and needs-activation pages are legacy SPA routes (no SSR tests)
|
||||
|
||||
### Middleware Stack
|
||||
- `wrap-secure`: Authentication gate
|
||||
- `wrap-admin`: Admin-only gate
|
||||
- `wrap-client-redirect-unauthenticated`: Converts 401 to redirects
|
||||
- `wrap-session-version`: Invalidates outdated sessions during SSR rollout
|
||||
|
||||
### Related Subsystems
|
||||
- **Users**: `auto-ap.datomic.users` handles user CRUD
|
||||
- **All SSR Pages**: Every protected route depends on auth middleware
|
||||
Reference in New Issue
Block a user