Skip to content

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.json for dev, appsettings.Test.json for the test environment). Reason: the WebApplicationFactory's ConfigureAppConfiguration runs after Program.cs's top-level config reads, so InMemory overrides came too late. JSON files load before any Configuration.Get* call. docker compose run … dotnet test … now needs zero -e overrides.
  • Removed the explicit BeginTransactionAsync from UsersController.Create. A single SaveChangesAsync is already atomic; the explicit transaction was redundant and made Hangfire enrolment behaviour harder to reason about.
  • Program.cs env-gates both AddHangfireServer() and UseHangfireDashboard(...) when ASPNETCORE_ENVIRONMENT=Test — tests verify enqueueing in Hangfire's real storage but don't run jobs (per ADR 0005).
  • TestBase truncates Hangfire's data tables but never hangfire.schema (the version-tracking row). Wiping it tricks PostgreSqlObjectsInstaller into reinstalling on the next host build, conflicting with already-present columns.
  • Cookie replay test uses a separate HttpClient with HandleCookies=false so the manual Cookie: 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_refresh cookie at Path=/.
  • POST /sessions/refresh rotates the cookie; a second refresh succeeds.
  • Replaying the previous refresh cookie returns 401 ReplayDetected and family is revoked.
  • DELETE /sessions → 204 and cookie cleared.
  • Frontend routes /sign-up, /sign-in, /verify, / all serve 200; tsc -b exits 0; /api/* proxies to the API container.

Goals

  1. Switch Molib.Api from minimal-API style to controller-based for non-trivial endpoints. The simple /healthz ping stays as a minimal-API endpoint.
  2. Introduce the project's first persisted domain models: User, EmailAuthentication, and RefreshToken (documented in wiki/models/).
  3. Ship five HTTP endpoints:
  4. POST /users — create user (email + password). Enqueues a verification email; does not return the token in the response.
  5. POST /users/verify — confirm the user against their verification token (token comes from the email link).
  6. POST /sessions — sign-in (issues access JWT + rotating refresh token cookie; see ADR 0002).
  7. POST /sessions/refresh — rotate refresh token, issue new access JWT.
  8. DELETE /sessions — logout (family-wide refresh-token revocation).
  9. 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.
  10. Build the SPA-side sign-up / sign-in / verify routes in the React frontend, plus the authedFetch interceptor 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 (per CLAUDE.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 RefreshToken rows. Add only when the table grows. (Once Hangfire is wired up, this becomes a one-line RecurringJob.AddOrUpdate(...).)

Approach

1. Switch the project to controller-based

  • In Program.cs: add builder.Services.AddControllers() and app.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 middleware
  • Hangfire.AspNetCore — background job processing
  • Hangfire.PostgreSql — Postgres storage backend for Hangfire
  • MailKit — modern SMTP client (replaces the deprecated System.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.

  • UserId (uuid PK), CreatedAt (utc), VerificationToken (string), VerifiedAt (utc, nullable, default NULL), PasswordHash (string, bcrypt).
  • EmailAuthenticationId, CreatedAt, Address (string, unique), UserId (FK → User, ON DELETE RESTRICT + ON UPDATE RESTRICT).
  • RefreshTokenId, 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 separate hangfire schema 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 uses C.UTF-8 collation so a plain unique constraint on the pre-normalized value is correct — no citext, no functional index.
  • FK EmailAuthentication.UserId → User.Id with .OnDelete(DeleteBehavior.Restrict) (per project FK policy).
  • FK RefreshToken.UserId → User.Id with .OnDelete(DeleteBehavior.Restrict).
  • Unique index on RefreshToken.TokenHash. Non-unique index on RefreshToken.FamilyId.
  • Configure CreatedAt defaults (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(...) reading MOLIB_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.yml and .env.example: MOLIB_JWT_SECRET, MOLIB_JWT_ISSUER (default molib), MOLIB_JWT_TTL_MINUTES (default 30), MOLIB_REFRESH_TTL_DAYS (default 30).

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=1080 to .env.example.
  • Update the molib-up skill 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-web style 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 hangfire schema; 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. Reads MOLIB_SMTP_HOST, MOLIB_SMTP_PORT, MOLIB_SMTP_FROM from config. No TLS / no auth in dev (Mailcatcher); production wiring will add those (deferred).
  • src/Molib.Api/Email/VerificationEmailJob.cs — Hangfire job. Method Task SendAsync(Guid userId, CancellationToken ct). Loads User + primary EmailAuthentication, builds URL ${MOLIB_PUBLIC_URL}/verify?token={user.VerificationToken}, calls IEmailService.SendAsync. Pass IDs to Hangfire jobs, never entities — Hangfire serializes args.

Trigger from UsersController after a successful sign-up:

_backgroundJobs.Enqueue<VerificationEmailJob>(j => j.SendAsync(user.Id, default));

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.Context is 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):

type AuthState = {
  accessToken: string | null;
  expiresAt: number | null;   // epoch ms
};

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 401 from any non-/api/sessions/refresh request: call refresh, retry once. Single-flight: if a refresh is already in progress, parallel 401s await the 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.tsi18next init, default locale pt-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):

  1. Open http://localhost:5173/sign-up, submit a fresh email + password.
  2. Open http://localhost:1080 (Mailcatcher), open the verification email, click the link. Browser lands on http://localhost:5173/verify?token=…, page reports "Conta verificada".
  3. Navigate to http://localhost:5173/sign-in, submit the same credentials. Land on /.
  4. 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/refresh and the original request retried successfully.
  5. signOut() — refresh cookie is cleared, protected routes redirect to /sign-in.

6. Migrations

  • dotnet ef migrations add InitialCreate from the host (per workflow: stop api/web/db first, then bring db back up alone, run the migration, then up the 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 VerificationToken from a cryptographic RNG (32 bytes, URL-safe base64).
  • Create User + EmailAuthentication in a single DB transaction.
  • Enqueue VerificationEmailJob via Hangfire (the actual email is sent out-of-band; the HTTP response does not block on SMTP).
  • Return 201 Created with { 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 User by VerificationToken.
  • If not found or already verified → 400/410 with a stable error code (e.g. { "error": "TokenInvalid" } / "AlreadyVerified").
  • Otherwise set VerifiedAt = UtcNow and clear VerificationToken. Return 204.
  • POST /sessions (sign-in) — body { email, password }. Per ADR 0002:
  • Look up EmailAuthentication by normalized address; load User.
  • 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; insert RefreshToken row with SHA-256 hash; set HttpOnly Secure SameSite=Strict cookie at Path=/. Return 200 { accessToken, expiresAt }.
  • POST /sessions/refresh — empty body; refresh-token cookie carries the secret.
  • Hash incoming token, look up the RefreshToken row.
  • If active (UsedAt IS NULL AND RevokedAt IS NULL AND ExpiresAt > now()) → mark UsedAt = now(), insert new row in same family, set new cookie, return new access JWT.
  • If UsedAt IS NOT NULLreplay: revoke the entire family, return 401.
  • If expired/revoked/unknown → 401.
  • DELETE /sessions (logout) — revoke every row in the current refresh token's FamilyId. Clear the cookie. Return 204.

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/*.md if 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

  1. ~~Sign-in response shape~~ — Resolved by ADR 0002: short-lived JWT access (30 min default, ≤6h hard ceiling) + opaque rotating refresh token in HttpOnly cookie + family-based replay detection.
  2. ~~Verification token storage~~ — Resolved: column on User, never a separate table. Per CLAUDE.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.
  3. ~~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.
  4. ~~Email normalization rule~~ — Resolved: store trim + lowercase at the controller boundary; plain UNIQUE constraint on EmailAuthentication.Address against the normalized value. With ADR 0004 (C.UTF-8), no citext or functional index is needed.
  5. ~~Hangfire dashboard authorization~~ — Resolved: option (c). The dashboard listens at /hangfire and 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.
  6. ~~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-deployment cookiePathRewrite brittleness.
  7. ~~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. The AuthContext still 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 watch can fight over file locks. Follow the workflow: stop api/web (keep docs up), run dotnet 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.json is 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.tsauthedFetch (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

  1. Create Molib.Api.Tests (xUnit) — the project's first .NET test project.
  2. Run integration tests against a real PostgreSQL database named molib_test, on the existing db service. Per ADR 0005 only true external dependencies are mocked — internal infrastructure (Postgres, Hangfire, our own services) runs real.
  3. Cover every endpoint shipped in this plan (POST /users, POST /users/verify, POST /sessions, POST /sessions/refresh, DELETE /sessions) plus the verification email job.
  4. 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.Sdk
  • xunit
  • xunit.runner.visualstudio
  • Microsoft.AspNetCore.Mvc.Testing (provides WebApplicationFactory<Program>)
  • Respawn (fast Postgres data reset between tests; truncates public schema, leaves hangfire alone)

T2. Test database

  • Tests run against db service, separate database molib_test. The dev molib database 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: Respawn truncates every table in the public schema. Hangfire's hangfire schema 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:Default to point at molib_test.
  • Replaces IEmailService with FakeEmailService (records each send to a thread-safe list). This is the only swap. SMTP is the only true external dependency in this plan.
  • Keeps IBackgroundJobClient and Hangfire.PostgreSql storage real — Hangfire runs against molib_test under the hangfire schema, 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 (via HangfireQueueInspector); 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 the public schema); invoked from IAsyncLifetime.InitializeAsync.
  • MolibDbContext NewDb() — fresh scoped context for direct DB assertions.
  • HangfireQueueInspector Hangfire { get; } — reads enqueued jobs from real Hangfire.PostgreSql storage. 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 via HangfireQueueInspector)
  • Create_DuplicateEmail_Returns409_EmailAlreadyRegistered
  • Create_WeakPassword_Returns400_WeakPassword
  • Create_InvalidEmail_Returns400_InvalidEmail
  • Verify_ValidToken_Returns204_AndClearsTokenAndSetsVerifiedAt
  • Verify_InvalidToken_Returns400_TokenInvalid
  • Verify_AlreadyVerified_Returns410_AlreadyVerified

SessionsControllerTests

  • SignIn_HappyPath_Returns200_WithAccessToken_AndSetsRefreshCookie
  • SignIn_UnverifiedUser_Returns403_EmailNotVerified
  • SignIn_WrongPassword_Returns401_InvalidCredentials
  • SignIn_UnknownEmail_Returns401_InvalidCredentials
  • Refresh_HappyPath_Rotates_AndReturnsNewAccessToken
  • Refresh_TwoConsecutiveRefreshes_BothSucceed
  • Refresh_NoCookie_Returns401_NoRefreshToken
  • Refresh_ReusedOldCookie_Returns401_ReplayDetected_AndRevokesEntireFamily
  • SignOut_RevokesFamily_AndClearsCookie

VerificationEmailJobTests

  • SendAsync_HappyPath_DeliversEmail_WithFrontendVerifyUrl_AndPortugueseSubject
  • SendAsync_AlreadyVerifiedUser_Skips_NoEmailSent
  • SendAsync_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

  1. Test parallelism. Default xUnit parallelizes test classes; with one shared molib_test DB 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.
  2. ~~Hangfire test strategy~~ — Resolved by ADR 0005: real IBackgroundJobClient + real Hangfire.PostgreSql storage, deterministic drain via HangfireTestHarness instead of a background server thread. Hangfire is internal, not external; mocking it would violate the policy.
  3. Should the api Program.cs be made testable? It currently calls db.Database.Migrate() at startup. The factory will need to suppress that for tests. The cleanest fix is a small Program.cs change: skip migrations when ASPNETCORE_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_test accidentally 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 Test environment via MOLIB_BCRYPT_WORK_FACTOR env var. Out of scope until measured.

Files affected

Backend code (test project, all new):

  • tests/Molib.Api.Tests/Molib.Api.Tests.csproj
  • tests/Molib.Api.Tests/Infrastructure/MolibApiFactory.cs
  • tests/Molib.Api.Tests/Infrastructure/DatabaseFixture.cs
  • tests/Molib.Api.Tests/Infrastructure/TestBase.cs
  • tests/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.cs
  • tests/Molib.Api.Tests/Controllers/SessionsControllerTests.cs
  • tests/Molib.Api.Tests/Email/VerificationEmailJobTests.cs

Backend code (modifications to support testability):

  • src/Molib.Api/Program.cs — env-gate the startup Database.Migrate() call; expose a partial public partial class Program {} if needed by WebApplicationFactory<Program>.

Solution / infra:

  • Molib.sln — add the test project.
  • tests/run.sh (optional) — wrapper for the docker compose run invocation.

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.