From 6cb5968b7b9018d377be87a477eaafe1305173a6 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Sat, 2 May 2026 05:25:04 +0200 Subject: [PATCH] fix: resolve Docker build failure and E2E authentication race conditions - Dockerfile: Update binary name from attendance to tutortool to fix the release build pipeline failure. - Backend: Expose test_mode in AppState to conditionally disable the secure flag on auth cookies during local E2E testing over HTTP. - Backend: Enable tower-http trace feature and attach TraceLayer for improved request logging. - Frontend: Refactor auth.svelte.ts to a plain reactive object to resolve initialization race conditions during tests. - Frontend: Append cache-busting timestamp to /api/auth/me to prevent stale session states. - Frontend: Update Playwright locator in superadmin.spec.ts for greater resilience. - Makefile: Inject required environment variables (STATIC_DIR, JWT_SECRET) into the test-up target. --- Dockerfile | 2 +- Makefile | 4 +- backend/Cargo.toml | 2 +- backend/src/main.rs | 10 ++++- backend/src/routes/auth_routes.rs | 19 +++++---- frontend/src/lib/api.ts | 3 +- frontend/src/lib/auth.svelte.ts | 52 +++++++++++++----------- frontend/src/routes/admin/+layout.svelte | 28 ++++++++----- frontend/tests/global-setup.ts | 2 +- frontend/tests/superadmin.spec.ts | 2 +- 10 files changed, 71 insertions(+), 53 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1f5e72a..1bae445 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/* RUN useradd -u 1000 -m app WORKDIR /app -COPY --from=backend-builder /app/backend/target/release/attendance ./server +COPY --from=backend-builder /app/backend/target/release/tutortool ./server COPY --from=backend-builder /app/backend/demo ./backend/demo COPY --from=frontend-builder /app/frontend/build ./frontend/build diff --git a/Makefile b/Makefile index dbf09e0..f716fdb 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ test-up: exit 0; \ fi; \ [ -f "$$TT_TEST_DB" ] || $(MAKE) test-rebuild; \ - DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 \ + DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 JWT_SECRET=testsecret STATIC_DIR=frontend/build \ cargo run --manifest-path backend/Cargo.toml &>/tmp/tutortool-test.log & \ echo $$! > data/test/.pid; \ echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \ @@ -91,4 +91,4 @@ test-reset: test-e2e: $(MAKE) test-up - pnpm --dir frontend test:e2e + @. scripts/test-env.sh; cd frontend && pnpm test:e2e diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a90e64c..94d4281 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -13,7 +13,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" jsonwebtoken = { version = "10", features = ["rust_crypto"] } bcrypt = "0.19" -tower-http = { version = "0.6", features = ["fs", "cors"] } +tower-http = { version = "0.6", features = ["fs", "cors", "trace"] } chrono = { version = "0.4", features = ["serde"] } rand = "0.9" thiserror = "2" diff --git a/backend/src/main.rs b/backend/src/main.rs index eb3aea3..16c5247 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -15,6 +15,7 @@ use tracing_subscriber::EnvFilter; pub struct AppState { pub pool: sqlx::SqlitePool, pub jwt_secret: String, + pub test_mode: bool, } impl axum::extract::FromRef for sqlx::SqlitePool { @@ -46,7 +47,11 @@ async fn main() { let pool = db::init().await.expect("db init failed"); db::maybe_seed_demo(&pool).await; - let state = AppState { pool, jwt_secret }; + let state = AppState { + pool, + jwt_secret, + test_mode, + }; let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/build".into()); @@ -54,7 +59,8 @@ async fn main() { .route("/health", get(|| async { "ok" })) .fallback_service( ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))), - ); + ) + .layer(tower_http::trace::TraceLayer::new_for_http()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); let addr = format!("0.0.0.0:{port}"); diff --git a/backend/src/routes/auth_routes.rs b/backend/src/routes/auth_routes.rs index 2511f4e..7f376be 100644 --- a/backend/src/routes/auth_routes.rs +++ b/backend/src/routes/auth_routes.rs @@ -38,22 +38,23 @@ async fn login( .path("/") .http_only(true) .same_site(SameSite::Strict) - .secure(true) // Should be true in prod, but for local dev we might need to be careful. - // Actually, most local setups use http, but we can stick to secure(true) and assume production-first. + .secure(!state.test_mode) .build(); - Ok(( jar.add(cookie), Json(json!({"is_superadmin": is_superadmin})), )) } -async fn me(auth: auth::TutorClaims) -> Json { - Json(json!({ - "id": auth.sub, - "email": auth.email, - "is_superadmin": auth.is_superadmin - })) +async fn me(auth: auth::TutorClaims) -> impl axum::response::IntoResponse { + ( + [("Cache-Control", "no-store")], + Json(json!({ + "id": auth.sub, + "email": auth.email, + "is_superadmin": auth.is_superadmin + })), + ) } async fn logout(jar: CookieJar) -> CookieJar { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 586898e..fc59912 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,9 +2,10 @@ import { browser } from '$app/environment'; const BASE = '/api'; -async function request(path: string, init?: RequestInit): Promise { +export async function request(path: string, init?: RequestInit): Promise { const res = await fetch(BASE + path, { ...init, + credentials: 'include', headers: { 'Content-Type': 'application/json', ...init?.headers, diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index a2bb943..0b5d712 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -2,45 +2,49 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { api } from './api'; -class AuthState { - #isSuperadmin = $state(false); - #initialized = $state(false); - #authenticated = $state(false); +let _isSuperadmin = $state(false); +let _initialized = $state(false); +let _authenticated = $state(false); - get isSuperadmin() { return this.#isSuperadmin; } - get initialized() { return this.#initialized; } - get authenticated() { return this.#authenticated; } +export const auth = { + get isSuperadmin() { return _isSuperadmin; }, + get initialized() { return _initialized; }, + get authenticated() { return _authenticated; }, async init() { - if (!browser || this.#initialized) return; + if (!browser || _initialized) return; try { - const me = await api.auth.me(); - this.#isSuperadmin = me.is_superadmin; - this.#authenticated = true; + const res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' }); + if (res.ok) { + const me = await res.json(); + _isSuperadmin = me.is_superadmin; + _authenticated = true; + } else { + _isSuperadmin = false; + _authenticated = false; + } } catch (e) { - this.#isSuperadmin = false; - this.#authenticated = false; + _isSuperadmin = false; + _authenticated = false; } finally { - this.#initialized = true; + _initialized = true; } - } + }, setAuthenticated(isSuperadmin: boolean) { - this.#isSuperadmin = isSuperadmin; - this.#authenticated = true; - this.#initialized = true; - } + _isSuperadmin = isSuperadmin; + _authenticated = true; + _initialized = true; + }, async logout() { try { await api.auth.logout(); } catch (e) {} - this.#isSuperadmin = false; - this.#authenticated = false; + _isSuperadmin = false; + _authenticated = false; if (browser) { goto('/admin/login'); } } -} - -export const auth = new AuthState(); +}; diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index bec9cc8..557f213 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -31,16 +31,22 @@ const activePath = $derived($page.url.pathname); -{#if auth.authenticated} - - {#snippet children()} - {@render children()} - {/snippet} - +{#if auth.initialized} + {#if auth.authenticated} + + {#snippet children()} + {@render children()} + {/snippet} + + {:else} +
Redirecting to login...
+ {/if} {:else} - {@render children()} +
+ Wird geladen... +
{/if} diff --git a/frontend/tests/global-setup.ts b/frontend/tests/global-setup.ts index 49b3959..24ebace 100644 --- a/frontend/tests/global-setup.ts +++ b/frontend/tests/global-setup.ts @@ -43,7 +43,7 @@ async function globalSetup() { domain: new URL(baseURL).hostname, path: '/', httpOnly: true, - secure: true, + secure: baseURL.startsWith('https'), sameSite: 'Strict' as const, } ], diff --git a/frontend/tests/superadmin.spec.ts b/frontend/tests/superadmin.spec.ts index 92f5adb..14e6945 100644 --- a/frontend/tests/superadmin.spec.ts +++ b/frontend/tests/superadmin.spec.ts @@ -34,7 +34,7 @@ test.describe('Superadmin CRUD & UI Consistency', () => { await expect(page.locator('text=Neuen Kurs anlegen')).toBeVisible(); - const tutorSelect = page.locator('select >> text=+ Hinzufügen').first(); + const tutorSelect = page.locator('select.tiny').first(); await expect(tutorSelect).toBeVisible(); }); });