2 Commits

Author SHA1 Message Date
536638b594 deploy: bump image tag to v0.1.9
Some checks failed
Release / release (push) Failing after 1m16s
2026-05-02 05:28:30 +02:00
6cb5968b7b 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.
2026-05-02 05:25:04 +02:00
11 changed files with 72 additions and 54 deletions

View File

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

View File

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

View File

@@ -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"

View File

@@ -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<AppState> 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}");

View File

@@ -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<Value> {
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 {

View File

@@ -3,7 +3,7 @@ httpRoute:
- tutor.puchstein.dev
image:
tag: v0.1.8
tag: v0.1.9
env:
extra:

View File

@@ -2,9 +2,10 @@ import { browser } from '$app/environment';
const BASE = '/api';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...init?.headers,

View File

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

View File

@@ -31,16 +31,22 @@
const activePath = $derived($page.url.pathname);
</script>
{#if auth.authenticated}
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
</TutorShell>
{#if auth.initialized}
{#if auth.authenticated}
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
</TutorShell>
{:else}
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
{/if}
{:else}
{@render children()}
<div style="padding: 2rem; text-align: center; font-family: var(--serif);">
Wird geladen...
</div>
{/if}

View File

@@ -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,
}
],

View File

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