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
25 changes: 20 additions & 5 deletions apps/app-frontend/src/shell/StorageRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Route, Routes } from 'react-router-dom';
import { create } from '@bufbuild/protobuf';
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { StorageBackendSchema } from '@osac/types/private';
import type { MockApiFixtures } from '@osac/ui-components/test-utils/createMockConnectTransport';
import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders';

import { StorageRoutes } from './StorageRoutes';
Expand All @@ -17,12 +20,12 @@ vi.mock('react-router-dom', async (importOriginal) => {
};
});

const renderAt = (path: string) =>
const renderAt = (path: string, apiFixtures?: MockApiFixtures) =>
renderWithProviders(
<Routes>
<Route path="/admin/infrastructure/storage/*" element={<StorageRoutes />} />
</Routes>,
{ routerEntries: [path] },
{ routerEntries: [path], apiFixtures },
);

describe('StorageRoutes', () => {
Expand All @@ -39,10 +42,22 @@ describe('StorageRoutes', () => {
expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
});

it('renders a placeholder for backends/:id/edit', () => {
renderAt('/admin/infrastructure/storage/backends/abc-123/edit');
it('renders the real edit form for backends/:id/edit', async () => {
renderAt('/admin/infrastructure/storage/backends/abc-123/edit', {
storageBackends: [
create(StorageBackendSchema, {
id: 'abc-123',
metadata: { name: 'vast-prod-1' },
spec: { provider: 'vast', endpoint: 'vast.example.com:443', description: '' },
}),
],
});

expect(screen.getByText('Edit storage backend')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Edit storage backend' })).toBeInTheDocument();
});
expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('vast.example.com:443');
expect(screen.queryByText('This feature is coming soon.')).not.toBeInTheDocument();
});

it('renders the Tiers tab at /admin/infrastructure/storage/tiers', async () => {
Expand Down
5 changes: 1 addition & 4 deletions apps/app-frontend/src/shell/StorageRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ export const StorageRoutes = () => {
<Route index element={<Navigate to="backends" replace />} />
<Route path="backends" element={<StorageManagementPage activeTab="backends" />} />
<Route path="backends/create" element={<StorageBackendCreatePage />} />
<Route
path="backends/:id/edit"
element={<StoragePlaceholder title={t('Edit storage backend')} />}
/>
<Route path="backends/:id/edit" element={<StorageBackendCreatePage />} />
<Route path="tiers" element={<StorageManagementPage activeTab="tiers" />} />
<Route path="tiers/create" element={<StorageTierCreatePage />} />
<Route
Expand Down
4 changes: 4 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"Encryption enabled": "Encryption enabled",
"Endpoint": "Endpoint",
"Endpoint is required": "Endpoint is required",
"Enter both username and password, or leave both blank": "Enter both username and password, or leave both blank",
"Error": "Error",
"Error loading external IP pools": "Error loading external IP pools",
"Error loading virtual networks": "Error loading virtual networks",
Expand Down Expand Up @@ -230,6 +231,7 @@
"Failed to fetch instance type": "Failed to fetch instance type",
"Failed to fetch role binding": "Failed to fetch role binding",
"Failed to fetch security groups": "Failed to fetch security groups",
"Failed to fetch storage backend": "Failed to fetch storage backend",
"Failed to fetch subnet": "Failed to fetch subnet",
"Failed to fetch virtual network": "Failed to fetch virtual network",
"Failed to load cluster password": "Failed to load cluster password",
Expand All @@ -243,6 +245,7 @@
"Failed to start virtual machine": "Failed to start virtual machine",
"Failed to stop virtual machine": "Failed to stop virtual machine",
"Failed to update role binding": "Failed to update role binding",
"Failed to update storage backend": "Failed to update storage backend",
"Filter bare metal instances by name": "Filter bare metal instances by name",
"Filter catalog by keyword": "Filter catalog by keyword",
"Filter catalog by resource type": "Filter catalog by resource type",
Expand Down Expand Up @@ -280,6 +283,7 @@
"Issuer is required": "Issuer is required",
"JWKS URL": "JWKS URL",
"Keep editing": "Keep editing",
"Leave blank to keep the current credentials.": "Leave blank to keep the current credentials.",
"Lifecycle state": "Lifecycle state",
"Loading cluster password": "Loading cluster password",
"Loading security groups...": "Loading security groups...",
Expand Down
28 changes: 26 additions & 2 deletions libs/ui-components/src/api/v1/private/storage-backends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,20 +176,22 @@ describe('useUpdateStorageBackend', () => {
it('sends a single spec.endpoint mask entry and never masks metadata.name or spec.provider', async () => {
const captured = await mutateAndCaptureUpdate({
id: 'b-1',
version: 3,
spec: { endpoint: 'new.example.com' },
});

const paths = (captured?.updateMask as { paths?: string[] } | undefined)?.paths;
expect(paths).toEqual(['spec.endpoint']);
expect(paths).not.toContain('metadata.name');
expect(paths).not.toContain('spec.provider');
const object = captured?.object as { metadata?: unknown };
expect(object.metadata).toBeUndefined();
const object = captured?.object as { metadata?: { name?: string } };
expect(object.metadata?.name).toBe('');
});

it('sends spec.endpoint and spec.description as separate mask entries when both change', async () => {
const captured = await mutateAndCaptureUpdate({
id: 'b-1',
version: 3,
spec: { endpoint: 'new.example.com', description: 'updated description' },
});

Expand All @@ -202,6 +204,7 @@ describe('useUpdateStorageBackend', () => {
it('sends a single spec.credentials mask entry, never split into username/password leaves', async () => {
const captured = await mutateAndCaptureUpdate({
id: 'b-1',
version: 3,
spec: { credentials: { username: 'test-updated-admin', password: 'test-updated-secret' } },
});

Expand All @@ -214,6 +217,27 @@ describe('useUpdateStorageBackend', () => {
password: 'test-updated-secret',
});
});

it('sends lock: true for optimistic concurrency', async () => {
const captured = await mutateAndCaptureUpdate({
id: 'b-1',
version: 3,
spec: { endpoint: 'new.example.com' },
});

expect(captured?.lock).toBe(true);
});

it('sends the current version in object.metadata so the server can enforce the lock', async () => {
const captured = await mutateAndCaptureUpdate({
id: 'b-1',
version: 7,
spec: { endpoint: 'new.example.com' },
});

const object = captured?.object as { metadata?: { version?: number } };
expect(object.metadata?.version).toBe(7);
});
});

describe('useDeleteStorageBackend', () => {
Expand Down
5 changes: 4 additions & 1 deletion libs/ui-components/src/api/v1/private/storage-backends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const useCreateStorageBackend = () => {

export type UpdateStorageBackendInput = {
id: string;
/** The `metadata.version` of the record the caller last fetched — required so the server can enforce the lock below; a request with no metadata is exempt from the optimistic-lock check regardless of `lock: true`. */
version: number;
spec: MessageInitShape<typeof StorageBackendSpecSchema>;
};

Expand All @@ -81,8 +83,9 @@ export const useUpdateStorageBackend = () => {
}

const resp = await client.update({
object: { id: input.id, spec },
object: { id: input.id, metadata: { version: input.version }, spec },
updateMask: { paths: buildUpdateMaskPaths({ spec } as Record<string, unknown>) },
lock: true,
});
if (!resp.object) {
throw new Error('Update response missing object');
Expand Down
Loading
Loading