feat: settings page exercises the password-change endpoint

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 00:16:21 +02:00
parent 49f6d4d213
commit c7cb689984
8 changed files with 390 additions and 2 deletions

View File

@@ -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);