Compare commits

...

2 commits

Author SHA1 Message Date
Johan Lundberg
2fc2bdcabb
test: allow disabling rate limiting for e2e runs
The full Playwright suite authenticates ~100 times in a few minutes, far
over the login endpoint's 5/minute limit, so most specs failed at the
beforeEach login with 429s.

Add an OIDC_OP_RATE_LIMIT_ENABLED setting (default True) wired to the
slowapi limiter's enabled flag, and set it to false in tests/e2e/run.sh.
Production behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:32:32 +02:00
Johan Lundberg
bcfe3a2a15
fix: keep password form visible on validation error
The password setup/change form used hx-target="#password-section" with
hx-swap="innerHTML", but that div wraps the form itself. On a validation
error the route returns only an alert div, so the swap replaced the entire
form — the password inputs disappeared. Most visible during registration's
"set password" step.

Retarget the form to a dedicated #password-error div outside the form
(mirrors the working login form's #login-error pattern), so the form and
its inputs survive errors while messages still render inside #password-section.

Also fix pre-existing broken e2e tests: they omitted the required
current_password fill and used passwords below the zxcvbn strength
threshold (score 1 < MIN_PASSWORD_STRENGTH=2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:22:01 +02:00
6 changed files with 31 additions and 6 deletions

View file

@ -128,6 +128,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
# Rate limiting
limiter.enabled = settings.rate_limit_enabled
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)

View file

@ -52,6 +52,9 @@ class Settings(BaseSettings):
# Magic links
invite_ttl: int = 86400 # seconds
# Rate limiting (disable for e2e/load tests that authenticate repeatedly)
rate_limit_enabled: bool = True
# Signing keys
signing_key_path: str = "data/keys"

View file

@ -38,7 +38,8 @@
{% else %}
<p>No password set.</p>
{% endif %}
<form hx-post="/manage/credentials/password" hx-target="#password-section" hx-swap="innerHTML">
<div id="password-error"></div>
<form hx-post="/manage/credentials/password" hx-target="#password-error" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token_processor(request) }}">
{% if has_password %}
<div>

View file

@ -42,6 +42,7 @@ test.describe('Credentials page', () => {
test.describe('Password validation', () => {
test('shows mismatch error', async ({ page }) => {
await page.fill('#current_password', fixtures.cred_password);
await page.fill('#password', 'newpassword1');
await page.fill('#confirm', 'newpassword2');
await page.click('#password-section button[type="submit"]');
@ -51,6 +52,23 @@ test.describe('Credentials page', () => {
await expect(alert).toContainText('do not match');
});
test('keeps the password form visible after a validation error', async ({ page }) => {
await page.fill('#current_password', fixtures.cred_password);
await page.fill('#password', 'newpassword1');
await page.fill('#confirm', 'newpassword2');
await page.click('#password-section button[type="submit"]');
const alert = page.locator('#password-section [role="alert"]');
await expect(alert).toBeVisible({ timeout: 5000 });
// Regression: the form and its inputs must NOT disappear on error.
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('#confirm')).toBeVisible();
await expect(
page.locator('#password-section button[type="submit"]'),
).toBeVisible();
});
test('password input has minlength="8"', async ({ page }) => {
await expect(page.locator('#password')).toHaveAttribute('minlength', '8');
});
@ -62,8 +80,9 @@ test.describe('Credentials page', () => {
test.describe('Password change', () => {
test('succeeds with matching passwords', async ({ page }) => {
await page.fill('#password', 'newpassword123');
await page.fill('#confirm', 'newpassword123');
await page.fill('#current_password', fixtures.cred_password);
await page.fill('#password', 'purple-tiger-mountain-42');
await page.fill('#confirm', 'purple-tiger-mountain-42');
await page.click('#password-section button[type="submit"]');
const status = page.locator('#password-section [role="status"]');

View file

@ -30,8 +30,8 @@ test.describe('Full user journey', () => {
await expect(passwordInput).toBeVisible();
await expect(confirmInput).toBeVisible();
await passwordInput.fill('mypassword123');
await confirmInput.fill('mypassword123');
await passwordInput.fill('purple-tiger-mountain-42');
await confirmInput.fill('purple-tiger-mountain-42');
await page.click('#password-section button[type="submit"]');
// Wait for success message
@ -51,7 +51,7 @@ test.describe('Full user journey', () => {
// ---- Step 4: Login with the password we just set ----
await page.fill('#username', fixtures.register_username);
await page.fill('#password', 'mypassword123');
await page.fill('#password', 'purple-tiger-mountain-42');
await page.click('form[hx-post="/login/password"] button[type="submit"]');
// Wait for redirect to credentials page

View file

@ -28,6 +28,7 @@ echo "Starting Porchlight on port ${PORT}..."
echo " DB: ${OIDC_OP_SQLITE_PATH}"
OIDC_OP_ISSUER="${TARGET_URL}" \
OIDC_OP_DEBUG=true \
OIDC_OP_RATE_LIMIT_ENABLED=false \
uv run --directory "$PROJECT_ROOT" \
uvicorn porchlight.app:create_app \
--factory --host 127.0.0.1 --port "$PORT" \