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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user