Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions e2e/error-pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect } from './support/fixtures.js';
import { waitForHydration } from './support/helpers';

test.describe('Error pages', () => {
test.use({ locale: 'en-US' });

test('an unknown page shows a readable 404 inside the app layout', async ({ page }) => {
const response = await page.goto('/does-not-exist');
expect(response?.status()).toBe(404);
await waitForHydration(page);

await expect(page).toHaveTitle('Page not found | Stackable');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Page not found');
const main = page.locator('main');
await expect(main.getByRole('heading', { name: /Page not found/ })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();

await main.getByRole('link', { name: 'Go to dashboard' }).click();
await expect(page).toHaveURL('/');
});

test('an unknown API path answers with a JSON error', async ({ page }) => {
const res = await page.request.get('/api/does-not-exist');

expect(res.status()).toBe(404);
expect(await res.json()).toEqual({ message: 'Not Found' });
});

test.describe('signed out', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('unknown pages still require sign-in', async ({ page }) => {
for (const path of ['/does-not-exist', '/auth/login/does-not-exist']) {
await page.goto(path);
await expect(page).toHaveURL(/\/auth\/login\?redirectTo=/);
}
});

test('only the exact public routes skip sign-in', async ({ page }) => {
for (const path of ['/metrics', '/healthz']) {
expect((await page.request.get(path)).status(), path).toBe(200);
}
const lookalike = await page.request.get('/metricsfoo', { maxRedirects: 0 });
expect(lookalike.status()).toBe(302);
expect(lookalike.headers().location).toMatch(/^\/auth\/login\?redirectTo=/);
});

test('unknown API paths still answer with a JSON 401', async ({ page }) => {
const res = await page.request.get('/api/does-not-exist');

expect(res.status()).toBe(401);
expect(await res.json()).toEqual({ message: 'Authentication required' });
});
});
});
17 changes: 17 additions & 0 deletions e2e/storage/connection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ test.describe('Storage S3 — Connection', () => {
await expect(page.getByText('Access denied — check your credentials.')).toBeVisible();
});

test('explains why an active connection cannot be opened', async ({ page }) => {
const credentials = requireGarageCredentials();

await connectToStorage(page, credentials);
await expect(page.locator('main').getByRole('heading', { name: 'Buckets' })).toBeVisible();

await page.route('**/api/storage/buckets', (route) =>
route.fulfill({ status: 502, json: { message: 'Storage error: unreachable' } })
);
await page.reload();

await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible();
await expect(
page.getByText('Could not connect to the selected storage — the endpoint may be unreachable.')
).toBeVisible();
});

test('a cleared port field means the default port, not a validation error', async ({ page }) => {
await openConnectForm(page);
const port = page.getByLabel('Port');
Expand Down
20 changes: 20 additions & 0 deletions e2e/storage/connections-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,26 @@ test.describe('Storage — Connections management', () => {
await waitForHydration(page);
await expect(page).toHaveURL('/settings/connections');
});

test('shows a translated error when deleting a connection fails', async ({ page }) => {
await page.goto('/settings/connections');
await waitForHydration(page);

// The UI only submits valid IDs; post a malformed one directly.
await page.evaluate(() => {
const form = document.createElement('form');
form.method = 'POST';
form.action = '?/deleteConnection';
const input = document.createElement('input');
input.name = 'connectionId';
input.value = 'not-a-uuid';
form.append(input);
document.body.append(form);
form.submit();
});

await expect(page.getByRole('alert')).toHaveText('Connection not found.');
});
});

test.describe('Storage — Connection edit sidebar', () => {
Expand Down
27 changes: 27 additions & 0 deletions e2e/storage/details.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ test.describe('Storage S3 — Details Modal', () => {
}
});

test('explains a failed size calculation', async ({ page }, testInfo) => {
const credentials = requireGarageCredentials();
const client = createS3Client(credentials);
const prefix = uniquePrefix(testInfo, 'details-size-error');
const cleanupKeys = [`${prefix}subdir/file.txt`];

try {
await putTextObject(client, credentials.bucket, cleanupKeys[0], 'x');
// A proxy in front of the app answers with its own HTML error page.
await page.route('**/api/storage/directory-size?*', (route) =>
route.fulfill({ status: 502, contentType: 'text/html', body: '<html>Bad Gateway</html>' })
);

await connectAndOpenPrefix(page, credentials, prefix);
await rowByName(page, 'subdir').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Details' }).click();

const modal = modalBox(page);
await modal.getByRole('button', { name: 'Calculate directory size' }).click();
await expect(
modal.getByText('Failed to calculate the directory size. Please try again.')
).toBeVisible();
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});

test('shows partial results when the key limit is reached', async ({ page }, testInfo) => {
const credentials = requireGarageCredentials();
const client = createS3Client(credentials);
Expand Down
21 changes: 21 additions & 0 deletions e2e/trino/catalog-browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ test.describe('Catalog browser', () => {
await expect(browser.getByRole('button', { name: 'system' })).toBeVisible();
});

test('shows a readable message when loading catalogues fails', async ({ page }) => {
await ensureCatalogBrowserOpen(page);
const browser = page.getByRole('navigation', { name: 'Catalog browser' });
await expect(browser.getByRole('button', { name: 'tpch' })).toBeVisible();

await page.route('**/api/trino/catalog?*', (route) =>
route.fulfill({ status: 502, json: { message: 'Trino is not reachable' } })
);
await page.getByRole('button', { name: 'Refresh catalog' }).click();

await expect(browser.getByText('Failed to load catalog')).toBeVisible();
await expect(browser.getByText('Trino is not reachable')).toBeVisible();
await expect(browser.getByText('{')).toHaveCount(0);

await page.unroute('**/api/trino/catalog?*');
await page.route('**/api/trino/catalog?*', (route) => route.abort());
await page.getByRole('button', { name: 'Refresh catalog' }).click();

await expect(browser.getByText('The server could not be reached.')).toBeVisible();
});

test('catalog browser can be toggled', async ({ page }) => {
await ensureCatalogBrowserOpen(page);

Expand Down
2 changes: 1 addition & 1 deletion e2e/trino/query-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ test.describe('Trino API', () => {
});

expect(res.status()).toBe(400);
expect(await res.json()).toEqual({ error: expect.any(String) });
expect(await res.json()).toEqual({ message: expect.any(String) });
});

test('cancelling between statements stops the script', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/trino/trino.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test.describe('Trino query editor', () => {
// Force the submit endpoint to fail with a server-provided reason.
await page.route('**/api/trino/query', async (route, request) => {
if (request.method() === 'POST') {
await route.fulfill({ status: 400, json: { error: 'Could not reach Trino' } });
await route.fulfill({ status: 400, json: { message: 'Could not reach Trino' } });
} else {
await route.continue();
}
Expand Down
14 changes: 13 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
"sidebar_favourites": "Favoriten",
"sidebar_tools": "Werkzeuge",
"skip_to_content": "Zum Hauptinhalt springen",
"error_status": "Fehler {status}",
"error_not_found_title": "Seite nicht gefunden",
"error_not_found_desc": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
"error_forbidden_title": "Zugriff verweigert",
"error_forbidden_desc": "Sie haben keine Berechtigung, diese Seite anzuzeigen.",
"error_generic_title": "Etwas ist schiefgelaufen",
"error_generic_desc": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.",
"error_request_id": "Anfrage-ID:",
"error_back_to_dashboard": "Zum Dashboard",
"error_retry": "Erneut versuchen",
"error_server_unreachable": "Der Server ist nicht erreichbar. Prüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
"header_open_nav": "Navigation öffnen",
"header_close_nav": "Navigation schließen",
"header_user_menu": "Benutzermenü",
Expand Down Expand Up @@ -168,6 +179,7 @@
"storage_connect_cancel": "Abbrechen",
"storage_connect_error": "Verbindung fehlgeschlagen — Endpunkt und Zugangsdaten prüfen.",
"storage_connection_switch_failed": "Wechsel zur ausgewählten Verbindung nicht möglich. Bitte erneut versuchen.",
"storage_connect_error_unreachable": "Verbindung zum ausgewählten Speicher nicht möglich — der Endpunkt ist möglicherweise nicht erreichbar.",
"storage_connect_error_access_denied": "Zugriff verweigert — Zugangsdaten prüfen.",
"storage_connect_error_not_found": "Speicher-Backend nicht gefunden — Endpunkt prüfen.",
"storage_connect_error_server_error": "Das Speicher-Backend hat einen Fehler gemeldet — Endpunkt und Zugangsdaten prüfen.",
Expand Down Expand Up @@ -639,7 +651,7 @@
"storage_details_error_fetch_file": "Dateidetails konnten nicht abgerufen werden ({status})",
"storage_details_error_fetch_bucket": "Bucket-Details konnten nicht abgerufen werden ({status})",
"storage_details_error_fetch_dir_meta": "Verzeichnismetadaten konnten nicht abgerufen werden ({status})",
"storage_details_error_calc_size": "Verzeichnisgröße konnte nicht berechnet werden ({status})",
"storage_details_error_calc_size": "Die Verzeichnisgröße konnte nicht berechnet werden. Bitte versuchen Sie es erneut.",
"storage_details_error_unknown": "Unbekannter Fehler",
"storage_details_error_no_body": "Kein Antworttext",
"storage_details_unnamed_rule": "(unbenannt)",
Expand Down
14 changes: 13 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
"sidebar_favourites": "Favourites",
"sidebar_tools": "Tools",
"skip_to_content": "Skip to main content",
"error_status": "Error {status}",
"error_not_found_title": "Page not found",
"error_not_found_desc": "The page you are looking for does not exist or has been moved.",
"error_forbidden_title": "Access denied",
"error_forbidden_desc": "You do not have permission to view this page.",
"error_generic_title": "Something went wrong",
"error_generic_desc": "An unexpected error occurred. Please try again later.",
"error_request_id": "Request ID:",
"error_back_to_dashboard": "Go to dashboard",
"error_retry": "Try again",
"error_server_unreachable": "The server could not be reached. Check your network connection and try again.",
"header_open_nav": "Open navigation",
"header_close_nav": "Close navigation",
"header_user_menu": "User menu",
Expand Down Expand Up @@ -168,6 +179,7 @@
"storage_connect_cancel": "Cancel",
"storage_connect_error": "Could not connect — check the endpoint and credentials.",
"storage_connection_switch_failed": "Could not switch to the selected connection. Please try again.",
"storage_connect_error_unreachable": "Could not connect to the selected storage — the endpoint may be unreachable.",
"storage_connect_error_access_denied": "Access denied — check your credentials.",
"storage_connect_error_not_found": "Could not find the storage backend — check the endpoint.",
"storage_connect_error_server_error": "The storage backend returned an error — check the endpoint and credentials.",
Expand Down Expand Up @@ -639,7 +651,7 @@
"storage_details_error_fetch_file": "Failed to fetch file details ({status})",
"storage_details_error_fetch_bucket": "Failed to fetch bucket details ({status})",
"storage_details_error_fetch_dir_meta": "Failed to fetch directory metadata ({status})",
"storage_details_error_calc_size": "Failed to calculate directory size ({status})",
"storage_details_error_calc_size": "Failed to calculate the directory size. Please try again.",
"storage_details_error_unknown": "Unknown error",
"storage_details_error_no_body": "No response body",
"storage_details_unnamed_rule": "(unnamed)",
Expand Down
31 changes: 31 additions & 0 deletions src/error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>%sveltekit.status% | Stackable</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
body {
display: flex;
min-height: 100dvh;
margin: 0;
align-items: center;
justify-content: center;
text-align: center;
}
</style>
</head>
<body>
<!-- Rendered without the app: for errors thrown in server hooks (e.g. the storage-disabled
404, a failing session lookup) and when the root layout cannot render. -->
<main>
<h1>%sveltekit.status%</h1>
<p>%sveltekit.error.message%</p>
<p><a href="/">Stackable</a></p>
</main>
</body>
</html>
7 changes: 5 additions & 2 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ const handleParaglide: Handle = ({ event, resolve }) =>
const handleAuth: Handle = ({ event, resolve }) =>
svelteKitHandler({ event, resolve, auth, building });

const PUBLIC_PATHS = ['/auth/login', '/auth/logout', '/api/auth', '/metrics', '/healthz'];
// Matched by route id, not path prefix, so `/metricsfoo` or `/auth/login/x`
// (which reaches the (app) catch-all) still require sign-in. `/api/auth/*` is
// answered by better-auth in `handleAuth` before this guard runs.
const PUBLIC_ROUTE_IDS = new Set(['/auth/login', '/auth/logout', '/metrics', '/healthz']);

const handleAuthGuard: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });
event.locals.user = session?.user ?? null;
event.locals.session = session?.session ?? null;

const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
const isPublic = event.route.id !== null && PUBLIC_ROUTE_IDS.has(event.route.id);
if (!isPublic && !event.locals.user) {
event.locals.logger.debug({ path: event.url.pathname }, 'Rejecting unauthenticated request');
return unauthenticatedResponse(event.url);
Expand Down
20 changes: 20 additions & 0 deletions src/lib/client/api-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { readErrorMessage } from './api-error.js';

describe('readErrorMessage', () => {
it('returns the message of an App.Error body', async () => {
const res = Response.json({ message: 'Invalid JSON body' }, { status: 400 });

expect(await readErrorMessage(res)).toBe('Invalid JSON body');
});

it.each([
['an HTML page', 502, '<!doctype html><html></html>'],
['an empty body', 502, ''],
['JSON without a message', 502, '{"error":"nope"}'],
['an expired session', 401, '{"message":"Authentication required"}'],
['an unexpected error', 500, '{"message":"An unexpected error occurred","requestId":"r1"}']
])('returns null for %s', async (_label, status, body) => {
expect(await readErrorMessage(new Response(body, { status }))).toBeNull();
});
});
21 changes: 21 additions & 0 deletions src/lib/client/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Read the message of an API error response.
*
* Every API route answers errors with an `App.Error` body (`{ message }`),
* produced by SvelteKit's `error()`, `handleError` or the auth guard. Returns
* `null` when the message is not worth showing, so callers can fall back to a
* translated message:
* - the body is empty or not JSON, e.g. a proxy's HTML error page;
* - a 401: the session expired and the session-expired modal takes over;
* - an unexpected error: `handleError` only sends a generic English text,
* recognisable by its `requestId`.
*/
export async function readErrorMessage(res: Response): Promise<string | null> {
if (res.status === 401) return null;
const body: unknown = await res
.clone()
.json()
.catch(() => null);
if (body === null || typeof body !== 'object' || 'requestId' in body) return null;
return 'message' in body && typeof body.message === 'string' ? body.message : null;
}
Loading
Loading