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.
8.1 KiB
8.1 KiB
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->jwtreturns nil when user or oauth-token is niluser->jwtincludes:user,:exp(30 days),:db/id,:user/role,:user/namefor all usersuser->jwtincludes:gz-clients(gzip-compressed client list with code, id, locations) for admin and read-only usersuser->jwtincludes:user/clients(plain client list) for non-admin, non-read-only usersuser->jwtexcludes:expfrom the returned JWT payload (expiration is handled by buddy.sign.jwt)gzipcompresses and base64-encodes a Clojure data structuregunzipreversesgzip, restoring the original data structuremake-api-tokencreates a JWT with:user "API",:user/role "admin",:user/name "API", expiring in 1000 dayslogoutreturns 301 redirect to/loginwith empty session mapimpersonateunsigns JWT from query param, dissoc's:exp, and returns 200 with new sessionimpersonaterequireswrap-secureandwrap-adminmiddleware (enforced in route registration)wrap-secureallows authenticated requests to proceedwrap-securereturns 401 withhx-redirectto/loginfor HTMX requests without authenticationwrap-securereturns 302 redirect to/loginwithredirect-toparam for normal requests without authenticationwrap-adminallows admin requests to proceedwrap-adminreturns 302 redirect to/loginfor non-admin requestswrap-client-redirect-unauthenticatedconverts 401 responses to HTMX redirects for unauthenticated userswrap-session-versioninvalidates sessions with mismatched version, redirecting to/loginwrap-session-versionallows valid sessions to proceedwrap-session-versionreturns 401 for GraphQL requests with invalid session version
Integration Tests
GET /api/oauthwith valid Googlecodeexchanges 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
stateparam (or/) with JWT in query string - Successful OAuth establishes server session with
:identityand:version - Failed OAuth (invalid code, network error) returns 401 with error message
- Failed OAuth logs warning via mulog
GET /logoutclears session and redirects to/loginGET /impersonate?jwt=<token>with admin session unsigns JWT and sets:identityin session- Impersonation JWT must be signed with
:jwt-secretand:hs512algorithm - Non-admin requests to
/impersonatereceive 302 redirect to/login - Unauthenticated requests to
/impersonatereceive redirect to/login - Session includes
:versionmatchingsession-version/current-session-version - Outdated session version triggers redirect to
/loginon normal routes - Outdated session version triggers
hx-redirectto/loginon HTMX routes - Outdated session version returns 401 for GraphQL routes
UI Test Behaviors (SSR)
Happy Path: OAuth Login
- Unauthenticated user navigates to
/login - User clicks "Sign in with Google" button
- Browser redirects to Google OAuth consent screen
- User consents and Google redirects to
/api/oauth?code=<code>&state=<state> - Server exchanges code for token, fetches profile, finds/creates user
- Server redirects to original page (or
/) with?jwt=<token> - Client page reads JWT and establishes session
- User is authenticated and sees their clients/data
Logout
- Authenticated user clicks logout link
- Browser navigates to
/logout - Session is cleared
- Browser redirects to
/login - User is unauthenticated; subsequent requests redirect to login
Impersonation (Admin)
- Admin user navigates to
/impersonate?jwt=<signed-jwt> - Server validates JWT signature and extracts identity
- Session is updated to impersonated user's identity
- Admin sees the application as the impersonated user
- Admin's original session is replaced (no stack)
Unauthenticated Access Attempt
- Unauthenticated user navigates to a protected page (e.g.,
/) wrap-securedetects missing authentication- Browser redirects to
/login?redirect-to=<original-path> - After successful OAuth, user is redirected back to original page
HTMX Unauthenticated Request
- Authenticated user's session expires
- HTMX request fires (e.g., card refresh)
wrap-securedetects missing authentication- Response includes
hx-redirect: /login - HTMX redirects the browser to login page
Session Version Mismatch
- User has old session (version != current)
- User navigates to any SSR route
wrap-session-versiondetects mismatch- Session is invalidated
- 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-clientscompresses 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/clientsas empty vector - Read-only user: Gets
:gz-clientslike admin, but role is"read-only" - JWT tampering:
jwt/unsignfails with invalid signature; impersonation fails - Expired impersonation JWT:
jwt/unsignrejects 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 gatewrap-admin: Admin-only gatewrap-client-redirect-unauthenticated: Converts 401 to redirectswrap-session-version: Invalidates outdated sessions during SSR rollout
Related Subsystems
- Users:
auto-ap.datomic.usershandles user CRUD - All SSR Pages: Every protected route depends on auth middleware