diff --git a/CLAUDE.md b/CLAUDE.md index 7d6b591b..c8a13d4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ The API's HTTP surface is `AddApiSurface` plus `MapApiEndpoints`; `Program.cs` a - **Proxy orphans** (`ProxyOrphans`, a proxy sweep's report) are written only by the Worker; outside it only `EfProxyOrphanReader` depends on the entity (`only-worker-writes-proxy-orphans`). See `docs/architecture/work-queue.md`, "Proxy sweeps". - Both hosts apply EF migrations at startup, serialised by a PostgreSQL advisory lock (`MigrateWithAdvisoryLockAsync`). -`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has a translated placeholder page. The work is planned in parent issues #344–#349; see "The UI" below. +`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has the login screen and a signed-in placeholder (#360). The work is planned in parent issues #344–#349; see "The UI" below. ### Storage Layout @@ -167,7 +167,7 @@ Each user has a private set of `UserStorageConfig` entries pointing to NAS roots - Merging a PR: wait until CI has finished and `CI gate` and `CodeQL` are green (`gh pr checks --watch`), then ask the maintainer to confirm. Never merge before CI passes, and never without that confirmation. - PostgreSQL connection is never hardcoded — always read from `POSTGRES_CONNECTION__*` env vars or Docker secrets - CORS allowed origins configured via `CORS__ALLOWEDORIGINS` env var (comma-separated); the same value switches the refresh cookie to `SameSite=None`. Leave empty for same-origin / reverse proxy deployments. -- `X-Forwarded-For` is honoured only from loopback or from addresses listed in `FORWARDEDHEADERS__KNOWNPROXIES` / `FORWARDEDHEADERS__KNOWNNETWORKS` (comma-separated IPs / CIDRs; `ForwardLimit = 1`). Rate limiting partitions on the resulting client IP, so a reverse-proxy deployment must set one of these or all clients share the proxy's bucket. Never trust the Docker bridge network for a directly published port — that lets any client spoof its IP. +- `X-Forwarded-For` is honoured only from loopback or from addresses listed in `FORWARDEDHEADERS__KNOWNPROXIES` / `FORWARDEDHEADERS__KNOWNNETWORKS` (comma-separated IPs / CIDRs; `ForwardLimit = 1`). Rate limiting partitions on the resulting client IP, so a reverse-proxy deployment must set one of these or all clients share the proxy's bucket. Never trust the Docker bridge network for a directly published port — that lets any client spoof its IP. The `Sensitive` policy (login, register, change-password, admin user writes) allows `RATELIMIT__SENSITIVEPERMITLIMIT` requests per minute (default 10); only `src/docker-compose.e2e.yml` raises it. - Registration requires an admin-issued invite token. The first user (bootstrap admin) is exempt. - Observability: `GET /api/v1/healthz` (database, proxy storage, originals storage; a missing mount reports Degraded). Logging is the default .NET console logger with `LoggerMessage` source-generated messages (architecture rule `logging-is-source-generated`; `BeginScope` is allowed); structured JSON output and OpenTelemetry are **not** wired up. - License: AGPL-3.0. diff --git a/docs/architecture/ui-serving.md b/docs/architecture/ui-serving.md index c4b00cb3..9de18bf3 100644 --- a/docs/architecture/ui-serving.md +++ b/docs/architecture/ui-serving.md @@ -61,5 +61,12 @@ for the flashback player, say) adds it to `Permissions-Policy` in its own change - **Seam 4** (`src/Anichron.UI/e2e/`): Playwright against the image built from the checkout, started by `src/docker-compose.e2e.yml` with PostgreSQL on tmpfs and no Worker. Projects: WebKit on an iPhone profile, Chromium on a Pixel profile, Firefox at a phone viewport. The smoke test - loads the placeholder and asserts the headers, the assets' caching, no CSP or Trusted Types - violation, no request to another origin, and no axe violations. CI runs it in the `E2E` job. + loads the app (a visitor without a token lands on `/login`) and asserts the headers, the assets' + caching, no CSP or Trusted Types violation, no request to another origin, and no axe violations. + `login.spec.ts` signs in through the screen: success, a wrong password, a disabled account and a + locked account's countdown, each with no CSP violation. Users are seeded only through the public + API (#360): the global setup rotates the bootstrap admin's first password (so it fails on a + database that is not fresh), and each test creates its own user with the admin API and changes + its temporary password. Every request reaches the API from the one Docker bridge address, so the + compose file raises `RateLimit__SensitivePermitLimit`; lockout keeps its production timings. CI + runs both in the `E2E` job. diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs b/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs index 95a88a45..efcb108b 100644 --- a/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs +++ b/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs @@ -38,7 +38,7 @@ public static async Task StartAsync( builder.Services.AddAuthorizationPolicies(); builder.Services.AddProblemDetails(); if (rateLimited) - builder.Services.AddRateLimiting(); + builder.Services.AddRateLimiting(builder.Configuration); configureServices(builder.Services); var app = builder.Build(); app.UseApiProblems(); diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs b/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs index d029e782..af9bb749 100644 --- a/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs +++ b/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using System.Net; namespace Anichron.API.Tests.Unit.Infrastructure; @@ -22,7 +23,7 @@ private static async Task StartAsync(Dictionary builder.Configuration.AddInMemoryCollection(settings); builder.Services .AddForwardedHeadersSupport(builder.Configuration) - .AddRateLimiting(); + .AddRateLimiting(builder.Configuration); var app = builder.Build(); app.Use((context, next) => @@ -113,6 +114,33 @@ public async Task RefreshPolicy_AllowsItsPermitLimitThenRejectsAsync() HttpStatusCode.TooManyRequests); } + [Fact] + public async Task SensitivePolicy_ConfiguredPermitLimit_AllowsThatManyThenRejectsAsync() + { + await using var app = await StartAsync(new() { ["RateLimit:SensitivePermitLimit"] = "3" }); + using var client = app.GetTestClient(); + var statuses = new List(); + for (var i = 0; i < 4; i++) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/probe"); + request.Headers.Add(PeerHeader, UntrustedPeer); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + statuses.Add(response.StatusCode); + } + + statuses.Should().Equal(HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + public async Task SensitivePolicy_PermitLimitBelowOne_FailsAtStartupAsync(string limit) + { + var start = () => StartAsync(new() { ["RateLimit:SensitivePermitLimit"] = limit }); + + await start.Should().ThrowAsync(); + } + [Fact] public void AddForwardedHeadersSupport_UnparseableNetwork_ThrowsAtRegistration() { diff --git a/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs b/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs index 54e5d0ae..511d33c4 100644 --- a/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Anichron.Infrastructure.Storage; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -31,7 +32,7 @@ public IServiceCollection AddApiSurface(IConfiguration configuration) .AddAuthServices(configuration) .AddWorkItemServices() .AddAuthorizationPolicies() - .AddRateLimiting() + .AddRateLimiting(configuration) .AddCorsPolicy(configuration) .AddApiHealthChecks(); @@ -62,12 +63,16 @@ public IServiceCollection AddForwardedHeadersSupport(IConfiguration configuratio }); } - public IServiceCollection AddRateLimiting() + public IServiceCollection AddRateLimiting(IConfiguration configuration) { - return services.AddRateLimiter(options => + services.AddOptions() + .Bind(configuration.GetSection("RateLimit")) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddOptions().Configure>((options, settings) => { options.AddPolicy(AuthRateLimitPolicies.Sensitive, new SlidingWindowPolicy( - AppDefaults.RateLimit.Sensitive.PermitLimit, + settings.Value.SensitivePermitLimit, TimeSpan.FromSeconds(AppDefaults.RateLimit.Sensitive.WindowSeconds), AppDefaults.RateLimit.Sensitive.Segments)); @@ -77,6 +82,8 @@ public IServiceCollection AddRateLimiting() TimeSpan.FromMinutes(AppDefaults.RateLimit.Refresh.WindowMinutes), AppDefaults.RateLimit.Refresh.Segments)); }); + + return services.AddRateLimiter(_ => { }); } public IServiceCollection AddWorkItemServices() diff --git a/src/Anichron.API/Settings/RateLimitSettings.cs b/src/Anichron.API/Settings/RateLimitSettings.cs new file mode 100644 index 00000000..318c5bd2 --- /dev/null +++ b/src/Anichron.API/Settings/RateLimitSettings.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Anichron.API.Settings; + +public sealed record RateLimitSettings +{ + [Range(1, int.MaxValue)] + public int SensitivePermitLimit { get; init; } = AppDefaults.RateLimit.Sensitive.PermitLimit; +} diff --git a/src/Anichron.UI/e2e/accounts.ts b/src/Anichron.UI/e2e/accounts.ts new file mode 100644 index 00000000..036cd034 --- /dev/null +++ b/src/Anichron.UI/e2e/accounts.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import { expect, type APIRequestContext } from '@playwright/test'; + +export const bootstrapAdmin = { username: 'admin', password: 'admin' }; +export const admin = { username: 'admin', password: 'end-to-end admin passphrase' }; + +export interface Account { + id: string; + username: string; + password: string; +} + +const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + +export async function signIn( + request: APIRequestContext, + usernameOrEmail: string, + password: string, +) { + const response = await request.post('/api/v1/auth/login/mobile', { + data: { usernameOrEmail, password }, + }); + expect(response.status(), await response.text()).toBe(200); + return ((await response.json()) as { accessToken: string }).accessToken; +} + +export async function changePassword( + request: APIRequestContext, + token: string, + currentPassword: string, + newPassword: string, +) { + const response = await request.post('/api/v1/users/me/password', { + headers: bearer(token), + data: { currentPassword, newPassword }, + }); + expect(response.status(), await response.text()).toBe(204); +} + +export async function createAccount(request: APIRequestContext): Promise { + const adminToken = await signIn(request, admin.username, admin.password); + const username = `user-${randomUUID().slice(0, 8)}`; + const created = await request.post('/api/v1/users', { + headers: bearer(adminToken), + data: { username, email: `${username}@example.com` }, + }); + expect(created.status(), await created.text()).toBe(201); + const { id, temporaryPassword } = (await created.json()) as { + id: string; + temporaryPassword: string; + }; + const password = `${username} passphrase`; + const token = await signIn(request, username, temporaryPassword); + await changePassword(request, token, temporaryPassword, password); + return { id, username, password }; +} + +export async function disable(request: APIRequestContext, account: Account) { + const adminToken = await signIn(request, admin.username, admin.password); + const response = await request.patch(`/api/v1/users/${account.id}`, { + headers: bearer(adminToken), + data: { isAdmin: null, isDisabled: true }, + }); + expect(response.status(), await response.text()).toBe(200); +} + +async function failSignIn(request: APIRequestContext, account: Account) { + const response = await request.post('/api/v1/auth/login/mobile', { + data: { usernameOrEmail: account.username, password: 'not the password' }, + }); + expect(response.status()).toBe(401); +} + +const pause = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +// Failures 4, 5 and 6 lock for 2, 4 and 8 s; one during a lock is not counted. +export async function lockOut(request: APIRequestContext, account: Account) { + for (let attempt = 0; attempt < 4; attempt++) await failSignIn(request, account); + await pause(2.5); + await failSignIn(request, account); + await pause(4.5); + await failSignIn(request, account); +} diff --git a/src/Anichron.UI/e2e/global-setup.ts b/src/Anichron.UI/e2e/global-setup.ts new file mode 100644 index 00000000..ed71a794 --- /dev/null +++ b/src/Anichron.UI/e2e/global-setup.ts @@ -0,0 +1,17 @@ +import { request, type FullConfig } from '@playwright/test'; +import { admin, bootstrapAdmin, changePassword } from './accounts'; + +export default async function globalSetup(config: FullConfig) { + const context = await request.newContext({ baseURL: config.projects[0]?.use.baseURL }); + const response = await context.post('/api/v1/auth/login/mobile', { + data: { usernameOrEmail: bootstrapAdmin.username, password: bootstrapAdmin.password }, + }); + if (response.status() !== 200) + throw new Error( + 'The bootstrap admin cannot sign in with its first password, so the database is not fresh. ' + + 'Restart it: docker compose -f src/docker-compose.e2e.yml down, then up.', + ); + const { accessToken } = (await response.json()) as { accessToken: string }; + await changePassword(context, accessToken, bootstrapAdmin.password, admin.password); + await context.dispose(); +} diff --git a/src/Anichron.UI/e2e/login.spec.ts b/src/Anichron.UI/e2e/login.spec.ts new file mode 100644 index 00000000..6f5f114f --- /dev/null +++ b/src/Anichron.UI/e2e/login.spec.ts @@ -0,0 +1,93 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; +import { createAccount, disable, lockOut } from './accounts'; + +const signedIn = "You're signed in. Your photo library is on its way."; +const pageErrors: string[] = []; + +test.beforeEach(async ({ page }) => { + pageErrors.length = 0; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.addInitScript(() => { + const violations: string[] = []; + Object.defineProperty(window, 'cspViolations', { value: violations }); + document.addEventListener('securitypolicyviolation', (event) => { + violations.push(`${event.effectiveDirective} ${event.blockedURI}`); + }); + }); +}); + +test.afterEach(async ({ page }) => { + expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]); + expect(pageErrors).toEqual([]); +}); + +async function signIn(page: Page, usernameOrEmail: string, password: string) { + await page.goto('/login'); + await page.getByLabel('Username or email').fill(usernameOrEmail); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); +} + +async function expectAccessible(page: Page) { + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations).toEqual([]); +} + +test('a user signs in and lands on the signed-in page', async ({ page, request }) => { + const account = await createAccount(request); + + await signIn(page, account.username, account.password); + + await expect(page.getByText(signedIn)).toBeVisible(); + await expect(page).toHaveURL('/'); + await expectAccessible(page); + + expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]); + await page.reload(); + await expect(page).toHaveURL('/login'); +}); + +test('a wrong password is refused in the user’s language', async ({ page, request }) => { + const account = await createAccount(request); + + await signIn(page, account.username, 'not the password'); + + await expect(page.getByRole('alert')).toHaveText('The username, email or password is wrong.'); + await expect(page).toHaveURL('/login'); + await expectAccessible(page); +}); + +test('a disabled account is told to ask the administrator', async ({ page, request }) => { + const account = await createAccount(request); + await disable(request, account); + + await signIn(page, account.username, account.password); + + await expect(page.getByRole('alert')).toHaveText( + 'This account is disabled. Ask your administrator to enable it.', + ); + await expect(page).toHaveURL('/login'); + await expectAccessible(page); +}); + +test('a locked account counts down, then signs in', async ({ page, request }) => { + test.slow(); + const account = await createAccount(request); + await lockOut(request, account); + + await signIn(page, account.username, account.password); + + const alert = page.getByRole('alert'); + await expect(alert).toContainText('Too many failed attempts. This account is locked for now.'); + await expect(page.getByText(/You can try again in \d+ seconds?\./)).toBeVisible(); + const submit = page.getByRole('button', { name: 'Sign in' }); + await expect(submit).toBeDisabled(); + await expectAccessible(page); + + await expect(submit).toBeEnabled({ timeout: 10_000 }); + await expect(page.getByText(/You can try again/)).toBeHidden(); + await submit.click(); + + await expect(page.getByText(signedIn)).toBeVisible(); +}); diff --git a/src/Anichron.UI/e2e/smoke.spec.ts b/src/Anichron.UI/e2e/smoke.spec.ts index 72f0faca..6a4981e8 100644 --- a/src/Anichron.UI/e2e/smoke.spec.ts +++ b/src/Anichron.UI/e2e/smoke.spec.ts @@ -7,7 +7,7 @@ const appPolicy = "font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; " + "frame-ancestors 'none'; require-trusted-types-for 'script'"; -test('the placeholder loads with its headers and requests nothing but its own origin', async ({ +test('the app loads with its headers and requests nothing but its own origin', async ({ page, context, baseURL, @@ -32,8 +32,8 @@ test('the placeholder loads with its headers and requests nothing but its own or const response = await page.goto('/'); - await expect(page.getByRole('heading', { level: 1, name: 'Anichron' })).toBeVisible(); - await expect(page.getByText('Your photo library is on its way.')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Sign in to Anichron' })).toBeVisible(); + await expect(page).toHaveURL('/login'); const headers = response?.headers() ?? {}; expect(headers['content-security-policy']).toBe(appPolicy); expect(headers['referrer-policy']).toBe('same-origin'); diff --git a/src/Anichron.UI/eslint.config.js b/src/Anichron.UI/eslint.config.js index 69741ebc..201bfc75 100644 --- a/src/Anichron.UI/eslint.config.js +++ b/src/Anichron.UI/eslint.config.js @@ -34,6 +34,13 @@ export default defineConfig([ 'error', { allowInterfaces: 'with-single-extends' }, ], + '@typescript-eslint/only-throw-error': [ + 'error', + { + allow: [{ from: 'package', package: '@tanstack/router-core', name: 'Redirect' }], + allowRethrowing: true, + }, + ], }, }, { diff --git a/src/Anichron.UI/package.json b/src/Anichron.UI/package.json index 2163ad94..a5786896 100644 --- a/src/Anichron.UI/package.json +++ b/src/Anichron.UI/package.json @@ -36,11 +36,14 @@ "last 2 Edge versions" ], "dependencies": { + "@hookform/resolvers": "6.0.2", "@tanstack/react-router": "1.170.46", "i18next": "26.5.11", "react": "19.3.1", "react-dom": "19.3.1", - "react-i18next": "17.1.9" + "react-hook-form": "7.91.0", + "react-i18next": "17.1.9", + "zod": "4.6.7" }, "devDependencies": { "@axe-core/playwright": "4.14.0", diff --git a/src/Anichron.UI/playwright.config.ts b/src/Anichron.UI/playwright.config.ts index 6e580f46..c52fe322 100644 --- a/src/Anichron.UI/playwright.config.ts +++ b/src/Anichron.UI/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', forbidOnly: !!process.env.CI, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { diff --git a/src/Anichron.UI/pnpm-lock.yaml b/src/Anichron.UI/pnpm-lock.yaml index 98159c75..de4b7ea3 100644 --- a/src/Anichron.UI/pnpm-lock.yaml +++ b/src/Anichron.UI/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: 6.0.2 + version: 6.0.2(ajv@8.20.0)(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7) '@tanstack/react-router': specifier: 1.170.46 version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -178,9 +181,15 @@ importers: react-dom: specifier: 19.3.1 version: 19.3.1(react@19.3.1) + react-hook-form: + specifier: 7.91.0 + version: 7.91.0(@types/react@19.3.0)(react@19.3.1) react-i18next: specifier: 17.1.9 version: 17.1.9(i18next@26.5.11(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3) + zod: + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@axe-core/playwright': specifier: 4.14.0 @@ -647,6 +656,81 @@ packages: '@noble/hashes': optional: true + '@hookform/resolvers@6.0.2': + resolution: {integrity: sha512-e2dqUOlaGuirKVOd/iZWk/Yg5zeaGw7t0hSc3LzxL3o7eZGwZkTyEbBL8H52m0wRKEsNLWoMGOiXGHbPHTXlEQ==} + peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.23.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^4.0.0 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 || ^18.0.0 + nope-validator: '>=0.12.0' + react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=6.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1089,6 +1173,9 @@ packages: peerDependencies: size-limit: 14.3.0 + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stryker-mutator/api@10.1.1': resolution: {integrity: sha512-3s/ECsrD1Tf1WEB7amBk0HxjN7zgkabKvmqnI2C831rTNszJ6o2HoV9PVxtElDEWxb2+t4iavK5x25G+DTUOUA==} engines: {node: '>=22.0.0'} @@ -2663,6 +2750,16 @@ packages: peerDependencies: react: ^19.3.1 + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + react-i18next@17.1.9: resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==} peerDependencies: @@ -3662,6 +3759,14 @@ snapshots: '@exodus/bytes@1.16.0': {} + '@hookform/resolvers@6.0.2(ajv@8.20.0)(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7)': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + ajv: 8.20.0 + zod: 4.6.7 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3963,6 +4068,8 @@ snapshots: dependencies: size-limit: 14.3.0 + '@standard-schema/utils@0.3.0': {} + '@stryker-mutator/api@10.1.1': dependencies: mutation-testing-metrics: 3.8.4 @@ -5579,6 +5686,12 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + react-i18next@17.1.9(i18next@26.5.11(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.10 diff --git a/src/Anichron.UI/src/api/client.ts b/src/Anichron.UI/src/api/client.ts new file mode 100644 index 00000000..7b7b2cd8 --- /dev/null +++ b/src/Anichron.UI/src/api/client.ts @@ -0,0 +1,30 @@ +import type { LoginRequest, LoginResponse } from './login-contract'; +import { readProblem, unknownProblem, type Problem } from './problem'; + +export type LoginOutcome = { ok: true } | { ok: false; problem: Problem }; + +export interface ApiClient { + login(request: LoginRequest): Promise; + isSignedIn(): boolean; +} + +export function createApiClient(): ApiClient { + let accessToken: string | undefined; + return { + async login(request) { + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + if (!response.ok) return { ok: false, problem: await readProblem(response) }; + accessToken = ((await response.json()) as LoginResponse).accessToken; + return { ok: true }; + } catch { + return { ok: false, problem: unknownProblem }; + } + }, + isSignedIn: () => accessToken !== undefined, + }; +} diff --git a/src/Anichron.UI/src/api/login-contract.ts b/src/Anichron.UI/src/api/login-contract.ts new file mode 100644 index 00000000..6c5759ef --- /dev/null +++ b/src/Anichron.UI/src/api/login-contract.ts @@ -0,0 +1,10 @@ +// Hand-written until #347 generates the API types; delete together with src/test/handlers/login.ts. + +export interface LoginRequest { + usernameOrEmail: string; + password: string; +} + +export interface LoginResponse { + accessToken: string; +} diff --git a/src/Anichron.UI/src/api/problem.ts b/src/Anichron.UI/src/api/problem.ts new file mode 100644 index 00000000..d3a0f423 --- /dev/null +++ b/src/Anichron.UI/src/api/problem.ts @@ -0,0 +1,41 @@ +import { z } from 'zod/mini'; + +export interface Problem { + code: string | undefined; + fields: Readonly>>; + retryAfterSeconds: number; +} + +const problemBody = z.catch( + z.object({ + code: z.catch(z.optional(z.string()), undefined), + errors: z.catch( + z.record( + z.string(), + z.pipe( + z.tuple([z.string()], z.string()), + z.transform(([first]) => first), + ), + ), + {}, + ), + }), + { code: undefined, errors: {} }, +); + +export const unknownProblem: Problem = { code: undefined, fields: {}, retryAfterSeconds: 0 }; + +export async function readProblem(response: Response): Promise { + const body = problemBody.parse(await response.json().catch(() => undefined)); + return { + code: body.code, + fields: body.errors, + retryAfterSeconds: Number(response.headers.get('Retry-After')) || 0, + }; +} + +export function fieldCodes(problem: Problem, fields: readonly F[]) { + return fields + .filter((field) => field in problem.fields) + .map((field) => [field, problem.fields[field]] as const); +} diff --git a/src/Anichron.UI/src/app/main.tsx b/src/Anichron.UI/src/app/main.tsx index 807130f3..a3db1021 100644 --- a/src/Anichron.UI/src/app/main.tsx +++ b/src/Anichron.UI/src/app/main.tsx @@ -1,15 +1,20 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { z } from 'zod/mini'; +import { createApiClient } from '../api/client'; import { createI18n } from '../i18n/i18n'; import { App } from './App'; import './base.css'; import { createAppRouter } from './router'; +// The CSP forbids the eval that zod probes for. +z.config({ jitless: true }); + const root = document.getElementById('root'); if (root) { createRoot(root).render( - + , ); } diff --git a/src/Anichron.UI/src/app/router.tsx b/src/Anichron.UI/src/app/router.tsx index 558ae4bc..450129ad 100644 --- a/src/Anichron.UI/src/app/router.tsx +++ b/src/Anichron.UI/src/app/router.tsx @@ -1,23 +1,39 @@ import { - createRootRoute, + createRootRouteWithContext, createRoute, createRouter, + redirect, type RouterHistory, } from '@tanstack/react-router'; +import type { ApiClient } from '../api/client'; +import { LoginPage } from '../features/login/LoginPage'; import { PlaceholderPage } from '../features/placeholder/PlaceholderPage'; -const rootRoute = createRootRoute(); +interface RouterContext { + api: ApiClient; +} + +const rootRoute = createRootRouteWithContext()(); const placeholderRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', + beforeLoad: ({ context }) => { + if (!context.api.isSignedIn()) throw redirect({ to: '/login' }); + }, component: PlaceholderPage, }); -const routeTree = rootRoute.addChildren([placeholderRoute]); +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}); + +const routeTree = rootRoute.addChildren([placeholderRoute, loginRoute]); -export function createAppRouter(history?: RouterHistory) { - return createRouter({ routeTree, history }); +export function createAppRouter(api: ApiClient, history?: RouterHistory) { + return createRouter({ routeTree, history, context: { api } }); } declare module '@tanstack/react-router' { diff --git a/src/Anichron.UI/src/features/login/LoginPage.test.tsx b/src/Anichron.UI/src/features/login/LoginPage.test.tsx new file mode 100644 index 00000000..981e7d66 --- /dev/null +++ b/src/Anichron.UI/src/features/login/LoginPage.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { HttpResponse } from 'msw'; +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { LoginRequest } from '../../api/login-contract'; +import { axeResults } from '../../test/axe'; +import { loginFails, loginRespondsWith, loginSucceeds, problem } from '../../test/handlers/login'; +import { renderApp } from '../../test/render-app'; +import { server } from '../../test/server'; + +const serverText = 'Server text that is never shown.'; + +async function openLogin() { + const rendered = renderApp('/login'); + await screen.findByRole('heading', { level: 1, name: 'Sign in to Anichron' }); + return rendered; +} + +function signIn(usernameOrEmail: string, password: string) { + fireEvent.change(screen.getByLabelText('Username or email'), { + target: { value: usernameOrEmail }, + }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: password } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); +} + +async function alertSays(message: string) { + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(message); + }); +} + +const submitButton = () => screen.getByRole('button', { name: 'Sign in' }); + +describe('the login screen', () => { + it('is labelled and accessible', async () => { + const { container } = await openLogin(); + + expect(screen.getByLabelText('Username or email')).toHaveAttribute('autocomplete', 'username'); + expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password'); + expect(screen.getByLabelText('Password')).toHaveAttribute('autocomplete', 'current-password'); + expect(await axeResults(container)).toHaveNoViolations(); + }); + + it('signs in with the trimmed username and lands on the signed-in page', async () => { + const sent: LoginRequest[] = []; + server.use(loginSucceeds((request) => sent.push(request))); + const { router, container } = await openLogin(); + + signIn(' mira ', 'correct horse battery staple'); + + expect( + await screen.findByText("You're signed in. Your photo library is on its way."), + ).toBeVisible(); + expect(router.state.location.pathname).toBe('/'); + expect(await axeResults(container)).toHaveNoViolations(); + expect(sent).toEqual([{ usernameOrEmail: 'mira', password: 'correct horse battery staple' }]); + }); + + it('asks for both fields before sending anything', async () => { + const { container } = await openLogin(); + + signIn(' ', ''); + + await waitFor(() => { + expect(screen.getAllByText('Fill in this field.')).toHaveLength(2); + }); + expect(screen.getByLabelText('Username or email')).toHaveAccessibleDescription( + 'Fill in this field.', + ); + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'true'); + expect(await axeResults(container)).toHaveNoViolations(); + }); + + it.each([ + [401, 'invalid_credentials', 'The username, email or password is wrong.'], + [401, 'account_disabled', 'This account is disabled. Ask your administrator to enable it.'], + [500, 'a_code_from_the_future', 'Something went wrong. Try again.'], + ])('shows a %i %s in the user’s language', async (status, code, message) => { + server.use(loginFails(status, problem(status, code))); + const { router } = await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays(message); + expect(screen.queryByText(serverText)).toBeNull(); + expect(router.state.location.pathname).toBe('/login'); + expect(submitButton()).toBeEnabled(); + expect(screen.getByLabelText('Username or email')).toHaveAttribute('aria-invalid', 'false'); + }); + + it.each([ + [ + 'a body that is not JSON', + () => new HttpResponse('Bad gateway', { status: 502 }), + ], + [ + 'a body that is not a problem', + () => HttpResponse.json(['invalid_credentials'], { status: 401 }), + ], + ['a network failure', () => HttpResponse.error()], + ])('shows the generic error for %s', async (_, response) => { + server.use(loginRespondsWith(response)); + await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Something went wrong. Try again.'); + expect(submitButton()).toBeEnabled(); + }); + + it('shows a field error from the API on its field', async () => { + server.use( + loginFails( + 422, + problem(422, 'a_code_from_the_future', { + errors: { password: ['invalid_credentials', 'a_code_from_the_future'], other: ['x'] }, + }), + ), + ); + await openLogin(); + + signIn('mira', 'short'); + + await waitFor(() => { + expect(screen.getByLabelText('Password')).toHaveAccessibleDescription( + 'The username, email or password is wrong.', + ); + }); + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText('Username or email')).not.toHaveAccessibleDescription(); + }); + + it.each([ + ['a single code', 'invalid_credentials'], + ['an empty list', []], + ])('ignores field errors given as %s', async (_, password) => { + server.use(loginFails(422, problem(422, 'invalid_credentials', { errors: { password } }))); + await openLogin(); + + signIn('mira', 'short'); + + await alertSays('The username, email or password is wrong.'); + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('counts down a locked account from Retry-After, then accepts a new attempt', async () => { + server.use(loginFails(429, problem(429, 'account_locked'), { 'Retry-After': '3' })); + const { container } = await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Too many failed attempts. This account is locked for now.'); + expect(screen.getByText('You can try again in 3 seconds.')).toBeVisible(); + expect(submitButton()).toBeDisabled(); + + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(screen.getByText('You can try again in 2 seconds.')).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(screen.getByText('You can try again in 1 second.')).toBeVisible(); + expect(submitButton()).toBeDisabled(); + await act(() => vi.advanceTimersByTimeAsync(1000)); + + expect(screen.queryByText(/You can try again/)).toBeNull(); + expect(submitButton()).toBeEnabled(); + expect(await axeResults(container)).toHaveNoViolations(); + server.use(loginSucceeds()); + fireEvent.click(submitButton()); + expect( + await screen.findByText("You're signed in. Your photo library is on its way."), + ).toBeVisible(); + }); + + it('counts down a rate limit from Retry-After', async () => { + server.use(loginFails(429, problem(429, 'rate_limited'), { 'Retry-After': '60' })); + await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Too many attempts from this device.'); + expect(screen.getByText('You can try again in 60 seconds.')).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(59_000)); + expect(screen.getByText('You can try again in 1 second.')).toBeVisible(); + }); + + it('shows no countdown when Retry-After is missing or unreadable', async () => { + server.use(loginFails(429, problem(429, 'rate_limited'), { 'Retry-After': 'soon' })); + await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Too many attempts from this device.'); + expect(screen.queryByText(/You can try again/)).toBeNull(); + expect(submitButton()).toBeEnabled(); + }); +}); diff --git a/src/Anichron.UI/src/features/login/LoginPage.tsx b/src/Anichron.UI/src/features/login/LoginPage.tsx new file mode 100644 index 00000000..5ef5840b --- /dev/null +++ b/src/Anichron.UI/src/features/login/LoginPage.tsx @@ -0,0 +1,70 @@ +import type { UseFormRegisterReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import type { MessageKey } from './messages'; +import { useLoginForm } from './use-login-form'; + +export function LoginPage() { + const { t } = useTranslation(); + const { register, onSubmit, formError, fieldError, retryIn, blocked } = useLoginForm(); + return ( +
+

{t('login.title')}

+
void onSubmit(event)} noValidate> + + + + + +
+ ); +} + +function FormError({ error, retryIn }: { error: MessageKey | undefined; retryIn: number }) { + const { t } = useTranslation(); + return ( + <> +
{error &&

{t(error)}

}
+ {retryIn > 0 &&

{t('login.retryIn', { count: retryIn })}

} + + ); +} + +interface FieldProps { + label: string; + type?: string; + autoComplete: string; + error: MessageKey | undefined; + registration: UseFormRegisterReturn; +} + +function Field({ label, type = 'text', autoComplete, error, registration }: FieldProps) { + const { t } = useTranslation(); + const errorId = `${registration.name}-error`; + return ( +
+ + + {error &&

{t(error)}

} +
+ ); +} diff --git a/src/Anichron.UI/src/features/login/messages.ts b/src/Anichron.UI/src/features/login/messages.ts new file mode 100644 index 00000000..49abb6c4 --- /dev/null +++ b/src/Anichron.UI/src/features/login/messages.ts @@ -0,0 +1,15 @@ +import type en from '../../i18n/locales/en.json'; + +const keys = new Map([ + ['required', 'login.errors.required'], + ['invalid_credentials', 'login.errors.invalidCredentials'], + ['account_disabled', 'login.errors.accountDisabled'], + ['account_locked', 'login.errors.accountLocked'], + ['rate_limited', 'login.errors.rateLimited'], +]); + +export type MessageKey = `login.errors.${keyof typeof en.login.errors}`; + +export function messageKey(code: string | undefined): MessageKey { + return keys.get(code) ?? 'login.errors.generic'; +} diff --git a/src/Anichron.UI/src/features/login/use-login-form.ts b/src/Anichron.UI/src/features/login/use-login-form.ts new file mode 100644 index 00000000..7b01e37d --- /dev/null +++ b/src/Anichron.UI/src/features/login/use-login-form.ts @@ -0,0 +1,50 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate, useRouter } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod/mini'; +import { fieldCodes } from '../../api/problem'; +import { useCountdown } from '../../shared/use-countdown'; +import { messageKey } from './messages'; + +const required = z.minLength(1, { error: 'required' }); + +const loginSchema = z.object({ + usernameOrEmail: z.string().check(z.trim(), required), + password: z.string().check(required), +}); + +type LoginFields = z.infer; + +const fields = ['usernameOrEmail', 'password'] as const; + +export function useLoginForm() { + const { api } = useRouter().options.context; + const navigate = useNavigate(); + const countdown = useCountdown(); + const { register, handleSubmit, setError, formState } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = handleSubmit(async (values) => { + const outcome = await api.login(values); + if (outcome.ok) { + await navigate({ to: '/' }); + return; + } + setError('root', { message: outcome.problem.code }); + for (const [field, code] of fieldCodes(outcome.problem, fields)) + setError(field, { message: code }); + countdown.start(outcome.problem.retryAfterSeconds); + }); + + const { errors, isSubmitting } = formState; + return { + register, + onSubmit, + formError: errors.root && messageKey(errors.root.message), + fieldError: (field: (typeof fields)[number]) => + errors[field] && messageKey(errors[field].message), + retryIn: countdown.remaining, + blocked: isSubmitting || countdown.remaining > 0, + }; +} diff --git a/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx b/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx index 462d9341..8e64b72e 100644 --- a/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx +++ b/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx @@ -1,14 +1,15 @@ import { screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { axeResults } from '../../test/axe'; import { renderApp } from '../../test/render-app'; -describe('the placeholder page', () => { - it('greets the user in English at the root', async () => { - const { container } = renderApp('/'); +describe('the signed-in placeholder', () => { + it('sends a visitor without a token to the login screen', async () => { + const { router } = renderApp('/'); - expect(await screen.findByRole('heading', { level: 1, name: 'Anichron' })).toBeVisible(); - expect(screen.getByText('Your photo library is on its way.')).toBeVisible(); - expect(await axeResults(container)).toHaveNoViolations(); + expect( + await screen.findByRole('heading', { level: 1, name: 'Sign in to Anichron' }), + ).toBeVisible(); + expect(router.state.location.pathname).toBe('/login'); + expect(screen.queryByText("You're signed in. Your photo library is on its way.")).toBeNull(); }); }); diff --git a/src/Anichron.UI/src/i18n/locales/en.json b/src/Anichron.UI/src/i18n/locales/en.json index 87f67e71..9322bb2e 100644 --- a/src/Anichron.UI/src/i18n/locales/en.json +++ b/src/Anichron.UI/src/i18n/locales/en.json @@ -1,6 +1,22 @@ { "placeholder": { "title": "Anichron", - "body": "Your photo library is on its way." + "body": "You're signed in. Your photo library is on its way." + }, + "login": { + "title": "Sign in to Anichron", + "usernameOrEmail": "Username or email", + "password": "Password", + "submit": "Sign in", + "retryIn_one": "You can try again in {{count}} second.", + "retryIn_other": "You can try again in {{count}} seconds.", + "errors": { + "required": "Fill in this field.", + "invalidCredentials": "The username, email or password is wrong.", + "accountDisabled": "This account is disabled. Ask your administrator to enable it.", + "accountLocked": "Too many failed attempts. This account is locked for now.", + "rateLimited": "Too many attempts from this device.", + "generic": "Something went wrong. Try again." + } } } diff --git a/src/Anichron.UI/src/shared/use-countdown.ts b/src/Anichron.UI/src/shared/use-countdown.ts new file mode 100644 index 00000000..1a28c44e --- /dev/null +++ b/src/Anichron.UI/src/shared/use-countdown.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +const secondsUntil = (deadline: number) => Math.ceil((deadline - Date.now()) / 1000); + +export function useCountdown() { + const [deadline, setDeadline] = useState(); + const [remaining, setRemaining] = useState(0); + + useEffect(() => { + if (deadline === undefined) return undefined; + const timer = setInterval(() => { + const left = Math.max(0, secondsUntil(deadline)); + setRemaining(left); + if (left === 0) setDeadline(undefined); + }, 1000); + return () => { + clearInterval(timer); + }; + }, [deadline]); + + return { + remaining, + start(seconds: number) { + setDeadline(Date.now() + seconds * 1000); + setRemaining(seconds); + }, + }; +} diff --git a/src/Anichron.UI/src/test/handlers/login.ts b/src/Anichron.UI/src/test/handlers/login.ts new file mode 100644 index 00000000..b0f6b583 --- /dev/null +++ b/src/Anichron.UI/src/test/handlers/login.ts @@ -0,0 +1,36 @@ +// Hand-written until #347 generates the API types; delete together with src/api/login-contract.ts. +import { http, HttpResponse } from 'msw'; +import type { LoginRequest, LoginResponse } from '../../api/login-contract'; + +const loginPath = '/api/v1/auth/login'; + +export function loginSucceeds(onRequest: (request: LoginRequest) => void = () => undefined) { + return http.post(loginPath, async ({ request }) => { + if (request.headers.get('Content-Type') !== 'application/json') + return HttpResponse.json(problem(415, 'unsupported_media_type'), { status: 415 }); + onRequest(await request.json()); + return HttpResponse.json({ accessToken: 'header.payload.signature' }); + }); +} + +export function loginFails(status: number, body: object, headers: Record = {}) { + return http.post(loginPath, () => + HttpResponse.json(body, { + status, + headers: { 'Content-Type': 'application/problem+json', ...headers }, + }), + ); +} + +export function problem(status: number, code: string, extra: object = {}) { + return { + status, + detail: 'Server text that is never shown.', + code, + ...extra, + }; +} + +export function loginRespondsWith(response: () => Response) { + return http.post(loginPath, response); +} diff --git a/src/Anichron.UI/src/test/render-app.tsx b/src/Anichron.UI/src/test/render-app.tsx index 03928094..d88efec3 100644 --- a/src/Anichron.UI/src/test/render-app.tsx +++ b/src/Anichron.UI/src/test/render-app.tsx @@ -1,10 +1,14 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { render } from '@testing-library/react'; +import { createApiClient } from '../api/client'; import { App } from '../app/App'; import { createAppRouter } from '../app/router'; import { createI18n } from '../i18n/i18n'; export function renderApp(path: string) { - const router = createAppRouter(createMemoryHistory({ initialEntries: [path] })); - return render(); + const router = createAppRouter( + createApiClient(), + createMemoryHistory({ initialEntries: [path] }), + ); + return { ...render(), router }; } diff --git a/src/docker-compose.e2e.yml b/src/docker-compose.e2e.yml index b5e018c9..6c719eda 100644 --- a/src/docker-compose.e2e.yml +++ b/src/docker-compose.e2e.yml @@ -40,6 +40,7 @@ services: - Jwt__Secret=end-to-end-only-secret-at-least-32-bytes-long - Jwt__Issuer=anichron - Jwt__Audience=anichron + - RateLimit__SensitivePermitLimit=10000 healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/healthz || exit 1"] interval: 2s