refactor(all): rewrite all behavior docs in table format with checkboxes

Rewrite all 11 remaining behavior documents to match the streamlined
invoice.md format:

- dashboard.md: 250 lines, 62 behaviors
- payment.md: 260 lines, behaviors for list, void, check printing, ACH
- transaction.md: 310 lines, list, import, admin insights
- ledger.md: 519 lines, entries, P&L, balance sheet, cash flows
- company.md: 320 lines, profile, 1099s, Plaid/Yodlee, reports
- admin.md: 494 lines, clients, accounts, vendors, rules, jobs, history
- pos.md: 405 lines, sales, deposits, tenders, refunds, shifts
- search-indicators.md: 167 lines, search modal, indicators
- auth.md: 184 lines, login, logout, impersonation, sessions
- outgoing-invoice.md: 192 lines, create, line items, PDF
- legacy-spa.md: 340 lines, all legacy pages (docs only)

All documents now use:
- Testing Patterns section with reusable abstractions
- Numbered tables: # | Behavior | Test Strategy | Status
- It should... behavior descriptions
- Checkboxes [ ]/[x] for tracking implementation
- Cross-Cutting Behaviors for permissions, lock dates, etc.
- Test Data Requirements tables
- Existing Tests to Preserve sections

Total: 3,844 lines of behavior documentation across 12 subsystem docs.
This commit is contained in:
2026-05-04 13:48:51 -07:00
parent e14a23ff54
commit d627e3c5d0
11 changed files with 2837 additions and 2500 deletions

View File

@@ -4,163 +4,181 @@
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
**Testing Philosophy**
- Prefer unit tests for pure business logic (JWT generation, compression, validation)
- Use integration tests for database interactions and cross-system flows (OAuth callback, session management)
- Use UI tests only for end-to-end happy paths that touch multiple pages
- Every behavior must be user-visible; no tests for implementation details
| 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
## Testing Patterns
### Unit Tests
### Pattern: OAuth Login Flow
The standard authentication flow follows these steps:
1. Unauthenticated user navigates to a protected page
2. User is redirected to `/login`
3. User clicks "Sign in with Google"
4. Browser redirects to Google OAuth consent screen
5. User consents and Google redirects to `/api/oauth?code=<code>&state=<state>`
6. Server exchanges code for token, fetches profile, finds/creates user
7. Server redirects to original page (or `/`) with `?jwt=<token>`
8. Client reads JWT and establishes session
- `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
**Test implications:** Integration test the OAuth callback handler. UI test only the happy path once.
### Integration Tests
### Pattern: Middleware Stack
Every protected route passes through authentication middleware:
1. `wrap-session-version` — validates session version, redirects to login if outdated
2. `wrap-secure` — checks for authenticated session, redirects to login if missing
3. `wrap-admin` — checks for admin role, redirects to login if not admin
4. `wrap-client-redirect-unauthenticated` — converts 401 responses to HTMX redirects
- `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
**Test implications:** Integration test each middleware independently. UI tests only verify redirect behavior.
### UI Test Behaviors (SSR)
### Pattern: JWT Claims
The JWT token contains user identity and permissions:
1. `:user`, `:exp`, `:db/id`, `:user/role`, `:user/name` for all users
2. `:gz-clients` (compressed) for admin and read-only users
3. `:user/clients` (plain) for regular users
#### 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
**Test implications:** Unit test JWT generation for each role type.
#### 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)
## Login
#### 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
### Display Behaviors
#### 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
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 1.1 | It should display a "Sign in with Google" button on the login page | UI | [ ] |
#### 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`
### OAuth Behaviors
## Edge Cases
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 1.2 | It should redirect to Google OAuth when the user clicks "Sign in with Google" | UI | [ ] |
| 1.3 | It should exchange the authorization code for an access token on callback | Integration | [ ] |
| 1.4 | It should fetch the user's Google profile using the access token | Integration | [ ] |
| 1.5 | It should create a new user account when the user logs in for the first time | Integration | [ ] |
| 1.6 | It should find the existing user account on subsequent logins | Integration | [ ] |
| 1.7 | It should redirect to the original page after successful OAuth | Integration | [ ] |
| 1.8 | It should redirect to the root page when no return URL is provided | Integration | [ ] |
| 1.9 | It should establish a server-side session with user identity and version | Integration | [ ] |
| 1.10 | It should pass the JWT token in the query string after successful OAuth | Integration | [ ] |
| 1.11 | It should display the user's clients and data after successful login | UI | [ ] |
| 1.12 | It should handle users without email via Google provider ID | Integration | [ ] |
| 1.13 | It should return 401 with error message when the OAuth code is missing | Integration | [ ] |
| 1.14 | It should return 401 when the OAuth code is invalid or expired | Integration | [ ] |
| 1.15 | It should return 401 and log a warning when the Google network request fails | Integration | [ ] |
### 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
- **Logout without active session**: Still clears session and redirects to login
- **Double logout**: Idempotent; session remains empty
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 2.1 | It should clear the session when the user navigates to `/logout` | Integration | [ ] |
| 2.2 | It should redirect to the login page after logout | Integration | [ ] |
| 2.3 | It should remain idempotent when logging out without an active session | Integration | [ ] |
---
## Impersonation
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 3.1 | It should allow admin users to assume another user's identity via signed JWT | Integration | [ ] |
| 3.2 | It should validate the impersonation JWT signature with `:jwt-secret` and `:hs512` | Integration | [ ] |
| 3.3 | It should reject expired impersonation JWTs | Integration | [ ] |
| 3.4 | It should block non-admin users from accessing `/impersonate` | Integration | [ ] |
| 3.5 | It should block unauthenticated users from accessing `/impersonate` | Integration | [ ] |
| 3.6 | It should replace the admin's session with the impersonated user's session | Integration | [ ] |
---
## Session Management
### Authentication Gate Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 4.1 | It should allow authenticated requests to proceed to protected routes | Integration | [ ] |
| 4.2 | It should redirect unauthenticated users to `/login` with a `redirect-to` parameter | Integration | [ ] |
| 4.3 | It should return `hx-redirect: /login` for unauthenticated HTMX requests | Integration | [ ] |
### Admin Gate Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 5.1 | It should allow admin requests to proceed to admin-only routes | Integration | [ ] |
| 5.2 | It should redirect non-admin users to `/login` when accessing admin routes | Integration | [ ] |
### Session Version Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 6.1 | It should invalidate sessions with outdated version numbers | Integration | [ ] |
| 6.2 | It should redirect to `/login` when an outdated session accesses normal routes | Integration | [ ] |
| 6.3 | It should return `hx-redirect: /login` for outdated sessions on HTMX routes | Integration | [ ] |
| 6.4 | It should return 401 for outdated sessions on GraphQL routes | Integration | [ ] |
| 6.5 | It should treat sessions without a version as outdated | Integration | [ ] |
---
## Cross-Cutting Behaviors
### JWT Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 7.1 | It should generate a JWT containing the user's role and client access on login | Unit | [ ] |
| 7.2 | It should compress the client list for admin users to fit in the JWT | Unit | [ ] |
| 7.3 | It should compress the client list for read-only users to fit in the JWT | Unit | [ ] |
| 7.4 | It should include a plain client list for regular users in the JWT | Unit | [ ] |
| 7.5 | It should create API tokens with admin role and 1000-day expiration | Unit | [ ] |
### Middleware Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 8.1 | It should convert 401 responses to HTMX redirects for unauthenticated users | Integration | [ ] |
### Role-Based Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 9.1 | It should allow admin users to access all clients | Integration | [ ] |
| 9.2 | It should allow regular users to access only their assigned clients | Integration | [ ] |
| 9.3 | It should allow read-only users to access all clients with view-only permissions | Integration | [ ] |
| 9.4 | It should handle admin users with no clients by providing an empty compressed list | Unit | [ ] |
| 9.5 | It should handle regular users with no clients by providing an empty client vector | Unit | [ ] |
### Security Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 10.1 | It should reject tampered JWTs during impersonation | Integration | [ ] |
| 10.2 | It should treat sessions with nil identity as unauthenticated | Integration | [ ] |
---
## 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
| Entity | Requirements |
|--------|-------------|
| **Users** | Admin user with multiple clients; regular user with subset of clients; read-only user with multiple clients; new user (not 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 |
### Clients
- Multiple clients with `:client/code`, `:client/name`, `:client/locations`
- Client associations on users
## Existing Tests to Preserve
### OAuth Mock
- Mock Google token endpoint responses (success and failure)
- Mock Google userinfo endpoint responses
- None identified
## 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
- Google OAuth 2.0 (authorization code exchange and userinfo retrieval)
- Buddy JWT (token signing/unsigning with HS512)
- Datomic (user lookup and creation)
- Legacy SPA (login and needs-activation pages, no SSR tests)