Skip to content
Open
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
12 changes: 12 additions & 0 deletions e2e/storage/connection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ test.describe('Storage S3 — Connection', () => {
await expect(page.getByText('Access denied — check your credentials.')).toBeVisible();
});

test('a cleared port field means the default port, not a validation error', async ({ page }) => {
await openConnectForm(page);
const port = page.getByLabel('Port');
await port.fill('9000');
await port.fill('');
await page.getByRole('button', { name: 'Connect', exact: true }).click();

// The empty host still fails, so validation has run.
await expect(page.getByText('Host is required')).toBeVisible();
await expect(page.getByText(/Port must be|Too small/)).toHaveCount(0);
});

test('disconnects from Garage S3', async ({ page }) => {
const credentials = requireGarageCredentials();

Expand Down
239 changes: 238 additions & 1 deletion e2e/storage/connections-management.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { test, expect, type Page } from '../support/fixtures.js';
import { hasGarageCredentials, requireGarageCredentials } from '../support/garage.js';
import {
createGarageBucketCredentials,
hasGarageAdmin,
hasGarageCredentials,
requireGarageCredentials
} from '../support/garage.js';
import { connectToStorage, openConnectForm } from './helpers.js';
import { waitForHydration } from '../support/helpers.js';

const PORT_1_ERROR = 'Could not reach the storage backend — check the host and port.';

/** Open the Options context menu for the first connection row and click Edit. */
async function openFirstConnectionEditPage(page: Page) {
await page.locator('table tbody tr').first().waitFor();
Expand Down Expand Up @@ -149,6 +156,22 @@ test.describe('Storage — Connections management', () => {
await expect(page.getByText('Access denied — check your credentials.')).toBeVisible();
});

test('edit page tests the connection when only the endpoint changes', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
await page.goto('/settings/connections');
await waitForHydration(page);

await openFirstConnectionEditPage(page);

// Credentials stay blank (keep the stored ones); nothing listens on port 1.
await page.getByLabel('Port').fill('1');
await page.getByRole('button', { name: 'Save changes' }).click();

await expect(page.getByText(PORT_1_ERROR)).toBeVisible();
await expect(page).toHaveURL(/\/edit$/);
});

test('delete button on management page removes the connection after confirmation', async ({
page
}) => {
Expand All @@ -173,6 +196,27 @@ test.describe('Storage — Connections management', () => {
await expect(dataRows).toHaveCount(initialItems - 1);
});

test('sidebar on the edit page deletes a connection', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
// Disconnecting shows the saved connections sidebar; navigate from there so it stays filled.
await page.getByRole('button', { name: 'Disconnect' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click();
await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible();
const savedList = page.getByRole('list', { name: 'Saved connections' });
await savedList.getByRole('button', { name: 'More options' }).first().click({ force: true });
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await waitForHydration(page);
await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible();

await savedList.getByRole('button', { name: 'More options' }).first().click({ force: true });
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click();

await expect(page).toHaveURL('/settings/connections');
await expect(page.locator('table tbody tr')).toHaveCount(0);
});

test('redirects to /settings/connections when editing a non-existent id', async ({ page }) => {
await page.goto('/settings/connections/00000000-0000-0000-0000-000000000000/edit');
await waitForHydration(page);
Expand Down Expand Up @@ -231,6 +275,40 @@ test.describe('Storage — Connections management', () => {
await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible();
});

test('edit page without changes leaves without asking', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
await page.goto('/settings/connections');
await waitForHydration(page);

await openFirstConnectionEditPage(page);
await page.getByRole('link', { name: '← Manage connections' }).click();

await expect(page).toHaveURL('/settings/connections');
await expect(page.getByRole('dialog')).toHaveCount(0);
});

test('unsaved-changes modal: Escape stays and the guard asks again', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
await page.goto('/settings/connections');
await waitForHydration(page);

await openFirstConnectionEditPage(page);

await page.getByLabel('Connection name').fill('Changed name');
const backLink = page.getByRole('link', { name: '← Manage connections' });
await backLink.click();
await expect(page.getByRole('dialog')).toBeVisible();

await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page).toHaveURL(/\/edit$/);

await backLink.click();
await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible();
});

test('unsaved-changes modal: Leave navigates away', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
Expand All @@ -248,3 +326,162 @@ test.describe('Storage — Connections management', () => {
await expect(page).toHaveURL('/settings/connections');
});
});

test.describe('Storage — Connection edit sidebar', () => {
test.use({ locale: 'en-US' });

test.beforeEach(() => {
test.skip(
!hasGarageCredentials() || !hasGarageAdmin(),
'Skipped: requires a running Garage instance with admin access'
);
});

/**
* Saves two connections that differ in their access key, then opens the edit page of the most
* recent one from the saved connections sidebar, which stays filled when navigated from there.
* Returns the access keys in sidebar order.
*/
async function openEditWithTwoConnections(page: Page): Promise<[string, string]> {
const base = requireGarageCredentials();
const second = await createGarageBucketCredentials(base, {
bucketName: 'e2e-connection-edit-sidebar',
keyName: 'e2e-connection-edit-sidebar',
permissions: { owner: false, read: true, write: false }
});
await connectToStorage(page, base);
await connectToStorage(page, second, { clearSaved: false });

await page.getByRole('button', { name: 'Disconnect' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click();
await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible();
const savedList = page.getByRole('list', { name: 'Saved connections' });
await expect(savedList.getByRole('listitem')).toHaveCount(2);
await savedList.getByRole('button', { name: 'More options' }).first().click({ force: true });
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await waitForHydration(page);
await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible();

// Most recently saved first.
return [second.accessKeyId, base.accessKeyId];
}

function sidebarConnection(page: Page, index: number) {
return page
.getByRole('list', { name: 'Saved connections' })
.getByRole('listitem')
.nth(index)
.getByRole('button')
.first();
}

async function deleteFromSidebar(page: Page, index: number) {
await page
.getByRole('list', { name: 'Saved connections' })
.getByRole('button', { name: 'More options' })
.nth(index)
.click({ force: true });
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click();
}

test('switching connections loads the other connection without asking', async ({ page }) => {
const [firstKey, secondKey] = await openEditWithTwoConnections(page);
await expect(page.getByLabel('Access key')).toHaveValue(firstKey);
const firstUrl = page.url();

await sidebarConnection(page, 1).click();

await expect(page).not.toHaveURL(firstUrl);
await expect(page.getByLabel('Access key')).toHaveValue(secondKey);
await expect(page.getByRole('dialog')).toHaveCount(0);

// The switch must not leave the form tainted.
await page.getByRole('link', { name: '← Manage connections' }).click();
await expect(page).toHaveURL('/settings/connections');
await expect(page.getByRole('dialog')).toHaveCount(0);
});

test('switching connections with unsaved changes asks first', async ({ page }) => {
await openEditWithTwoConnections(page);
const firstUrl = page.url();

await page.getByLabel('Connection name').fill('Changed name');
await sidebarConnection(page, 1).click();

await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Stay on page' }).click();
await expect(page).toHaveURL(firstUrl);
await expect(page.getByLabel('Connection name')).toHaveValue('Changed name');
});

test('sidebar delete with unsaved changes asks in the app before deleting', async ({ page }) => {
await openEditWithTwoConnections(page);
let deleteRequests = 0;
let nativeDialogs = 0;
page.on('request', (request) => {
if (request.url().endsWith('?/deleteConnection')) deleteRequests++;
});
page.on('dialog', (dialog) => {
nativeDialogs++;
void dialog.dismiss();
});

await page.getByLabel('Connection name').fill('Changed name');
await deleteFromSidebar(page, 1);

await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible();
await page.getByRole('button', { name: 'Stay on page' }).click();
await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeHidden();
await expect(page).toHaveURL(/\/edit$/);
expect(deleteRequests).toBe(0);

await deleteFromSidebar(page, 1);
await page.getByRole('button', { name: 'Leave' }).click();

await expect(page).toHaveURL('/settings/connections');
await expect(page.locator('table tbody tr')).toHaveCount(1);
expect(deleteRequests).toBe(1);
expect(nativeDialogs).toBe(0);
});

test('sidebar delete without changes deletes without asking', async ({ page }) => {
await openEditWithTwoConnections(page);

await deleteFromSidebar(page, 1);

await expect(page).toHaveURL('/settings/connections');
await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toHaveCount(0);
await expect(page.locator('table tbody tr')).toHaveCount(1);

// The sidebar on the next edit page no longer lists the deleted connection.
await openFirstConnectionEditPage(page);
await expect(
page.getByRole('list', { name: 'Saved connections' }).getByRole('listitem')
).toHaveCount(1);
});

test('a failed sidebar delete shows the error and keeps the unsaved changes', async ({
page
}) => {
await openEditWithTwoConnections(page);
// An invalid ID makes the action fail.
await page.route(/\?\/deleteConnection$/, (route) =>
route.continue({ postData: 'connectionId=not-a-uuid' })
);

await page.getByLabel('Connection name').fill('Changed name');
await deleteFromSidebar(page, 1);
await page.getByRole('button', { name: 'Leave' }).click();

await expect(
page.getByRole('alert').filter({ hasText: 'Connection not found.' })
).toBeVisible();
await expect(page).toHaveURL(/\/edit$/);
await expect(page.getByLabel('Connection name')).toHaveValue('Changed name');

// The changes are still guarded.
await page.getByRole('link', { name: '← Manage connections' }).click();
await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible();
});
});
23 changes: 17 additions & 6 deletions e2e/storage/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async function clearSavedConnections(page: Page) {
.catch(() => false)
)
break;
const before = await items.count();
// Click the last button in the first list item. On desktop this is the
// "more options" button (opacity-0, so use force). On mobile it's the
// X delete button which submits the form directly.
Expand All @@ -65,9 +66,15 @@ async function clearSavedConnections(page: Page) {
// Confirm in the modal
await page.getByRole('button', { name: 'Delete', exact: true }).click();
}
// The form action POSTs and the server redirects back to /storage;
// wait for the page to re-hydrate before checking the list again.
await waitForHydration(page);
// The delete is sent without a page reload: wait until the list has changed. The last
// delete empties it (desktop shows a placeholder, mobile hides the list).
await expect(async () => {
const changed =
!(await savedList.isVisible()) ||
(await savedList.getByText('No saved connections yet').isVisible()) ||
(await items.count()) < before;
expect(changed).toBe(true);
}).toPass();
}
}

Expand Down Expand Up @@ -103,14 +110,18 @@ export async function openConnectForm(page: Page, { clearSaved = true } = {}) {
}
}

export async function connectToStorage(page: Page, credentials: GarageCredentials) {
await openConnectForm(page);
export async function connectToStorage(
page: Page,
credentials: GarageCredentials,
{ clearSaved = true } = {}
) {
await openConnectForm(page, { clearSaved });
const url = new URL(credentials.endpoint);
const host = url.hostname;
const port = url.port;
const useTls = url.protocol === 'https:';

await page.getByLabel('Host').fill(host);
await page.getByLabel('Host', { exact: true }).fill(host);
if (port) {
await page.getByLabel('Port').fill(port);
}
Expand Down
11 changes: 10 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,20 @@
"storage_connect_timeout": "Zeitüberschreitung der Verbindung. Das Speicher-Backend hat nicht geantwortet.",
"storage_connect_cancel": "Abbrechen",
"storage_connect_error": "Verbindung fehlgeschlagen — Endpunkt und Zugangsdaten prüfen.",
"storage_connect_error_unreachable": "Verbindung zum ausgewählten Speicher nicht möglich — der Endpunkt ist möglicherweise nicht erreichbar.",
"storage_connection_switch_failed": "Wechsel zur ausgewählten Verbindung nicht möglich. Bitte erneut versuchen.",
"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.",
"storage_connect_error_network": "Speicher-Backend nicht erreichbar — Host und Port prüfen.",
"storage_connect_error_hdfs": "HDFS-Verbindungen werden noch nicht unterstützt.",
"storage_connect_save_failed": "Die Verbindung konnte nicht gespeichert werden. Bitte erneut versuchen.",
"storage_connect_activate_failed": "Die Verbindung wurde gespeichert, konnte aber nicht aktiviert werden. Laden Sie die Seite neu und wählen Sie sie in den gespeicherten Verbindungen aus.",
"storage_connect_host_required": "Host ist erforderlich",
"storage_connect_host_invalid": "Host muss ein gültiger Hostname oder eine gültige IP-Adresse sein",
"storage_connect_port_invalid": "Port muss eine ganze Zahl zwischen 1 und 65535 sein",
"storage_connect_region_required": "Region ist erforderlich",
"storage_connect_access_key_required": "Zugriffsschlüssel ist erforderlich, wenn ein geheimer Schlüssel angegeben ist",
"storage_connect_secret_key_required": "Geheimer Schlüssel ist erforderlich, wenn ein Zugriffsschlüssel angegeben ist",
"storage_connect_saved": "Gespeicherte Verbindungen",
"storage_connect_no_saved": "Noch keine gespeicherten Verbindungen",
"storage_connect_forget": "Löschen",
Expand Down Expand Up @@ -202,6 +210,7 @@
"storage_connections_delete_confirm": "\"{label}\" aus den gespeicherten Verbindungen entfernen? Dies kann nicht rückgängig gemacht werden.",
"storage_connections_delete_cancel": "Abbrechen",
"storage_connections_delete_confirm_button": "Löschen",
"storage_connections_delete_failed": "Die Verbindung konnte nicht gelöscht werden. Bitte erneut versuchen.",
"storage_connection_edit_title": "Verbindung bearbeiten",
"storage_connection_edit_name": "Verbindungsname",
"storage_connection_edit_name_placeholder": "z. B. Produktions-Garage",
Expand Down
11 changes: 10 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,20 @@
"storage_connect_timeout": "Connection timed out. The storage backend did not respond.",
"storage_connect_cancel": "Cancel",
"storage_connect_error": "Could not connect — check the endpoint and credentials.",
"storage_connect_error_unreachable": "Could not connect to the selected storage — the endpoint may be unreachable.",
"storage_connection_switch_failed": "Could not switch to the selected connection. Please try again.",
"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.",
"storage_connect_error_network": "Could not reach the storage backend — check the host and port.",
"storage_connect_error_hdfs": "HDFS connections are not yet supported.",
"storage_connect_save_failed": "Could not save the connection. Please try again.",
"storage_connect_activate_failed": "The connection was saved but could not be activated. Reload the page and select it from the saved connections.",
"storage_connect_host_required": "Host is required",
"storage_connect_host_invalid": "Host must be a valid hostname or IP address",
"storage_connect_port_invalid": "Port must be a whole number between 1 and 65535",
"storage_connect_region_required": "Region is required",
"storage_connect_access_key_required": "Access key is required when a secret key is provided",
"storage_connect_secret_key_required": "Secret key is required when an access key is provided",
"storage_connect_saved": "Saved connections",
"storage_connect_no_saved": "No saved connections yet",
"storage_connect_forget": "Delete",
Expand Down Expand Up @@ -202,6 +210,7 @@
"storage_connections_delete_confirm": "Remove “{label}” from saved connections? This cannot be undone.",
"storage_connections_delete_cancel": "Cancel",
"storage_connections_delete_confirm_button": "Delete",
"storage_connections_delete_failed": "Could not delete the connection. Please try again.",
"storage_connection_edit_title": "Edit connection",
"storage_connection_edit_name": "Connection name",
"storage_connection_edit_name_placeholder": "e.g. Production Garage",
Expand Down
Loading
Loading