Plan — Login (User, EmailAuthentication, verification, sign-in)¶
- Status: Implemented (2026-04-28) — core + 19 backend tests
- Date: 2026-04-27 (core), 2026-04-28 (tests amendment + execution)
- Owner: TBD
Implementation notes (2026-04-28)¶
Plan executed end-to-end including the test amendment. Stack ships User / EmailAuthentication / RefreshToken models, MolibDbContext with EF migrations on a C.UTF-8-collated Postgres, UsersController and SessionsController, JWT bearer + Hangfire-backed verification email via MailKit → Mailcatcher in dev, the React SPA's sign-up / sign-in / verify routes with the silent-refresh AuthContext and authedFetch interceptor, and 19 passing backend integration tests under tests/Molib.Api.Tests/.
Notable tweaks during execution:
- Config moved out of compose env vars and into
appsettings.{Environment}.json(appsettings.Development.jsonfor dev,appsettings.Test.jsonfor the test environment). Reason: the WebApplicationFactory'sConfigureAppConfigurationruns after Program.cs's top-level config reads, so InMemory overrides came too late. JSON files load before anyConfiguration.Get*call.docker compose run … dotnet test …now needs zero-eoverrides. - Removed the explicit
BeginTransactionAsyncfromUsersController.Create. A singleSaveChangesAsyncis already atomic; the explicit transaction was redundant and made Hangfire enrolment behaviour harder to reason about. Program.csenv-gates bothAddHangfireServer()andUseHangfireDashboard(...)whenASPNETCORE_ENVIRONMENT=Test— tests verify enqueueing in Hangfire's real storage but don't run jobs (per ADR 0005).TestBasetruncates Hangfire's data tables but neverhangfire.schema(the version-tracking row). Wiping it tricksPostgreSqlObjectsInstallerinto reinstalling on the next host build, conflicting with already-present columns.- Cookie replay test uses a separate
HttpClientwithHandleCookies=falseso the manualCookie: molib_refresh=<old>header isn't combined with the auto-managed jar's rotated cookie.
End-to-end smoke test verified:
POST /users→ 201, returns id only (no token in body).- Mailcatcher inbox shows the verification email at
http://localhost:1080. - Token from email →
POST /users/verify→ 204. POST /sessions→ 200 with access JWT +molib_refreshcookie atPath=/.POST /sessions/refreshrotates the cookie; a second refresh succeeds.- Replaying the previous refresh cookie returns
401 ReplayDetectedand family is revoked. DELETE /sessions→ 204 and cookie cleared.- Frontend routes
/sign-up,/sign-in,/verify,/all serve 200;tsc -bexits 0;/api/*proxies to the API container.
Goals¶
- Switch
Molib.Apifrom minimal-API style to controller-based for non-trivial endpoints. The simple/healthzping stays as a minimal-API endpoint. - Introduce the project's first persisted domain models: User, EmailAuthentication, and RefreshToken (documented in
wiki/models/). - Ship five HTTP endpoints:
POST /users— create user (email + password). Enqueues a verification email; does not return the token in the response.POST /users/verify— confirm the user against their verification token (token comes from the email link).POST /sessions— sign-in (issues access JWT + rotating refresh token cookie; see ADR 0002).POST /sessions/refresh— rotate refresh token, issue new access JWT.DELETE /sessions— logout (family-wide refresh-token revocation).- Send verification email via a Hangfire-backed background job. Capture dev emails in Mailcatcher (web inbox at http://localhost:1080) so we can verify the flow end-to-end without a real provider.
- Build the SPA-side sign-up / sign-in / verify routes in the React frontend, plus the
authedFetchinterceptor specified in ADR 0002 so access-token refresh is transparent to the rest of the app.
Out of scope (explicitly deferred)¶
- Production email provider (SES, Postmark, SendGrid, etc.). Dev uses Mailcatcher; production SMTP wiring is its own decision and likely a small future ADR.
- Email templating engine. First pass uses a hard-coded plain-text + minimal-HTML body composed in C#. A real templating story (e.g. Razor email templates, MJML) can wait.
- Password reset / forgot-password.
- Multi-factor auth.
- API URL versioning (
/v1/...). Project SemVer (perCLAUDE.md) is unrelated to API versioning. - Roles / permissions.
- Cross-tab access-token sharing in the SPA (
BroadcastChannel). Each tab refreshes independently for now. - "Active sessions" UI / multi-device session listing. The data model supports it; UI is deferred.
- Periodic cleanup job for expired
RefreshTokenrows. Add only when the table grows. (Once Hangfire is wired up, this becomes a one-lineRecurringJob.AddOrUpdate(...).)
Approach¶
1. Switch the project to controller-based¶
- In
Program.cs: addbuilder.Services.AddControllers()andapp.MapControllers(). - Keep the existing
app.MapGet("/healthz", …)minimal-API endpoint (simple enough that a controller would be over-shaped). - New controllers go under
src/Molib.Api/Controllers/.
2. Add packages to Molib.Api.csproj¶
Microsoft.EntityFrameworkCore(10.x)Npgsql.EntityFrameworkCore.PostgreSQL(10.x)Microsoft.EntityFrameworkCore.Design(migrations)BCrypt.Net-Next(password hashing)Microsoft.AspNetCore.Authentication.JwtBearer(10.x) — JWT validation middlewareHangfire.AspNetCore— background job processingHangfire.PostgreSql— Postgres storage backend for HangfireMailKit— modern SMTP client (replaces the deprecatedSystem.Net.Mail.SmtpClient)
3. Models (under src/Molib.Api/Models/)¶
See wiki/models/user.md and wiki/models/email-authentication.md for field-level detail.
- User —
Id(uuid PK),CreatedAt(utc),VerificationToken(string),VerifiedAt(utc, nullable, default NULL),PasswordHash(string, bcrypt). - EmailAuthentication —
Id,CreatedAt,Address(string, unique),UserId(FK → User, ON DELETE RESTRICT + ON UPDATE RESTRICT). - RefreshToken —
Id,CreatedAt,ExpiresAt,UserId(FK → User, RESTRICT),FamilyId,TokenHash(sha-256 unique),UsedAt(nullable),RevokedAt(nullable). See model doc for invariants and indexes.
4. MolibDbContext (src/Molib.Api/Data/MolibDbContext.cs)¶
DbSet<User>,DbSet<EmailAuthentication>,DbSet<RefreshToken>.- Place the application schema under
public(default). Hangfire's tables live under a separatehangfireschema and are managed by Hangfire — not part of EF Core migrations. - In
OnModelCreating: - Unique constraint on
EmailAuthentication.Address. Address is normalized (trim + lowercase) at the controller boundary before any DB read or write; per ADR 0004, the database usesC.UTF-8collation so a plain unique constraint on the pre-normalized value is correct — nocitext, no functional index. - FK
EmailAuthentication.UserId → User.Idwith.OnDelete(DeleteBehavior.Restrict)(per project FK policy). - FK
RefreshToken.UserId → User.Idwith.OnDelete(DeleteBehavior.Restrict). - Unique index on
RefreshToken.TokenHash. Non-unique index onRefreshToken.FamilyId. - Configure
CreatedAtdefaults (now() at time zone 'utc').
5. Wire DbContext + JWT auth in Program.cs¶
- Read
ConnectionStrings:Default(already injected by docker-compose). builder.Services.AddDbContext<MolibDbContext>(opt => opt.UseNpgsql(...)).builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...)readingMOLIB_JWT_SECRET,MOLIB_JWT_ISSUER,MOLIB_JWT_TTL_MINUTES(default 30, hard ceiling 360 ≈ 6h per ADR 0002).- Wire
app.UseAuthentication()+app.UseAuthorization(). - New env vars to add to
docker-compose.ymland.env.example:MOLIB_JWT_SECRET,MOLIB_JWT_ISSUER(defaultmolib),MOLIB_JWT_TTL_MINUTES(default30),MOLIB_REFRESH_TTL_DAYS(default30).
6. Add a Mailcatcher service to docker-compose.yml¶
A separate service that captures every email the API tries to send in development. SMTP-in on :1025, web inbox on :1080.
mailcatcher:
image: sj26/mailcatcher:<pin-at-exec> # pin to whatever `latest` resolves to at execution time, per project convention
container_name: molib-mailcatcher
ports:
- "${MAIL_UI_PORT:-1080}:1080"
# SMTP is reached over the docker network from the api container; no need to expose 1025 to the host.
- Add
MAIL_UI_PORT=1080to.env.example. - Update the
molib-upskill to include the inbox URL (http://localhost:1080). - Mailcatcher is dev-only. It must not be present in any production compose file.
6b. Set the database collation on the db service¶
Per ADR 0004, the database initialises with C.UTF-8 to avoid the glibc-version index-corruption hazard and keep behavior predictable. Add to the db service environment:
db:
# …
environment:
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
# …existing POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB stay
POSTGRES_INITDB_ARGS only takes effect on a fresh data directory. The existing pgdata volume was created without these arguments, so as part of executing this plan we must wipe and re-init it: docker compose down -v once. There is no real data to lose at this stage. Document this clearly in the implementation commit so nobody runs down -v later and is surprised.
7. Wire Hangfire in Program.cs¶
builder.Services.AddHangfire(cfg => cfg.UsePostgreSqlStorage(connStr, new PostgreSqlStorageOptions { SchemaName = "hangfire" }));builder.Services.AddHangfireServer();— runs a worker in-process. Sufficient for one-app deployments; if we ever scale, run a dedicated worker container with the same image and--no-webstyle entrypoint.- Mount the dashboard at
/hangfire(operator-only — see ADR 0003 "Out of scope of this rule"). The dashboard authorization filter allows all callers; the access control is at the network boundary, not in app code (see Open Question 5). - Hangfire creates its own tables on startup in the
hangfireschema; do not include them in EF Core migrations.
Network exposure rule: in production deployments, the API service must not be directly reachable from the public internet. The public reverse proxy forwards only the routes the SPA needs (/api/..., possibly /healthz); /hangfire is not in that allow-list. Operator access to the Hangfire dashboard happens over the internal network (VPN / SSH tunnel / kubectl port-forward / cluster-internal ingress). A general "expose internal admin surfaces" solution is its own future work; this plan does not block on it.
8. Email service + verification job¶
Files:
src/Molib.Api/Email/IEmailService.cs— abstraction. Initial method:Task SendAsync(string to, string subject, string textBody, string htmlBody, CancellationToken ct);src/Molib.Api/Email/SmtpEmailService.cs— MailKit-backed implementation. ReadsMOLIB_SMTP_HOST,MOLIB_SMTP_PORT,MOLIB_SMTP_FROMfrom config. No TLS / no auth in dev (Mailcatcher); production wiring will add those (deferred).src/Molib.Api/Email/VerificationEmailJob.cs— Hangfire job. MethodTask SendAsync(Guid userId, CancellationToken ct). LoadsUser+ primaryEmailAuthentication, builds URL${MOLIB_PUBLIC_URL}/verify?token={user.VerificationToken}, callsIEmailService.SendAsync. Pass IDs to Hangfire jobs, never entities — Hangfire serializes args.
Trigger from UsersController after a successful sign-up:
The HTTP response returns 201 Created with the user's id only — no token in the body, since dev has Mailcatcher and prod has the real inbox.
9. New env vars (added to docker-compose.yml and .env.example)¶
| Var | Default (dev) | Purpose |
|---|---|---|
MOLIB_SMTP_HOST |
mailcatcher |
SMTP host (service name on the docker network). |
MOLIB_SMTP_PORT |
1025 |
Mailcatcher SMTP port. |
MOLIB_SMTP_FROM |
noreply@molib.local |
Default From address. |
MOLIB_PUBLIC_URL |
http://localhost:5173 |
Public base URL for the SPA. Used to build links in emails per ADR 0003. |
MAIL_UI_PORT |
1080 |
Host port for the Mailcatcher web inbox. |
Approach — Frontend (React SPA)¶
The frontend ships the user-visible half of this feature: three routes, an auth context that holds the in-memory access token, and a single authedFetch wrapper that implements the refresh interceptor from ADR 0002.
F1. Add packages to frontend/package.json¶
react-router-dom(latest 7.x) — routing.react-i18next+i18next(latest) — Portuguese strings via the project's localization rule (no hardcoded user-facing copy).- No form library yet: controlled MUI components are fine for the three small forms in this plan. Revisit only if friction emerges.
- No global state library: a
React.Contextis sufficient for the auth state. Revisit if state-sharing needs grow.
F2. Configure the Vite dev-server proxy¶
Same-origin during development — the SPA calls /api/... and Vite proxies to the API container:
// vite.config.ts
server: {
host: true,
port: 5173,
watch: { usePolling: true },
proxy: {
'/api': {
target: 'http://api:8080',
changeOrigin: true,
rewrite: p => p.replace(/^\/api/, ''),
},
},
},
The refresh-token cookie is set with Path=/ (per ADR 0002), so it travels on every same-origin request without needing a cookiePathRewrite. SameSite=Strict + HttpOnly + Secure continue to do the heavy lifting. In production, the upstream reverse proxy serves the SPA static bundle and forwards /api/* to the API — same shape, no per-environment code change, and /hangfire is not in the public proxy's allow-list (see step 7 network exposure rule).
F3. Routes (react-router-dom)¶
| Path | Page | Purpose |
|---|---|---|
/sign-up |
SignUpPage.tsx |
Email + password form. POSTs /api/users. On success, shows "Verifique seu e-mail" and stays put — does not auto-sign-in (user is unverified). |
/verify |
VerifyPage.tsx |
On mount: reads token from ?token=…, POSTs /api/users/verify. Renders "Verificando…", "Conta verificada", or a localized error from the API's stable error code. |
/sign-in |
SignInPage.tsx |
Email + password form. POSTs /api/sessions. On success, stores accessToken in AuthContext and navigates to /. |
/ |
HomePage.tsx |
Placeholder protected route. If the user has no access token (and silent refresh fails), redirects to /sign-in. |
A small <RequireAuth> wrapper or route loader handles the protected-route redirect.
F4. AuthContext (frontend/src/auth/AuthContext.tsx)¶
State (memory only — no localStorage/sessionStorage, per ADR 0002):
Actions: signIn(email, password), signOut(), refresh() (returns the fresh access token or throws). On app boot, the provider attempts a silent refresh() once — if the user has a valid refresh-token cookie, they land authenticated; if not, the access token stays null and protected routes redirect.
F5. authedFetch (frontend/src/api/client.ts)¶
The single fetch wrapper every endpoint module uses. Implements the algorithm from ADR 0002:
- Inject
Authorization: Bearer ${accessToken}if present. - On
401from any non-/api/sessions/refreshrequest: call refresh, retry once. Single-flight: if a refresh is already in progress, parallel 401sawaitthe same in-flight promise instead of stampeding/api/sessions/refresh. - On refresh failure: clear
AuthContext, navigate to/sign-in, throw. - Always
credentials: 'include'so the refresh cookie travels.
F6. API endpoint wrappers (frontend/src/api/endpoints.ts)¶
Typed thin wrappers over authedFetch for the five endpoints in this plan: createUser, verifyUser, signIn, refresh, signOut. Inputs and outputs are typed; error responses surface the API's stable error code so pages can map it to localized copy.
F7. i18n (frontend/src/i18n/)¶
i18n/index.ts—i18nextinit, default localept-BR.i18n/locales/pt-BR.json— all user-visible strings for these pages and the API-error → message mapping (e.g.errors.EmailNotVerified: "E-mail ainda não verificado.").- The frontend default locale is Portuguese; English copy is added only when the project actually serves an English audience.
F8. End-to-end smoke test (manual, dev only)¶
With docker compose up running the full stack (api, web, db, mailcatcher, docs):
- Open http://localhost:5173/sign-up, submit a fresh email + password.
- Open http://localhost:1080 (Mailcatcher), open the verification email, click the link. Browser lands on http://localhost:5173/verify?token=…, page reports "Conta verificada".
- Navigate to http://localhost:5173/sign-in, submit the same credentials. Land on
/. - Force-expire the access token (e.g. dev-only debug action that calls
AuthContext.expireForTest()), trigger any authed call, observe a single network call to/api/sessions/refreshand the original request retried successfully. signOut()— refresh cookie is cleared, protected routes redirect to/sign-in.
6. Migrations¶
dotnet ef migrations add InitialCreatefrom the host (per workflow: stopapi/web/dbfirst, then bringdbback up alone, run the migration, thenupthe rest).- Apply with
dotnet ef database update. Avoid auto-migrating on app startup beyond development.
7. Endpoints (UsersController + SessionsController)¶
POST /users— body{ email, password }.- Validate email format, password minimum strength.
- Normalize email (trim + lowercase).
- Hash password with bcrypt (work factor 12).
- Generate
VerificationTokenfrom a cryptographic RNG (32 bytes, URL-safe base64). - Create
User+EmailAuthenticationin a single DB transaction. - Enqueue
VerificationEmailJobvia Hangfire (the actual email is sent out-of-band; the HTTP response does not block on SMTP). - Return
201 Createdwith{ id }. The token is not in the body — it is delivered exclusively via the email captured by Mailcatcher in dev (or the real provider in prod). This closes the "verify any email you control without receiving the email" hole. - On duplicate email →
409 Conflict. POST /users/verify— body{ token }.- The frontend extracts the token from its own URL (e.g.
https://app.molib.example/verify?token=…) and calls this endpoint. The API does not serve a verification page or redirect anywhere — see ADR 0003. - Find
UserbyVerificationToken. - If not found or already verified →
400/410with a stable error code (e.g.{ "error": "TokenInvalid" }/"AlreadyVerified"). - Otherwise set
VerifiedAt = UtcNowand clearVerificationToken. Return204. POST /sessions(sign-in) — body{ email, password }. Per ADR 0002:- Look up
EmailAuthenticationby normalized address; loadUser. - If unverified →
403. If bcrypt mismatch →401. Constant-time on the failure path (do not early-exit on missing email vs. wrong password). - On success: mint access JWT (30 min default TTL); generate raw refresh token + new
FamilyId; insertRefreshTokenrow with SHA-256 hash; setHttpOnlySecureSameSite=Strictcookie atPath=/. Return200 { accessToken, expiresAt }. POST /sessions/refresh— empty body; refresh-token cookie carries the secret.- Hash incoming token, look up the
RefreshTokenrow. - If active (
UsedAt IS NULL AND RevokedAt IS NULL AND ExpiresAt > now()) → markUsedAt = now(), insert new row in same family, set new cookie, return new access JWT. - If
UsedAt IS NOT NULL→ replay: revoke the entire family, return401. - If expired/revoked/unknown →
401. DELETE /sessions(logout) — revoke every row in the current refresh token'sFamilyId. Clear the cookie. Return204.
8. Smoke test¶
- With the stack up: curl all three endpoints against a fresh DB. Document expected status codes in the plan completion note.
9. Document & close¶
- Mark this plan Implemented and link the commit/PR.
- Update
wiki/models/*.mdif any field shape changed during implementation. - Open ADR 0002 for the session-token decision before any real client consumes
POST /sessions.
Open questions / decisions needed before implementing¶
- ~~Sign-in response shape~~ — Resolved by ADR 0002: short-lived JWT access (30 min default, ≤6h hard ceiling) + opaque rotating refresh token in
HttpOnlycookie + family-based replay detection. - ~~Verification token storage~~ — Resolved: column on
User, never a separate table. PerCLAUDE.md→ "Single-use security tokens are columns, not tables." A row-per-request scheme on an unauthenticated endpoint is a DoS vector. The same rule will apply to password reset when added. - ~~Verification UX~~ — Resolved by ADR 0003: the API exposes only
POST /users/verify { token }. The verification email points at the SPA, the SPA pulls the token from its URL and posts it. The backend never redirects or renders a page. - ~~Email normalization rule~~ — Resolved: store trim + lowercase at the controller boundary; plain
UNIQUEconstraint onEmailAuthentication.Addressagainst the normalized value. With ADR 0004 (C.UTF-8), nocitextor functional index is needed. - ~~Hangfire dashboard authorization~~ — Resolved: option (c). The dashboard listens at
/hangfireand the dashboard authorization filter allows all callers; access control is at the network boundary. The public reverse proxy in production only forwards the SPA's allow-list (/api/*,/healthz), not/hangfire. Operator access happens over the internal network (VPN / SSH tunnel / kubectl port-forward). A separate "expose internal admin surfaces" solution is future work and does not block this plan. - ~~Refresh-token cookie path under the Vite proxy~~ — Resolved: set
Path=/(ADR 0002).HttpOnly+Secure+SameSite=Strict+ single-application origin make path scoping unnecessary; this avoids per-deploymentcookiePathRewritebrittleness. - ~~Frontend boot behavior when there is no refresh cookie~~ — Resolved: lazy redirect via
<RequireAuth>. Public pages (sign-up, sign-in, verify) render normally; protected pages redirect to/sign-in. TheAuthContextstill attempts one silent refresh on app boot so a returning user with a valid cookie lands authenticated without a redirect bounce.
Risks¶
- EF Core migrations and
dotnet watchcan fight over file locks. Follow the workflow: stopapi/web(keepdocsup), rundotnet ef ..., then bring services back. - bcrypt work factor 12 is the modern default; if integration tests are visibly slow, dev may drop to 10 (production stays at 12+).
- Postgres timezone semantics: persist
timestamp with time zone, treat all DateTimes as UTC at the boundary. - Hangfire serializes job arguments. Pass IDs (Guid, int), never entities or DbContext-tracked objects. Job methods load their own state from the DB.
- Mailcatcher is dev-only. Its presence in a production compose file would be a security incident (silently swallows email). Keep it in the dev compose only (when we split compose files) or guard with a
profiles:filter. - The Hangfire dashboard renders HTML — that's fine under the carve-out in ADR 0003, but it must be access-controlled in non-dev (see Open Question 5).
- Refresh-token cookie persistence. With
Path=/(per ADR 0002), the cookie travels on every same-origin request and there's no proxy rewrite to misconfigure. Smoke test still verifies a second triggered refresh after the first to confirm rotation works end-to-end (the new cookie was set, retained, and sent). - i18n drift. Adding new copy without updating
pt-BR.jsonis easy to miss. A pre-commit hook or simple typecheck of translation keys is a future cleanup, not in this plan. - Memory-only access token loses on hard refresh. Expected behavior — the silent-refresh on boot covers the case. Verify in F8 that browser F5 keeps the user signed in (refresh cookie carries them).
Files affected¶
Backend code:
- src/Molib.Api/Molib.Api.csproj — add package refs (EF Core, Npgsql, BCrypt, JwtBearer, Hangfire.AspNetCore, Hangfire.PostgreSql, MailKit)
- src/Molib.Api/Program.cs — controllers, DbContext, JWT auth, Hangfire server + dashboard
- src/Molib.Api/Controllers/UsersController.cs — new
- src/Molib.Api/Controllers/SessionsController.cs — new
- src/Molib.Api/Models/User.cs — new
- src/Molib.Api/Models/EmailAuthentication.cs — new
- src/Molib.Api/Models/RefreshToken.cs — new
- src/Molib.Api/Auth/JwtIssuer.cs (or similar) — new, mints JWTs from config
- src/Molib.Api/Auth/RefreshTokenService.cs — new, encapsulates rotate/revoke/replay logic
- src/Molib.Api/Email/IEmailService.cs — new
- src/Molib.Api/Email/SmtpEmailService.cs — new (MailKit)
- src/Molib.Api/Email/VerificationEmailJob.cs — new (Hangfire job)
- src/Molib.Api/Data/MolibDbContext.cs — new
- src/Molib.Api/Migrations/* — generated (app schema only; Hangfire schema is self-managed)
- docker-compose.yml — add mailcatcher service; add JWT/refresh/SMTP/public-URL env vars to api
- .env.example — add MAIL_UI_PORT, MOLIB_SMTP_*, MOLIB_PUBLIC_URL, MOLIB_JWT_*, MOLIB_REFRESH_TTL_DAYS
- .claude/skills/molib-up/SKILL.md — add Mailcatcher inbox URL (http://localhost:1080) and Hangfire dashboard URL (http://localhost:8080/hangfire) to the reported endpoints
Frontend code:
- frontend/package.json — add react-router-dom, i18next, react-i18next
- frontend/vite.config.ts — add /api dev proxy with cookiePathRewrite
- frontend/src/App.tsx — Router + ThemeProvider + AuthProvider wiring
- frontend/src/main.tsx — i18n bootstrap before render
- frontend/src/api/client.ts — authedFetch (single-flight refresh interceptor)
- frontend/src/api/endpoints.ts — typed wrappers (createUser, verifyUser, signIn, refresh, signOut)
- frontend/src/auth/AuthContext.tsx — state + actions
- frontend/src/auth/RequireAuth.tsx — protected-route wrapper
- frontend/src/pages/SignUpPage.tsx — new
- frontend/src/pages/SignInPage.tsx — new
- frontend/src/pages/VerifyPage.tsx — new
- frontend/src/pages/HomePage.tsx — placeholder protected page
- frontend/src/i18n/index.ts — i18next setup
- frontend/src/i18n/locales/pt-BR.json — all UI copy + API-error code mappings
Wiki (created/updated with this plan):
- wiki/models/README.md
- wiki/models/user.md
- wiki/models/email-authentication.md
- wiki/models/refresh-token.md
- wiki/plans/README.md
- wiki/plans/login.md (this file)
- wiki/adr/0002-sign-in-token-strategy.md
- wiki/adr/0003-api-frontend-separation.md
Tests (amendment 2026-04-28)¶
The core feature shipped without tests; this amendment establishes the project's first test project and uses login as the seed test surface. The infrastructure introduced here (real Postgres, _test database, fake email, stubbed Hangfire client) becomes the template for all future backend tests.
Goals¶
- Create
Molib.Api.Tests(xUnit) — the project's first .NET test project. - Run integration tests against a real PostgreSQL database named
molib_test, on the existingdbservice. Per ADR 0005 only true external dependencies are mocked — internal infrastructure (Postgres, Hangfire, our own services) runs real. - Cover every endpoint shipped in this plan (
POST /users,POST /users/verify,POST /sessions,POST /sessions/refresh,DELETE /sessions) plus the verification email job. - Establish patterns (test factory, DB reset between tests, fake
IEmailService, Hangfire drain helper) for future plans to reuse.
Out of scope¶
- Frontend tests (Vitest + React Testing Library). Deferred to a future amendment or plan. (Per CLAUDE.md → "Plans cover both backend and frontend": this section explicitly documents that frontend tests are deferred for now, not silently omitted.)
- Performance / load tests.
- End-to-end browser tests (Playwright). Could come later for cross-cutting flows.
Approach¶
T1. Test project layout¶
tests/
└── Molib.Api.Tests/
├── Molib.Api.Tests.csproj
├── Infrastructure/
│ ├── MolibApiFactory.cs # WebApplicationFactory<Program>
│ ├── DatabaseFixture.cs # creates molib_test, applies migrations once per test run
│ ├── TestBase.cs # base class with Reset() + scoped DbContext + Hangfire harness
│ ├── FakeEmailService.cs # captures sent emails to memory (only mock — SMTP is external)
│ └── HangfireQueueInspector.cs # reads enqueued jobs from real Hangfire storage; does NOT execute
├── Controllers/
│ ├── UsersControllerTests.cs
│ └── SessionsControllerTests.cs
└── Email/
└── VerificationEmailJobTests.cs
Add the test project to Molib.sln.
Packages:
Microsoft.NET.Test.Sdkxunitxunit.runner.visualstudioMicrosoft.AspNetCore.Mvc.Testing(providesWebApplicationFactory<Program>)Respawn(fast Postgres data reset between tests; truncatespublicschema, leaveshangfirealone)
T2. Test database¶
- Tests run against
dbservice, separate databasemolib_test. The devmolibdatabase is never touched by tests (and vice-versa). - Created on first test run by the fixture:
SELECT 1 FROM pg_database WHERE datname='molib_test';
-- if not present:
CREATE DATABASE molib_test
ENCODING 'UTF8'
LC_COLLATE 'C.UTF-8'
LC_CTYPE 'C.UTF-8'
TEMPLATE template0;
TEMPLATE template0 is required to override the collation per ADR 0004. The molib user is a superuser in our compose setup, so it can create databases.
- After creation, the fixture applies EF migrations against
molib_test(db.Database.Migrate()). - Between tests:
Respawntruncates every table in thepublicschema. Hangfire'shangfireschema is left alone (the stub job client means Hangfire isn't actually used during tests, but Respawn ignoring it keeps the door open).
T3. MolibApiFactory : WebApplicationFactory<Program>¶
The factory configures the API to run in-process for tests. Per ADR 0005, the only swap is the email service — Hangfire and Postgres run real:
- Sets
ASPNETCORE_ENVIRONMENT=Test. - Overrides
ConnectionStrings:Defaultto point atmolib_test. - Replaces
IEmailServicewithFakeEmailService(records each send to a thread-safe list). This is the only swap. SMTP is the only true external dependency in this plan. - Keeps
IBackgroundJobClientandHangfire.PostgreSqlstorage real — Hangfire runs againstmolib_testunder thehangfireschema, exactly like production. - Does not call
AddHangfireServer()in tests, and does not execute Hangfire jobs from controller tests. Per ADR 0005: controller tests verify that the right job entry was enqueued (viaHangfireQueueInspector); the job's own behavior is tested separately by instantiating the job class via DI and invoking its method directly. - Skips the startup
Database.Migrate()call (the fixture has already migrated the test DB).
T4. TestBase and HangfireTestHarness¶
xUnit IClassFixture<MolibApiFactory>-based base class providing:
HttpClient Client— pre-built from the factory.IServiceProvider Services— for resolving DbContext / jobs in arrange-act-assert.Task ResetAsync()— calls Respawn (scoped to thepublicschema); invoked fromIAsyncLifetime.InitializeAsync.MolibDbContext NewDb()— fresh scoped context for direct DB assertions.HangfireQueueInspector Hangfire { get; }— reads enqueued jobs from realHangfire.PostgreSqlstorage. Tests assert on type, method name, and argument values. Jobs are not executed during controller tests.
Each test method gets a clean DB and an empty Hangfire queue.
Two distinct rhythms.
Controller tests — verify enqueueing:
await Client.PostAsync("/users", body);
// real Hangfire storage; we inspect, we don't execute
var enqueued = Hangfire.Enqueued();
Assert.Single(enqueued);
Assert.Equal(typeof(VerificationEmailJob), enqueued[0].Type);
Assert.Equal(nameof(VerificationEmailJob.SendAsync), enqueued[0].MethodName);
Assert.Equal(createdUser.Id, (Guid)enqueued[0].Args[0]);
Job behavior tests — invoke the job directly via DI, assert on outcomes:
using (var scope = Factory.Services.CreateScope()) {
var job = scope.ServiceProvider.GetRequiredService<VerificationEmailJob>();
await job.SendAsync(user.Id, default);
}
Assert.Single(FakeEmail.Sent);
Assert.Contains(user.VerificationToken, FakeEmail.Sent[0].HtmlBody);
T5. Initial test coverage¶
UsersControllerTests
Create_HappyPath_Returns201_AndEnqueuesVerificationEmailJob_WithCorrectUserId(assert viaHangfireQueueInspector)Create_DuplicateEmail_Returns409_EmailAlreadyRegisteredCreate_WeakPassword_Returns400_WeakPasswordCreate_InvalidEmail_Returns400_InvalidEmailVerify_ValidToken_Returns204_AndClearsTokenAndSetsVerifiedAtVerify_InvalidToken_Returns400_TokenInvalidVerify_AlreadyVerified_Returns410_AlreadyVerified
SessionsControllerTests
SignIn_HappyPath_Returns200_WithAccessToken_AndSetsRefreshCookieSignIn_UnverifiedUser_Returns403_EmailNotVerifiedSignIn_WrongPassword_Returns401_InvalidCredentialsSignIn_UnknownEmail_Returns401_InvalidCredentialsRefresh_HappyPath_Rotates_AndReturnsNewAccessTokenRefresh_TwoConsecutiveRefreshes_BothSucceedRefresh_NoCookie_Returns401_NoRefreshTokenRefresh_ReusedOldCookie_Returns401_ReplayDetected_AndRevokesEntireFamilySignOut_RevokesFamily_AndClearsCookie
VerificationEmailJobTests
SendAsync_HappyPath_DeliversEmail_WithFrontendVerifyUrl_AndPortugueseSubjectSendAsync_AlreadyVerifiedUser_Skips_NoEmailSentSendAsync_MissingUser_Skips_NoEmailSent
T6. Running tests¶
Per the workflow rule (always stop api/web before .NET builds — they hold the source under dotnet watch):
docker compose rm -sf api web
docker compose run --rm --no-deps \
-e ConnectionStrings__Default="Host=db;Port=5432;Database=molib_test;Username=molib;Password=molib" \
--entrypoint "" api \
dotnet test /src/../tests/Molib.Api.Tests/Molib.Api.Tests.csproj --logger "console;verbosity=normal"
(db, docs, mailcatcher stay running. The api container is stopped before the run, brought back up afterward when the user wants the app live again.)
A small tests/run.sh wrapper script can encapsulate the above once it gets repetitive.
Open questions¶
- Test parallelism. Default xUnit parallelizes test classes; with one shared
molib_testDB and Respawn between tests, parallel classes would race. Options: (a) disable parallelism via[assembly: CollectionBehavior(DisableTestParallelization = true)]— simple, slow, fine until the suite grows; (b) one DB per test class (molib_test_<guid>), faster but adds setup/teardown; (c) per-class transactional rollback. Plan default: (a). Revisit when the test suite passes ~50 tests. - ~~Hangfire test strategy~~ — Resolved by ADR 0005: real
IBackgroundJobClient+ realHangfire.PostgreSqlstorage, deterministic drain viaHangfireTestHarnessinstead of a background server thread. Hangfire is internal, not external; mocking it would violate the policy. - Should the api
Program.csbe made testable? It currently callsdb.Database.Migrate()at startup. The factory will need to suppress that for tests. The cleanest fix is a smallProgram.cschange: skip migrations whenASPNETCORE_ENVIRONMENT=Test. Alternatively, the factory replaces the entire startup pipeline. Plan default: env-gate the migration call, simpler.
Risks¶
- Test DB drift. If migrations evolve and the fixture doesn't re-apply, tests pass against a stale schema. Mitigation: fixture always calls
Database.Migrate()on startup, which is idempotent. - Respawn missing tables. Adding a new table without re-checking Respawn's allow-list could leave data between tests. Mitigation: configure Respawn with
SchemasToInclude = new[] { "public" }rather than per-table. molib_testaccidentally being the dev DB. Guard: assert at fixture init that the connection string's database name ends in_test. Refuse to truncate otherwise.- Bcrypt cost in tests. Work factor 12 hashes are ~250ms each; a sign-in test does one. Suite of 20 sign-in tests = 5s on hashing alone. If it bites, reduce work factor to 4 in the
Testenvironment viaMOLIB_BCRYPT_WORK_FACTORenv var. Out of scope until measured.
Files affected¶
Backend code (test project, all new):
tests/Molib.Api.Tests/Molib.Api.Tests.csprojtests/Molib.Api.Tests/Infrastructure/MolibApiFactory.cstests/Molib.Api.Tests/Infrastructure/DatabaseFixture.cstests/Molib.Api.Tests/Infrastructure/TestBase.cstests/Molib.Api.Tests/Infrastructure/FakeEmailService.cs(only fake — SMTP is the only external dep)tests/Molib.Api.Tests/Infrastructure/HangfireQueueInspector.cs(reads enqueued jobs from real storage; does not execute)tests/Molib.Api.Tests/Controllers/UsersControllerTests.cstests/Molib.Api.Tests/Controllers/SessionsControllerTests.cstests/Molib.Api.Tests/Email/VerificationEmailJobTests.cs
Backend code (modifications to support testability):
src/Molib.Api/Program.cs— env-gate the startupDatabase.Migrate()call; expose a partialpublic partial class Program {}if needed byWebApplicationFactory<Program>.
Solution / infra:
Molib.sln— add the test project.tests/run.sh(optional) — wrapper for thedocker compose runinvocation.
Wiki:
wiki/plans/login.md(this amendment).
Frontend impact¶
None for this amendment. Frontend test infrastructure (Vitest + React Testing Library) is a separate plan or a follow-up amendment. Documented per the unified-process rule.