From c7cb689984e6ae9ca5cc64804f8c36b9c35e38a2 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 17 May 2026 00:16:21 +0200 Subject: [PATCH] feat: settings page exercises the password-change endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.10.0 backend endpoint had no UI caller — the audit flagged it as either-ship-a-form-or-remove-the-endpoint dead code. Shipping the form, plus the bearer-token-keeps-working regression test the audit asked for to pin the docstring contract. Backend: - New test change_password_via_bearer_leaves_bearer_working asserts that PATCH /me/password called with Authorization: Bearer wipes cookie sessions but leaves the bearer (api_token) intact and usable — matches the docstring claim that bot tokens are opt-in to revoke. Frontend: - lib/api/auth.ts: new changePassword(input) wrapping PATCH /v1/auth/me/password. Vitest covers happy 204, 401 unauthenticated (wrong current), 400 invalid_input (weak new) — same envelope parsing shape used elsewhere. - routes/settings/+page.svelte: minimal form with current / new / confirm fields, derived passwordsMatch + canSubmit guards (submit stays disabled until current is filled, new is ≥8 chars, new == confirm). Shows the API's message inline on failure. Documents the "other devices signed out, bot tokens stay" UX in a short hint. - routes/+layout.svelte: new "Settings" link in the session-aware nav (between username and Logout) for authed users only. - e2e/settings.spec.ts (5 cases): nav link reaches the form, successful change shows confirmation + clears the form, 401 surfaces inline, password mismatch keeps submit disabled, anonymous user gets a sign-in prompt instead of the form. Lockstep version bump to 0.11.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Cargo.toml | 2 +- backend/tests/api_auth.rs | 63 +++++++++ frontend/e2e/settings.spec.ts | 109 +++++++++++++++ frontend/package.json | 2 +- frontend/src/lib/api/auth.test.ts | 33 +++++ frontend/src/lib/api/auth.ts | 22 +++ frontend/src/routes/+layout.svelte | 1 + frontend/src/routes/settings/+page.svelte | 160 ++++++++++++++++++++++ 8 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 frontend/e2e/settings.spec.ts create mode 100644 frontend/src/routes/settings/+page.svelte diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 91ad53b..0e02537 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.10.2" +version = "0.11.0" edition = "2021" [lib] diff --git a/backend/tests/api_auth.rs b/backend/tests/api_auth.rs index cac4b76..8ded505 100644 --- a/backend/tests/api_auth.rs +++ b/backend/tests/api_auth.rs @@ -319,6 +319,69 @@ async fn change_password_rotates_sessions_and_swaps_credentials(pool: PgPool) { assert_eq!(resp.status(), StatusCode::OK); } +#[sqlx::test(migrations = "./migrations")] +async fn change_password_via_bearer_leaves_bearer_working(pool: PgPool) { + // Bot scripts that call PATCH /me/password using Authorization: + // Bearer must keep their bearer working — change_password only + // wipes session rows, not api_tokens. Pin this behaviour so a + // future refactor that wipes everything would fail noisily. + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/auth/tokens", + json!({ "name": "ci-bot" }), + &cookie, + )) + .await + .unwrap(); + let bearer = common::body_json(resp).await["bearer"] + .as_str() + .unwrap() + .to_string(); + + // Use the bearer to change the password. + let resp = h + .app + .clone() + .oneshot({ + let body = json!({ + "current_password": "hunter2hunter2", + "new_password": "freshpassfreshpass" + }); + axum::http::Request::builder() + .method("PATCH") + .uri("/api/v1/auth/me/password") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}")) + .body(axum::body::Body::from(body.to_string())) + .unwrap() + }) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Cookie is dead (all sessions wiped). + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + // Bearer still works — that's the documented contract. + let resp = h + .app + .oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + #[sqlx::test(migrations = "./migrations")] async fn change_password_rejects_wrong_current_with_401(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 0000000..c08a5c1 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,109 @@ +import { test, expect, type Page } from '@playwright/test'; + +const userFixture = { + id: 'u1', + username: 'alice', + created_at: '2026-01-01T00:00:00Z' +}; + +async function stubAuthenticated(page: Page) { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }) + ); +} + +test('settings link shows for authed users and reaches the password form', async ({ page }) => { + await stubAuthenticated(page); + await page.route('**/api/v1/mangas?*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + }) + ); + + await page.goto('/'); + await expect(page.getByTestId('nav-settings')).toBeVisible(); + await page.getByTestId('nav-settings').click(); + await expect(page).toHaveURL(/\/settings$/); + await expect(page.getByTestId('password-form')).toBeVisible(); +}); + +test('changing password shows success and clears the form', async ({ page }) => { + await stubAuthenticated(page); + let patchCalls = 0; + let patchBody: unknown = null; + await page.route('**/api/v1/auth/me/password', async (route) => { + patchCalls += 1; + patchBody = JSON.parse(route.request().postData() ?? '{}'); + await route.fulfill({ status: 204 }); + }); + + await page.goto('/settings'); + await page.getByTestId('current-password').fill('hunter2hunter2'); + await page.getByTestId('new-password').fill('freshpassfreshpass'); + await page.getByTestId('confirm-password').fill('freshpassfreshpass'); + await page.getByTestId('password-submit').click(); + + await expect(page.getByTestId('password-success')).toContainText('Password updated'); + expect(patchCalls).toBe(1); + expect(patchBody).toEqual({ + current_password: 'hunter2hunter2', + new_password: 'freshpassfreshpass' + }); + // Form should clear after success. + await expect(page.getByTestId('current-password')).toHaveValue(''); +}); + +test('wrong current password surfaces the 401 envelope inline', async ({ page }) => { + await stubAuthenticated(page); + await page.route('**/api/v1/auth/me/password', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + + await page.goto('/settings'); + await page.getByTestId('current-password').fill('definitelyNotIt'); + await page.getByTestId('new-password').fill('freshpassfreshpass'); + await page.getByTestId('confirm-password').fill('freshpassfreshpass'); + await page.getByTestId('password-submit').click(); + + await expect(page.getByTestId('password-error')).toBeVisible(); +}); + +test('mismatched new + confirm disables the submit button', async ({ page }) => { + await stubAuthenticated(page); + + await page.goto('/settings'); + await page.getByTestId('current-password').fill('hunter2hunter2'); + await page.getByTestId('new-password').fill('freshpassfreshpass'); + await page.getByTestId('confirm-password').fill('different'); + + await expect(page.getByTestId('mismatch')).toBeVisible(); + await expect(page.getByTestId('password-submit')).toBeDisabled(); +}); + +test('anonymous user sees a sign-in prompt on /settings', async ({ page }) => { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + + await page.goto('/settings'); + await expect(page.getByTestId('settings-signin')).toBeVisible(); + await expect(page.getByTestId('password-form')).toHaveCount(0); +}); diff --git a/frontend/package.json b/frontend/package.json index 806924c..72a468c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.10.2", + "version": "0.11.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/auth.test.ts b/frontend/src/lib/api/auth.test.ts index 4b741d8..7826151 100644 --- a/frontend/src/lib/api/auth.test.ts +++ b/frontend/src/lib/api/auth.test.ts @@ -12,6 +12,7 @@ import { login, logout, me, + changePassword, createToken, deleteToken } from './auth'; @@ -110,6 +111,38 @@ describe('auth api client', () => { await expect(me()).rejects.toMatchObject({ status: 500 }); }); + it('changePassword PATCHes /v1/auth/me/password and handles 204', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await expect( + changePassword({ + current_password: 'hunter2hunter2', + new_password: 'freshpassfreshpass' + }) + ).resolves.toBeUndefined(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/auth\/me\/password$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ + current_password: 'hunter2hunter2', + new_password: 'freshpassfreshpass' + }); + }); + + it('changePassword surfaces 401 (wrong current) via ApiError', async () => { + fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated')); + await expect( + changePassword({ current_password: 'wrong', new_password: 'freshpassfreshpass' }) + ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' }); + }); + + it('changePassword surfaces 400 (weak new) via ApiError', async () => { + fetchSpy.mockResolvedValueOnce(envelope(400, 'invalid_input', 'password must be at least 8 characters')); + await expect( + changePassword({ current_password: 'hunter2hunter2', new_password: 'short' }) + ).rejects.toMatchObject({ status: 400, code: 'invalid_input' }); + }); + it('createToken POSTs to /v1/auth/tokens and returns CreatedToken with bearer', async () => { fetchSpy.mockResolvedValueOnce( ok( diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index dbc0515..bc9f905 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -35,6 +35,28 @@ export async function logout(): Promise { await request('/v1/auth/logout', { method: 'POST' }); } +export type ChangePassword = { + current_password: string; + new_password: string; +}; + +/** + * Rotates the password. Backend signs out every other session for this + * user and mints a fresh cookie for the caller (returned as Set-Cookie, + * applied automatically by the browser). Bot tokens are left alone. + * + * Throws ApiError with `status=401, code='unauthenticated'` for wrong + * `current_password`; `status=400, code='invalid_input'` for a weak new + * password. + */ +export async function changePassword(input: ChangePassword): Promise { + await request('/v1/auth/me/password', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }); +} + /** * Returns the current user, or `null` if no valid session. * Re-throws any non-401 error. diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index b2b7ff4..37e255e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -34,6 +34,7 @@ {:else if session.user} {session.user.username} + Settings diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte new file mode 100644 index 0000000..46fc15e --- /dev/null +++ b/frontend/src/routes/settings/+page.svelte @@ -0,0 +1,160 @@ + + + + Settings — Mangalord + + +

Settings

+ +{#if !session.loaded} +

Loading…

+{:else if !session.user} +

+ Sign in to change your password. +

+{:else} +
+

Change password

+

+ Changing your password signs out every other device using this account. + Bot API tokens keep working — revoke them individually from the bot-token + list if you want to invalidate them too. +

+
+ + + + + {#if success} +

{success}

+ {/if} + {#if error} + + {/if} +
+
+{/if} + +