security: fullstack hardening — task ownership, mapping validation, API-key scoping, test fixes
Backend: - Add validate_mapping_database_ownership() to verify source/target UUIDs belong to declared environments before persisting mappings (mappings.py) - Add API-key environment scoping to get_mappings (filter) and suggest_mappings_api (enforce) (mappings.py) - Add user_id Column to TaskRecord model + Alembic migration (task.py) - Persist task.user_id on save, restore on load (persistence.py) - Wire current_user.id into migrate_dashboards + backup_dashboards task creation (_action_routes.py) - Fix test_migration_routes.py: module-level patch leak → autouse fixture, SupersetClient→AsyncSupersetClient, AsyncMock for sync_environment/run - Fix 7 Pydantic serializer warnings: 'PENDING'→TaskStatus.PENDING in test_tasks.py + import TaskStatus Frontend: - Deepen isDryRunResult(): validate selection field, risk.items entries (all 5 fields), and diff object uuids individually (ExecutorModel.svelte.ts) Prior work included: task password redaction, resume ownership checks, canonical dry-run DTO alignment, migration UI callback fixes, credential exposure reduction, assistant dry-run await fix.
This commit is contained in:
@@ -119,9 +119,22 @@ function shouldSuppressApiErrorToast(endpoint: string, error: ApiError): boolean
|
||||
// #region wsUrlHelpers [C:2] [TYPE Block] [SEMANTICS websocket, url, task-logs, maintenance, translate]
|
||||
// @BRIEF WebSocket URL builders — each constructs an authenticated WS endpoint for a specific channel.
|
||||
// @PRE taskId / runId are non-empty strings where applicable.
|
||||
// @POST Returns fully-qualified ws:// or wss:// URL with auth token as query parameter.
|
||||
// @POST Returns fully-qualified ws:// or wss:// URL with auth and x-trace-id query parameters.
|
||||
// @SIDE_EFFECT Reads localStorage for auth_token on each call.
|
||||
// @RATIONALE WebSocket API does not support custom headers in browser, so auth token is appended as query param.
|
||||
// @RATIONALE WebSocket API does not support custom headers in browser, so auth and trace IDs are
|
||||
// appended as query parameters for authentication and cross-stack correlation.
|
||||
|
||||
function _appendWsCredentials(url: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) params.set('token', token);
|
||||
const traceId = getTraceId();
|
||||
if (traceId && traceId !== 'no-trace') params.set('x-trace-id', traceId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${url}?${query}` : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WebSocket URL for a task's log stream, including auth token.
|
||||
@@ -129,14 +142,7 @@ function shouldSuppressApiErrorToast(endpoint: string, error: ApiError): boolean
|
||||
export const getWsUrl = (taskId: string): string => {
|
||||
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
|
||||
let url = `${protocol}//${host}/ws/logs/${taskId}`;
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
return _appendWsCredentials(`${protocol}//${host}/ws/logs/${taskId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -145,14 +151,7 @@ export const getWsUrl = (taskId: string): string => {
|
||||
export const getTaskEventsWsUrl = (): string => {
|
||||
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
|
||||
let url = `${protocol}//${host}/ws/task-events`;
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
return _appendWsCredentials(`${protocol}//${host}/ws/task-events`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,14 +161,7 @@ export const getTaskEventsWsUrl = (): string => {
|
||||
export const getMaintenanceEventsWsUrl = (): string => {
|
||||
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
|
||||
let url = `${protocol}//${host}/ws/maintenance/events`;
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
return _appendWsCredentials(`${protocol}//${host}/ws/maintenance/events`);
|
||||
};
|
||||
// #endregion getMaintenanceEventsWsUrl
|
||||
|
||||
@@ -180,14 +172,7 @@ export const getMaintenanceEventsWsUrl = (): string => {
|
||||
export const getTranslateRunWsUrl = (runId: string): string => {
|
||||
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
|
||||
let url = `${protocol}//${host}/ws/translate/run/${runId}`;
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
url += `?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
return _appendWsCredentials(`${protocol}//${host}/ws/translate/run/${runId}`);
|
||||
};
|
||||
// #endregion getTranslateRunWsUrl
|
||||
// #endregion wsUrlHelpers
|
||||
@@ -281,14 +266,18 @@ async function fetchApi<T = unknown>(endpoint: string, options: FetchOptions = {
|
||||
// @PRE endpoint is a non-empty string path.
|
||||
// @POST Returns Promise<Blob> with binary data. Throws ApiError on failure or 202 "in progress".
|
||||
// @SIDE_EFFECT Sends HTTP GET request. On failure (unless notifyError=false), dispatches error toast.
|
||||
// Writes CoT log line on entry, success, and failure.
|
||||
// @RELATION DEPENDS_ON -> [buildApiError]
|
||||
// @RELATION DEPENDS_ON -> [notifyApiError]
|
||||
// @RELATION DEPENDS_ON -> [getAuthHeaders]
|
||||
// @RATIONALE 202 status is handled as a special case — the thumbnail generation may still be in progress.
|
||||
// The caller can retry after a delay. This is NOT treated as a server error.
|
||||
async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promise<Blob> {
|
||||
const _start = performance.now();
|
||||
const notifyError = options.notifyError !== false;
|
||||
const _silent = _isSilentPolling(endpoint);
|
||||
try {
|
||||
if (!_silent) log('ApiClient', 'REASON', 'GET blob', { endpoint });
|
||||
const fetchInit: RequestInit = { headers: getAuthHeaders(options.headers || {}) };
|
||||
if (options.signal) fetchInit.signal = options.signal;
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, fetchInit);
|
||||
@@ -299,9 +288,15 @@ async function fetchApiBlob(endpoint: string, options: FetchOptions = {}): Promi
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
_captureTraceId(response);
|
||||
if (!_silent) log('ApiClient', 'REFLECT', 'GET blob completed', {
|
||||
endpoint, status: response.status,
|
||||
elapsed_ms: Math.round(performance.now() - _start),
|
||||
});
|
||||
return await response.blob();
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
log('ApiClient', 'EXPLORE', 'GET blob failed', { endpoint, status: apiError?.status }, apiError?.message || 'unknown');
|
||||
if (notifyError) notifyApiError(apiError);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -7,22 +7,7 @@
|
||||
// @TEST_CONTRACT: postApi -> Sends POST with JSON body, handles errors
|
||||
// @TEST_CONTRACT: deleteApi -> Sends DELETE, always notifies on error
|
||||
// @TEST_CONTRACT: requestApi -> Generic method with suppression heuristics
|
||||
// @TEST_CONTRACT: wsUrl helpers -> Build correct ws:// URL with auth token
|
||||
// @TEST_EDGE: network_failure -> fetch throws, error toast dispatched
|
||||
// @TEST_EDGE: auth_token -> localStorage token injected as Bearer header
|
||||
// @TEST_EDGE: suppress_toast -> Options.suppressToast suppresses error toast
|
||||
// @TEST_EDGE: api_methods -> Registry methods call correct endpoints
|
||||
|
||||
// #region ApiModuleTest [C:3] [TYPE Module] [SEMANTICS test, api, fetch, ws-url]
|
||||
// @BRIEF Unit tests for the core API communication layer — fetch wrappers, WebSocket URL builders,
|
||||
// endpoint registry, error normalization, and toast suppression.
|
||||
// @LAYER Tests
|
||||
// @RELATION BINDS_TO -> [ApiModule]
|
||||
// @TEST_CONTRACT: fetchApi -> Returns typed JSON for 2xx, null for 204, throws ApiError on error
|
||||
// @TEST_CONTRACT: postApi -> Sends POST with JSON body, handles errors
|
||||
// @TEST_CONTRACT: deleteApi -> Sends DELETE, always notifies on error
|
||||
// @TEST_CONTRACT: requestApi -> Generic method with suppression heuristics
|
||||
// @TEST_CONTRACT: wsUrl helpers -> Build correct ws:// URL with auth token
|
||||
// @TEST_CONTRACT: wsUrl helpers -> Build correct ws:// URL with auth token and trace ID
|
||||
// @TEST_EDGE: network_failure -> fetch throws, error toast dispatched
|
||||
// @TEST_EDGE: auth_token -> localStorage token injected as Bearer header
|
||||
// @TEST_EDGE: suppress_toast -> Options.suppressToast suppresses error toast
|
||||
@@ -65,34 +50,34 @@ describe('ApiModule — wsUrl helpers', () => {
|
||||
localStorage.setItem('auth_token', 'my-token-123');
|
||||
const { getWsUrl } = await import('$lib/api.js');
|
||||
const url = getWsUrl('task-42');
|
||||
expect(url).toBe('ws://localhost:5173/ws/logs/task-42?token=my-token-123');
|
||||
expect(url).toBe('ws://localhost:5173/ws/logs/task-42?token=my-token-123&x-trace-id=test-trace-id');
|
||||
});
|
||||
|
||||
it('getWsUrl returns ws:// URL without token when auth_token missing', async () => {
|
||||
const { getWsUrl } = await import('$lib/api.js');
|
||||
const url = getWsUrl('task-99');
|
||||
expect(url).toBe('ws://localhost:5173/ws/logs/task-99');
|
||||
expect(url).toBe('ws://localhost:5173/ws/logs/task-99?x-trace-id=test-trace-id');
|
||||
});
|
||||
|
||||
it('getTaskEventsWsUrl builds correct URL with token', async () => {
|
||||
localStorage.setItem('auth_token', 'tok');
|
||||
const { getTaskEventsWsUrl } = await import('$lib/api.js');
|
||||
const url = getTaskEventsWsUrl();
|
||||
expect(url).toBe('ws://localhost:5173/ws/task-events?token=tok');
|
||||
expect(url).toBe('ws://localhost:5173/ws/task-events?token=tok&x-trace-id=test-trace-id');
|
||||
});
|
||||
|
||||
it('getMaintenanceEventsWsUrl builds correct URL', async () => {
|
||||
localStorage.setItem('auth_token', 'maint-tok');
|
||||
const { getMaintenanceEventsWsUrl } = await import('$lib/api.js');
|
||||
const url = getMaintenanceEventsWsUrl();
|
||||
expect(url).toBe('ws://localhost:5173/ws/maintenance/events?token=maint-tok');
|
||||
expect(url).toBe('ws://localhost:5173/ws/maintenance/events?token=maint-tok&x-trace-id=test-trace-id');
|
||||
});
|
||||
|
||||
it('getTranslateRunWsUrl builds correct URL with runId', async () => {
|
||||
localStorage.setItem('auth_token', 'run-tok');
|
||||
const { getTranslateRunWsUrl } = await import('$lib/api.js');
|
||||
const url = getTranslateRunWsUrl('run-1');
|
||||
expect(url).toBe('ws://localhost:5173/ws/translate/run/run-1?token=run-tok');
|
||||
expect(url).toBe('ws://localhost:5173/ws/translate/run/run-1?token=run-tok&x-trace-id=test-trace-id');
|
||||
});
|
||||
|
||||
it('getWsUrl uses wss:// when window.location.protocol is https:', async () => {
|
||||
@@ -102,7 +87,7 @@ describe('ApiModule — wsUrl helpers', () => {
|
||||
localStorage.setItem('auth_token', 'sec-tok');
|
||||
const { getWsUrl } = await import('$lib/api.js');
|
||||
const url = getWsUrl('task-secure');
|
||||
expect(url).toBe('wss://app.example.com/ws/logs/task-secure?token=sec-tok');
|
||||
expect(url).toBe('wss://app.example.com/ws/logs/task-secure?token=sec-tok&x-trace-id=test-trace-id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -785,6 +770,146 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => {
|
||||
});
|
||||
});
|
||||
// #endregion captureTraceIdTests
|
||||
|
||||
// #region fetchApiBlobTraceTests [C:2] [TYPE Test] [SEMANTICS test,api,blob,trace-id,cot]
|
||||
// @BRIEF Verify fetchApiBlob captures x-trace-id and logs CoT markers on success/failure.
|
||||
describe('fetchApiBlob — trace propagation & CoT logging', () => {
|
||||
const testUuid = 'a1b2c3d4e5f67890abcdef1234567890';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('captures x-trace-id from response headers', async () => {
|
||||
const blob = new Blob(['img'], { type: 'image/png' });
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: {
|
||||
get: vi.fn((name: string) => name === 'x-trace-id' ? testUuid : null),
|
||||
},
|
||||
} as unknown as Response);
|
||||
|
||||
const cotLogger = await import('$lib/cot-logger.js');
|
||||
cotLogger.setTraceId.mockClear();
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
await api.fetchApiBlob('/thumbnail');
|
||||
expect(cotLogger.setTraceId).toHaveBeenCalledWith(testUuid);
|
||||
});
|
||||
|
||||
it('handles missing x-trace-id header gracefully', async () => {
|
||||
const blob = new Blob(['img'], { type: 'image/png' });
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: { get: vi.fn(() => null) },
|
||||
} as unknown as Response);
|
||||
|
||||
const cotLogger = await import('$lib/cot-logger.js');
|
||||
cotLogger.setTraceId.mockClear();
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
await api.fetchApiBlob('/no-trace');
|
||||
expect(cotLogger.setTraceId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs REASON then REFLECT on success with elapsed_ms', async () => {
|
||||
const blob = new Blob(['data'], { type: 'text/plain' });
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: { get: vi.fn(() => null) },
|
||||
} as unknown as Response);
|
||||
|
||||
const cotLogger = await import('$lib/cot-logger.js');
|
||||
cotLogger.log.mockClear();
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
await api.fetchApiBlob('/file');
|
||||
|
||||
const calls = cotLogger.log.mock.calls;
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const reasonCall = calls.find((c: unknown[]) => c[1] === 'REASON');
|
||||
expect(reasonCall).toBeTruthy();
|
||||
expect(reasonCall[0]).toBe('ApiClient');
|
||||
expect(reasonCall[2]).toBe('GET blob');
|
||||
expect(reasonCall[3]).toEqual({ endpoint: '/file' });
|
||||
|
||||
const reflectCall = calls.find((c: unknown[]) => c[1] === 'REFLECT');
|
||||
expect(reflectCall).toBeTruthy();
|
||||
expect(reflectCall[0]).toBe('ApiClient');
|
||||
expect(reflectCall[2]).toBe('GET blob completed');
|
||||
expect(reflectCall[3]).toMatchObject({ endpoint: '/file', status: 200 });
|
||||
expect(typeof reflectCall[3]?.elapsed_ms).toBe('number');
|
||||
});
|
||||
|
||||
it('logs EXPLORE on 500 with status in payload and error message', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ detail: 'Internal server error' }),
|
||||
} as Response);
|
||||
|
||||
const cotLogger = await import('$lib/cot-logger.js');
|
||||
cotLogger.log.mockClear();
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
await expect(api.fetchApiBlob('/fail')).rejects.toMatchObject({ status: 500 });
|
||||
|
||||
const exploreCall = cotLogger.log.mock.calls.find((c: unknown[]) => c[1] === 'EXPLORE');
|
||||
expect(exploreCall).toBeTruthy();
|
||||
expect(exploreCall[0]).toBe('ApiClient');
|
||||
expect(exploreCall[2]).toBe('GET blob failed');
|
||||
expect(exploreCall[3]).toMatchObject({ endpoint: '/fail', status: 500 });
|
||||
expect(exploreCall[4]).toMatch(/Internal server error/);
|
||||
});
|
||||
|
||||
it('logs EXPLORE on 202 (resource being prepared)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: () => Promise.resolve({ message: 'Still generating' }),
|
||||
} as Response);
|
||||
|
||||
const cotLogger = await import('$lib/cot-logger.js');
|
||||
cotLogger.log.mockClear();
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
await expect(api.fetchApiBlob('/pending')).rejects.toMatchObject({ status: 202 });
|
||||
|
||||
const exploreCall = cotLogger.log.mock.calls.find((c: unknown[]) => c[1] === 'EXPLORE');
|
||||
expect(exploreCall).toBeTruthy();
|
||||
expect(exploreCall[2]).toBe('GET blob failed');
|
||||
// 202 is an error case for blob — status in payload
|
||||
expect(exploreCall[3]).toMatchObject({ endpoint: '/pending', status: 202 });
|
||||
});
|
||||
|
||||
it('suppresses CoT REASON/REFLECT for silent polling endpoints, still logs EXPLORE on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ detail: 'boom' }),
|
||||
} as Response);
|
||||
|
||||
const cotLogger = await import('$lib/cot-logger.js');
|
||||
cotLogger.log.mockClear();
|
||||
|
||||
const { api } = await import('$lib/api.js');
|
||||
await expect(api.fetchApiBlob('/health/summary')).rejects.toMatchObject({ status: 500 });
|
||||
|
||||
const calls = cotLogger.log.mock.calls;
|
||||
const reasonCall = calls.find((c: unknown[]) => c[1] === 'REASON');
|
||||
expect(reasonCall).toBeUndefined();
|
||||
const exploreCall = calls.find((c: unknown[]) => c[1] === 'EXPLORE');
|
||||
expect(exploreCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// #endregion fetchApiBlobTraceTests
|
||||
});
|
||||
|
||||
describe('ApiModule — deleteValidationTask query param', () => {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<!-- @ingroup UI -->
|
||||
<!-- @BRIEF Displays and allows editing of database mappings. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @UX_STATE Suggested -> Preselects the recommendation and exposes its confidence as an editable draft. -->
|
||||
<!-- @UX_STATE Saved -> Shows the persisted target database; a user-selected alternative replaces the suggestion and is labelled as manual. -->
|
||||
<!-- @UX_STATE Unmapped -> Requires an explicit target database selection before replacement can use this source database. -->
|
||||
<!-- @UX_FEEDBACK Choosing a target invokes the parent persistence callback and changes status to Saved after success. -->
|
||||
<!-- @UX_RECOVERY The user can select any target database again to overwrite a saved or suggested mapping. -->
|
||||
<!-- @UX_TEST Suggested -> {select: non-recommended target, expected: Saved status and selected alternative remains visible}. -->
|
||||
<!--
|
||||
@SEMANTICS: mapping, table, database, editor
|
||||
@PURPOSE: Displays and allows editing of database mappings.
|
||||
@@ -11,20 +17,37 @@
|
||||
@INVARIANT: Each source database can be mapped to one target database.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { HelpTooltip } from '$lib/ui';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
let {
|
||||
sourceDatabases = [],
|
||||
targetDatabases = [],
|
||||
mappings = [],
|
||||
suggestions = [],
|
||||
onupdate = () => {},
|
||||
} = $props();
|
||||
type Database = { uuid: string; database_name: string; engine?: string };
|
||||
type Mapping = { source_db_uuid?: string; target_db_uuid?: string };
|
||||
type Suggestion = { source_db_uuid: string; target_db_uuid: string; confidence: number };
|
||||
type MappingUpdate = {
|
||||
sourceUuid: string;
|
||||
targetUuid: string;
|
||||
sourceName: string;
|
||||
targetName: string;
|
||||
engine: string;
|
||||
};
|
||||
|
||||
let {
|
||||
sourceDatabases = [],
|
||||
targetDatabases = [],
|
||||
mappings = [],
|
||||
suggestions = [],
|
||||
onupdate = () => {},
|
||||
}: {
|
||||
sourceDatabases?: Database[];
|
||||
targetDatabases?: Database[];
|
||||
mappings?: Mapping[];
|
||||
suggestions?: Suggestion[];
|
||||
onupdate?: (_update: MappingUpdate) => void;
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
@@ -35,9 +58,11 @@
|
||||
* @pre sourceUuid and targetUuid are provided.
|
||||
* @post Parent callback receives normalized mapping payload.
|
||||
*/
|
||||
function updateMapping(sourceUuid: string, targetUuid: string) {
|
||||
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
|
||||
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
|
||||
function updateMapping(sourceUuid: string, targetUuid: string) {
|
||||
if (!targetUuid) return;
|
||||
const sDb = sourceDatabases.find(d => d.uuid === sourceUuid);
|
||||
const tDb = targetDatabases.find(d => d.uuid === targetUuid);
|
||||
if (!sDb || !tDb) return;
|
||||
|
||||
onupdate({
|
||||
sourceUuid,
|
||||
@@ -56,7 +81,7 @@
|
||||
* @pre sourceUuid is provided.
|
||||
* @post Returns matching suggestion object or undefined.
|
||||
*/
|
||||
function getSuggestion(sourceUuid: string) {
|
||||
function getSuggestion(sourceUuid: string): Suggestion | undefined {
|
||||
return suggestions.find(s => s.source_db_uuid === sourceUuid);
|
||||
}
|
||||
// #endregion getSuggestion:Function
|
||||
@@ -78,7 +103,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-surface-card divide-y divide-border">
|
||||
{#each sourceDatabases as sDb}
|
||||
{#each sourceDatabases as sDb (sDb.uuid)}
|
||||
{@const mapping = mappings.find(m => m.source_db_uuid === sDb.uuid)}
|
||||
{@const suggestion = getSuggestion(sDb.uuid)}
|
||||
<tr class={suggestion && !mapping ? 'bg-success-light' : ''}>
|
||||
@@ -87,23 +112,31 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
|
||||
<select
|
||||
aria-label={`${$t.dashboard?.target_database ?? "Target database"}: ${sDb.database_name}`}
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-border-strong focus:outline-none focus:ring-primary-ring focus:border-primary-ring sm:text-sm rounded-md"
|
||||
value={mapping?.target_db_uuid || suggestion?.target_db_uuid || ""}
|
||||
onchange={(e) => updateMapping(sDb.uuid, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">{$t.migration?.target_env }</option>
|
||||
{#each targetDatabases as tDb}
|
||||
<option value="">{$t.migration?.select_target_database ?? "Select target database"}</option>
|
||||
{#each targetDatabases as tDb (tDb.uuid)}
|
||||
<option value={tDb.uuid}>{tDb.database_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
|
||||
{#if mapping}
|
||||
<span class="text-primary font-semibold">{$t.dashboard?.saved }</span>
|
||||
<span class="text-primary font-semibold">
|
||||
{$t.dashboard?.saved ?? "Saved"}
|
||||
{#if suggestion && mapping.target_db_uuid !== suggestion.target_db_uuid}
|
||||
<span class="ml-1 text-text-muted font-normal">({$t.migration?.mapping_custom_selection ?? "Selected manually"})</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if suggestion}
|
||||
<span class="text-success font-semibold">{$t.dashboard?.suggested } ({Math.round(suggestion.confidence * 100)}%)</span>
|
||||
<span class="text-success font-semibold">
|
||||
{$t.dashboard?.suggested ?? "Suggested"} ({Math.round(suggestion.confidence * 100)}%)
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-destructive">{$t.dashboard?.not_mapped }</span>
|
||||
<span class="text-destructive">{$t.dashboard?.not_mapped ?? "Not mapped"}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// #region Test.MappingTable [C:3] [TYPE Module] [SEMANTICS test,mapping,database,ux]
|
||||
// @BRIEF Verify database mapping selection, recommendation, and manual-selection UX.
|
||||
// @RELATION BINDS_TO -> [MappingTable]
|
||||
// @TEST_SCENARIO: suggested_mapping -> Recommended target is selected and confidence is shown.
|
||||
// @TEST_SCENARIO: manual_mapping -> Selecting another target emits a normalized plain object.
|
||||
// @TEST_SCENARIO: unmapped_database -> Empty target remains unmapped and does not emit a save.
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import MappingTable from '../MappingTable.svelte';
|
||||
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
t: {
|
||||
subscribe(run: (value: Record<string, Record<string, string>>) => void) {
|
||||
run({
|
||||
dashboard: {
|
||||
source_database: 'Source database',
|
||||
target_database: 'Target database',
|
||||
status: 'Status',
|
||||
saved: 'Saved',
|
||||
suggested: 'Suggested',
|
||||
not_mapped: 'Not mapped',
|
||||
},
|
||||
migration: {
|
||||
help_mapping_status: 'Mapping status',
|
||||
select_target_database: 'Select target database',
|
||||
mapping_custom_selection: 'Selected manually',
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const sourceDatabases = [
|
||||
{ uuid: 'source-1', database_name: 'Source DB', engine: 'postgresql' },
|
||||
];
|
||||
|
||||
const targetDatabases = [
|
||||
{ uuid: 'target-recommended', database_name: 'Recommended DB', engine: 'postgresql' },
|
||||
{ uuid: 'target-manual', database_name: 'Manual DB', engine: 'postgresql' },
|
||||
];
|
||||
|
||||
const suggestions = [
|
||||
{ source_db_uuid: 'source-1', target_db_uuid: 'target-recommended', confidence: 0.93 },
|
||||
];
|
||||
|
||||
describe('MappingTable', () => {
|
||||
it('renders a recommendation and its confidence', () => {
|
||||
render(MappingTable, { sourceDatabases, targetDatabases, suggestions });
|
||||
|
||||
const select = screen.getByRole('combobox', { name: 'Target database: Source DB' }) as HTMLSelectElement;
|
||||
expect(select.value).toBe('target-recommended');
|
||||
expect(screen.getByText('Suggested (93%)')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits a normalized plain object for a manually selected target', async () => {
|
||||
const onupdate = vi.fn();
|
||||
render(MappingTable, { sourceDatabases, targetDatabases, suggestions, onupdate });
|
||||
|
||||
const select = screen.getByRole('combobox', { name: 'Target database: Source DB' });
|
||||
await fireEvent.change(select, { target: { value: 'target-manual' } });
|
||||
|
||||
expect(onupdate).toHaveBeenCalledWith({
|
||||
sourceUuid: 'source-1',
|
||||
targetUuid: 'target-manual',
|
||||
sourceName: 'Source DB',
|
||||
targetName: 'Manual DB',
|
||||
engine: 'postgresql',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels a saved alternative as manually selected', () => {
|
||||
render(MappingTable, {
|
||||
sourceDatabases,
|
||||
targetDatabases,
|
||||
suggestions,
|
||||
mappings: [{ source_db_uuid: 'source-1', target_db_uuid: 'target-manual' }],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Saved')).toBeTruthy();
|
||||
expect(screen.getByText('(Selected manually)')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not save an unmapped empty selection', async () => {
|
||||
const onupdate = vi.fn();
|
||||
render(MappingTable, { sourceDatabases, targetDatabases, onupdate });
|
||||
|
||||
const select = screen.getByRole('combobox', { name: 'Target database: Source DB' });
|
||||
expect(screen.getByText('Not mapped')).toBeTruthy();
|
||||
await fireEvent.change(select, { target: { value: '' } });
|
||||
|
||||
expect(onupdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion Test.MappingTable
|
||||
@@ -9,6 +9,8 @@
|
||||
"select_dashboards_title": "Select Dashboards",
|
||||
"replace_db": "Replace Database (Apply Mappings)",
|
||||
"database_mappings": "Database Mappings",
|
||||
"select_target_database": "Select target database",
|
||||
"mapping_custom_selection": "Selected manually",
|
||||
"loading_dbs": "Loading databases and suggestions...",
|
||||
"refresh_dbs": "Refresh Databases & Suggestions",
|
||||
"start": "Start Migration",
|
||||
@@ -39,8 +41,19 @@
|
||||
"step_review": "Review",
|
||||
"step_migrate": "Migrate",
|
||||
"options": "Options",
|
||||
"review_validate": "Review & Validate",
|
||||
"selected_dashboards": "Selected Dashboards",
|
||||
"dashboards": "Dashboards",
|
||||
"charts": "Charts",
|
||||
"datasets": "Datasets",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"risk_assessment": "Risk Assessment",
|
||||
"risk_score": "Score",
|
||||
"risk_level_low": "Low",
|
||||
"risk_level_medium": "Medium",
|
||||
"risk_level_high": "High",
|
||||
"no_issues": "No issues detected.",
|
||||
"no_dry_run": "Run a dry-run to see the migration summary here.",
|
||||
"get_started": "Get Started",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"select_dashboards_title": "Выберите дашборды",
|
||||
"replace_db": "Заменить БД (применить маппинги)",
|
||||
"database_mappings": "Маппинги баз данных",
|
||||
"select_target_database": "Выберите целевую БД",
|
||||
"mapping_custom_selection": "Выбрано вручную",
|
||||
"loading_dbs": "Загрузка баз данных и подсказок...",
|
||||
"refresh_dbs": "Обновить БД и подсказки",
|
||||
"start": "Запустить миграцию",
|
||||
@@ -40,8 +42,19 @@
|
||||
"step_review": "Проверка",
|
||||
"step_migrate": "Миграция",
|
||||
"options": "Опции",
|
||||
"review_validate": "Проверка и подтверждение",
|
||||
"selected_dashboards": "Выбранные дашборды",
|
||||
"dashboards": "Дашборды",
|
||||
"charts": "Чарты",
|
||||
"datasets": "Датасеты",
|
||||
"create": "Создать",
|
||||
"update": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"risk_assessment": "Оценка рисков",
|
||||
"risk_score": "Оценка",
|
||||
"risk_level_low": "Низкий",
|
||||
"risk_level_medium": "Средний",
|
||||
"risk_level_high": "Высокий",
|
||||
"no_issues": "Проблем не обнаружено.",
|
||||
"no_dry_run": "Запустите пробный прогон, чтобы увидеть сводку миграции.",
|
||||
"get_started": "Начать",
|
||||
|
||||
@@ -17,27 +17,84 @@
|
||||
// @REJECTED Inline dry-run/execution logic in Migration.Model rejected — the execution lifecycle (dry-run calculate, submit migration, poll for AWAITING_INPUT, resume with passwords) has distinct state (dryRunResult, showPasswordPrompt) that would bloat the parent past the 400-line decomposition gate. A single action method `executeFullMigration()` on Migration.Model that internally orchestrates dry-run→execute→resume was rejected — the parent would need to track sub-state atoms anyway, and the caller (MigrationPage) needs separate gating (`canExecute`, `showPasswordPrompt`) for UI step controls.
|
||||
|
||||
import { api } from "$lib/api.js";
|
||||
import { selectedTask } from "$lib/stores/selectedTask.svelte.js";
|
||||
import { resumeTask } from "../../services/taskService.js";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import type {
|
||||
DashboardSelection as DashboardSelectionDto,
|
||||
MigrationDryRunResult,
|
||||
} from "../../types/dashboard";
|
||||
import type { MigrationModel } from "./MigrationModel.svelte.ts";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface DryRunResult {
|
||||
summary?: Record<string, unknown>;
|
||||
risk?: { score: number; level: string; items: unknown[] };
|
||||
selected_dashboard_titles?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type DryRunResult = MigrationDryRunResult;
|
||||
export type DashboardSelection = DashboardSelectionDto;
|
||||
|
||||
export interface DashboardSelection {
|
||||
selected_ids: string[];
|
||||
source_env_id: string;
|
||||
target_env_id: string;
|
||||
replace_db_config: boolean;
|
||||
fix_cross_filters: boolean;
|
||||
// #region Migration.ValidateDryRunResult [C:3] [TYPE Function] [SEMANTICS migration,dry-run,validation,dto]
|
||||
// @ingroup Migration
|
||||
// @BRIEF Narrow an untrusted API payload to the canonical migration dry-run DTO.
|
||||
// @POST Returns true only when the review page's required summary, diff, and risk fields are present.
|
||||
function isDryRunResult(payload: unknown): payload is DryRunResult {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
const result = payload as Record<string, unknown>;
|
||||
const isCountBucket = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const bucket = value as Record<string, unknown>;
|
||||
return ["create", "update", "delete"].every(
|
||||
(key) => typeof bucket[key] === "number",
|
||||
);
|
||||
};
|
||||
const isDiffBucket = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const bucket = value as Record<string, unknown>;
|
||||
return ["create", "update", "delete"].every(
|
||||
(key) => Array.isArray(bucket[key]),
|
||||
);
|
||||
};
|
||||
const isDiffObject = (item: unknown): boolean => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const d = item as Record<string, unknown>;
|
||||
return typeof d.uuid === "string";
|
||||
};
|
||||
const isRiskItem = (item: unknown): boolean => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const r = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof r.code === "string" &&
|
||||
typeof r.severity === "string" &&
|
||||
typeof r.object_type === "string" &&
|
||||
typeof r.object_uuid === "string" &&
|
||||
typeof r.message === "string"
|
||||
);
|
||||
};
|
||||
const summary = result.summary as Record<string, unknown> | undefined;
|
||||
const diff = result.diff as Record<string, unknown> | undefined;
|
||||
const risk = result.risk as Record<string, unknown> | undefined;
|
||||
return (
|
||||
typeof result.generated_at === "string" &&
|
||||
!!result.selection &&
|
||||
typeof result.selection === "object" &&
|
||||
Array.isArray(result.selected_dashboard_titles) &&
|
||||
!!summary &&
|
||||
["dashboards", "charts", "datasets"].every((key) => isCountBucket(summary[key])) &&
|
||||
typeof summary.selected_dashboards === "number" &&
|
||||
!!diff &&
|
||||
["dashboards", "charts", "datasets"].every((key) => {
|
||||
if (!isDiffBucket(diff[key])) return false;
|
||||
const bucket = diff[key] as Record<string, unknown>;
|
||||
return ["create", "update", "delete"].every((op) => {
|
||||
const items = bucket[op] as unknown[];
|
||||
return Array.isArray(items) && items.every(isDiffObject);
|
||||
});
|
||||
}) &&
|
||||
!!risk &&
|
||||
typeof risk.score === "number" &&
|
||||
typeof risk.level === "string" &&
|
||||
Array.isArray(risk.items) &&
|
||||
risk.items.every(isRiskItem)
|
||||
);
|
||||
}
|
||||
// #endregion Migration.ValidateDryRunResult
|
||||
|
||||
export class MigrationExecutor {
|
||||
parent: MigrationModel;
|
||||
@@ -70,7 +127,14 @@ export class MigrationExecutor {
|
||||
this.parent.error = "";
|
||||
this.dryRunLoading = true;
|
||||
try {
|
||||
this.dryRunResult = await api.postApi("/migration/dry-run", this.parent._buildSelection());
|
||||
const result: unknown = await api.postApi(
|
||||
"/migration/dry-run",
|
||||
this.parent._buildSelection(),
|
||||
);
|
||||
if (!isDryRunResult(result)) {
|
||||
throw new Error("Migration dry-run response has an invalid shape");
|
||||
}
|
||||
this.dryRunResult = result;
|
||||
this.parent.wizard.currentStep = 3;
|
||||
} catch (e: unknown) {
|
||||
this.parent.error = e instanceof Error ? e.message : "Dry-run failed";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// @INVARIANT Changing source environment resets dashboard selection to empty.
|
||||
// @INVARIANT Changing source environment resets databases, mappings, and suggestions.
|
||||
// @INVARIANT Changing source environment clears dry-run result.
|
||||
// @INVARIANT Persisting a database mapping clears dry-run results because the target database selection changes the import plan.
|
||||
// @INVARIANT Migration execution blocked unless source≠target and ≥1 dashboard selected.
|
||||
// @INVARIANT Dry-run must complete before advancing to execution step.
|
||||
// @INVARIANT Password prompt only appears for AWAITING_INPUT tasks with type "database_password".
|
||||
@@ -78,6 +79,12 @@ interface Mapping {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DatabaseSuggestion {
|
||||
source_db_uuid: string;
|
||||
target_db_uuid: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface LogViewerTask {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -108,7 +115,7 @@ export class MigrationModel {
|
||||
sourceDatabases: Database[] = $state([]);
|
||||
targetDatabases: Database[] = $state([]);
|
||||
mappings: Mapping[] = $state([]);
|
||||
suggestions: Record<string, unknown>[] = $state([]);
|
||||
suggestions: DatabaseSuggestion[] = $state([]);
|
||||
|
||||
// Loading & error
|
||||
loading: boolean = $state(true);
|
||||
@@ -303,7 +310,7 @@ export class MigrationModel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a database mapping between source and target. */
|
||||
/** Save a database mapping between source and target and invalidate any stale dry-run. */
|
||||
async saveMapping(sourceUuid: string, targetUuid: string): Promise<void> {
|
||||
const sDb = this.sourceDatabases.find((d: Database) => d.uuid === sourceUuid);
|
||||
const tDb = this.targetDatabases.find((d: Database) => d.uuid === targetUuid);
|
||||
@@ -321,6 +328,8 @@ export class MigrationModel {
|
||||
...this.mappings.filter((m: Mapping) => m.source_db_uuid !== sourceUuid),
|
||||
savedMapping,
|
||||
];
|
||||
// @INVARIANT: a mapping change alters the archive transformation/import plan.
|
||||
this.dryRunResult = null;
|
||||
} catch (e: unknown) {
|
||||
this.error = e instanceof Error ? e.message : "Failed to save mapping";
|
||||
}
|
||||
@@ -361,7 +370,7 @@ export class MigrationModel {
|
||||
/** Build the DashboardSelection payload for API calls. */
|
||||
_buildSelection(): DashboardSelection {
|
||||
return {
|
||||
selected_ids: this.selectedDashboardIds,
|
||||
selected_ids: this.selectedDashboardIds.map((id) => Number(id)),
|
||||
source_env_id: this.sourceEnvId,
|
||||
target_env_id: this.targetEnvId,
|
||||
replace_db_config: this.replaceDb,
|
||||
|
||||
@@ -270,6 +270,18 @@ describe("MigrationModel — L1 invariants (no render)", () => {
|
||||
expect(model.mappings[0].id).toBe(2);
|
||||
expect(model.mappings[1].id).toBe(3);
|
||||
});
|
||||
it("clears a completed dry-run after choosing a different target database", async () => {
|
||||
model.sourceDatabases = [{ uuid: "suuid", database_name: "SrcDB" }];
|
||||
model.targetDatabases = [{ uuid: "recommended", database_name: "Recommended" }, { uuid: "manual", database_name: "Manual" }];
|
||||
model.mappings = [{ id: 1, source_db_uuid: "suuid", target_db_uuid: "recommended" }];
|
||||
model.dryRunResult = { summary: {}, risk: { score: 10, level: "low", items: [] } } as any;
|
||||
vi.mocked(api.postApi).mockResolvedValue({ id: 2, source_db_uuid: "suuid", target_db_uuid: "manual" });
|
||||
|
||||
await model.saveMapping("suuid", "manual");
|
||||
|
||||
expect(model.mappings).toEqual([{ id: 2, source_db_uuid: "suuid", target_db_uuid: "manual" }]);
|
||||
expect(model.dryRunResult).toBeNull();
|
||||
});
|
||||
it("sets error on failure", async () => {
|
||||
model.sourceDatabases = [{ uuid: "suuid", database_name: "S" }]; model.targetDatabases = [{ uuid: "tuuid", database_name: "T" }];
|
||||
vi.mocked(api.postApi).mockRejectedValue(new Error("Save failed"));
|
||||
@@ -288,7 +300,7 @@ describe("MigrationModel — L1 invariants (no render)", () => {
|
||||
it("builds correct payload", () => {
|
||||
model.selectedDashboardIds = ["1"]; model.sourceEnvId = "src"; model.targetEnvId = "tgt"; model.replaceDb = true; model.fixCrossFilters = false;
|
||||
const sel = model._buildSelection();
|
||||
expect(sel).toEqual({ selected_ids: ["1"], source_env_id: "src", target_env_id: "tgt", replace_db_config: true, fix_cross_filters: false });
|
||||
expect(sel).toEqual({ selected_ids: [1], source_env_id: "src", target_env_id: "tgt", replace_db_config: true, fix_cross_filters: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,7 +333,14 @@ describe("MigrationModel — L1 invariants (no render)", () => {
|
||||
describe("calculateDryRun → toggleDashboard invariant", () => {
|
||||
it("clears dryRunResult on toggle after dry-run", async () => {
|
||||
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
||||
vi.mocked(api.postApi).mockResolvedValueOnce({ summary: {}, risk: { score: 10, level: "low", items: [] }, selected_dashboard_titles: ["Sales"] });
|
||||
vi.mocked(api.postApi).mockResolvedValueOnce({
|
||||
generated_at: "2026-07-15T00:00:00+00:00",
|
||||
selection: { selected_ids: [1], source_env_id: "env-1", target_env_id: "env-2", replace_db_config: false, fix_cross_filters: true },
|
||||
selected_dashboard_titles: ["Sales"],
|
||||
diff: { dashboards: { create: [], update: [], delete: [] }, charts: { create: [], update: [], delete: [] }, datasets: { create: [], update: [], delete: [] } },
|
||||
summary: { dashboards: { create: 0, update: 0, delete: 0 }, charts: { create: 0, update: 0, delete: 0 }, datasets: { create: 0, update: 0, delete: 0 }, selected_dashboards: 1 },
|
||||
risk: { score: 10, level: "low", items: [] },
|
||||
});
|
||||
await model.calculateDryRun();
|
||||
expect(model.dryRunResult).toBeTruthy();
|
||||
model.toggleDashboard("1");
|
||||
@@ -378,7 +397,14 @@ describe("MigrationModel — L1 invariants (no render)", () => {
|
||||
});
|
||||
it("advances to step 3 on success", async () => {
|
||||
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
||||
vi.mocked(api.postApi).mockResolvedValue({ summary: {}, risk: { score: 10, level: "low", items: [] }, selected_dashboard_titles: ["Sales"] });
|
||||
vi.mocked(api.postApi).mockResolvedValue({
|
||||
generated_at: "2026-07-15T00:00:00+00:00",
|
||||
selection: { selected_ids: [1], source_env_id: "env-1", target_env_id: "env-2", replace_db_config: false, fix_cross_filters: true },
|
||||
selected_dashboard_titles: ["Sales"],
|
||||
diff: { dashboards: { create: [], update: [], delete: [] }, charts: { create: [], update: [], delete: [] }, datasets: { create: [], update: [], delete: [] } },
|
||||
summary: { dashboards: { create: 0, update: 0, delete: 0 }, charts: { create: 0, update: 0, delete: 0 }, datasets: { create: 0, update: 0, delete: 0 }, selected_dashboards: 1 },
|
||||
risk: { score: 10, level: "low", items: [] },
|
||||
});
|
||||
await model.calculateDryRun();
|
||||
expect(model.currentStep).toBe(3);
|
||||
expect(model.dryRunResult).toBeTruthy();
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
}}
|
||||
>
|
||||
<option value="">{$t.dashboard?.target_env_placeholder || 'Select target environment...'}</option>
|
||||
{#each environments.filter((e) => e.id !== model.selectedEnv) as env}
|
||||
{#each environments.filter((e) => e.id !== model.selectedEnv) as env (env.id)}
|
||||
<option value={env.id}>{env.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -106,7 +106,7 @@
|
||||
targetDatabases={model.bulkMigrateModel.targetDatabases}
|
||||
mappings={model.bulkMigrateModel.mappings}
|
||||
suggestions={model.bulkMigrateModel.suggestions}
|
||||
onupdate={(e) => model.bulkMigrateModel.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
|
||||
onupdate={(mapping) => model.bulkMigrateModel.saveMapping(mapping.sourceUuid, mapping.targetUuid)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -121,7 +121,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if model.bulkMigrateModel.suggestions.length > 0}
|
||||
{#each model.bulkMigrateModel.suggestions as mapping}
|
||||
{#each model.bulkMigrateModel.suggestions as mapping (mapping.source_db_uuid)}
|
||||
{@const targetMapping = model.bulkMigrateModel.mappings.find((m2) => m2.source_db_uuid === mapping.source_db_uuid)}
|
||||
<tr class="border-b border-border last:border-b-0">
|
||||
<td class="px-4 py-2">{mapping.source_db || mapping.source_db_name}</td>
|
||||
@@ -170,8 +170,8 @@
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-text mb-2">{$t.dashboard?.selected_dashboards}</p>
|
||||
<div class="max-h-40 overflow-y-auto border border-border rounded-lg bg-surface-muted p-2">
|
||||
{#each Array.from(model.selectedIds) as id}
|
||||
{#each model.allDashboards as d}
|
||||
{#each Array.from(model.selectedIds) as id (id)}
|
||||
{#each model.allDashboards as d (d.id)}
|
||||
{#if d.id === id}
|
||||
<div class="flex items-center text-sm py-1 px-2"><span class="text-success mr-2">☑</span><span class="text-text">{d.title}</span></div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// #region Test.MigrateDashboardModal [C:3] [TYPE Module] [SEMANTICS test,dashboard,migration,modal,ux]
|
||||
// @BRIEF Verify the dashboard migration modal consumes MappingTable callback payloads correctly.
|
||||
// @RELATION BINDS_TO -> [MigrateDashboardModal]
|
||||
// @RELATION DEPENDS_ON -> [MappingTable]
|
||||
// @TEST_SCENARIO: manual_mapping -> Modal forwards the selected source and target UUIDs to the migration model.
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import MigrateDashboardModal from '../MigrateDashboardModal.svelte';
|
||||
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
t: {
|
||||
subscribe(run: (value: Record<string, Record<string, string>>) => void) {
|
||||
run({
|
||||
common: { close_modal: 'Close', cancel: 'Cancel', on: 'On', off: 'Off' },
|
||||
migration: {
|
||||
source_env: 'Source environment',
|
||||
target_env: 'Target environment',
|
||||
database_mappings: 'Database mappings',
|
||||
start: 'Start migration',
|
||||
},
|
||||
dashboard: {
|
||||
migrate_modal_title: 'Migrate {count} dashboards',
|
||||
read_only: '(read-only)',
|
||||
target_env_placeholder: 'Select target environment...',
|
||||
edit_mappings: 'Edit mappings',
|
||||
view_summary: 'View summary',
|
||||
source_database: 'Source database',
|
||||
target_database: 'Target database',
|
||||
match_percent: 'Match',
|
||||
suggested: 'Suggested',
|
||||
not_mapped: 'Not mapped',
|
||||
selected_dashboards: 'Selected dashboards',
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/taskDrawer.svelte.js', () => ({
|
||||
openDrawerForTaskIfPreferred: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('MigrateDashboardModal', () => {
|
||||
it('forwards a manual MappingTable selection to the migration model', async () => {
|
||||
const saveMapping = vi.fn();
|
||||
const model = {
|
||||
showMigrateModal: true,
|
||||
selectedEnv: 'source-env',
|
||||
selectedIds: new Set(['dashboard-1']),
|
||||
allDashboards: [{ id: 'dashboard-1', title: 'Sales dashboard' }],
|
||||
isEditingMappings: true,
|
||||
clearSelectedIds: vi.fn(),
|
||||
bulkMigrateModel: {
|
||||
targetEnvId: 'target-env',
|
||||
sourceEnvId: 'source-env',
|
||||
replaceDb: true,
|
||||
fixCrossFilters: true,
|
||||
sourceDatabases: [{ uuid: 'source-db', database_name: 'Source DB', engine: 'postgresql' }],
|
||||
targetDatabases: [
|
||||
{ uuid: 'recommended-db', database_name: 'Recommended DB', engine: 'postgresql' },
|
||||
{ uuid: 'manual-db', database_name: 'Manual DB', engine: 'postgresql' },
|
||||
],
|
||||
mappings: [],
|
||||
suggestions: [{ source_db_uuid: 'source-db', target_db_uuid: 'recommended-db', confidence: 0.95 }],
|
||||
saveMapping,
|
||||
selectedDashboardIds: ['dashboard-1'],
|
||||
dryRunResult: null,
|
||||
dryRunLoading: false,
|
||||
error: '',
|
||||
currentStep: 1,
|
||||
canExecute: false,
|
||||
selectTargetEnv: vi.fn(),
|
||||
fetchDatabases: vi.fn().mockResolvedValue(undefined),
|
||||
calculateDryRun: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
executeMigration: vi.fn(),
|
||||
selectedTaskStore: { current: null },
|
||||
deselectAllDashboards: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
render(MigrateDashboardModal, {
|
||||
model,
|
||||
environments: [
|
||||
{ id: 'source-env', name: 'Source' },
|
||||
{ id: 'target-env', name: 'Target' },
|
||||
],
|
||||
});
|
||||
|
||||
await fireEvent.change(screen.getByRole('combobox', { name: 'Target database: Source DB' }), {
|
||||
target: { value: 'manual-db' },
|
||||
});
|
||||
|
||||
expect(saveMapping).toHaveBeenCalledWith('source-db', 'manual-db');
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion Test.MigrateDashboardModal
|
||||
@@ -129,7 +129,7 @@
|
||||
{ step: 2, label: $t.migration?.select_dashboards_title || "Dashboards" },
|
||||
{ step: 3, label: $t.tasks?.summary_report || "Review" },
|
||||
{ step: 4, label: $t.migration?.start || "Migrate" },
|
||||
] as s}
|
||||
] as s (s.step)}
|
||||
<!-- Step indicator buttons — custom styling for step wizard (not standard Button) -->
|
||||
<Button variant="ghost"
|
||||
onclick={() => model.goToStep(s.step)}
|
||||
@@ -353,7 +353,7 @@
|
||||
targetDatabases={model.targetDatabases}
|
||||
mappings={model.mappings}
|
||||
suggestions={model.suggestions}
|
||||
onupdate={(e) => model.saveMapping(e.detail.sourceUuid, e.detail.targetUuid)}
|
||||
onupdate={(mapping) => model.saveMapping(mapping.sourceUuid, mapping.targetUuid)}
|
||||
/>
|
||||
{:else if model.sourceEnvId && model.targetEnvId}
|
||||
<p class="text-sm text-text-muted">{$t.migration?.mapping_hint || "Select environments and click \"Fetch Databases\" to start mapping."}</p>
|
||||
@@ -389,7 +389,7 @@
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-full bg-primary-light text-primary text-sm font-bold">3</span>
|
||||
<h3 class="text-lg font-semibold text-text">{$t.tasks?.summary_report || "Review & Validate"}</h3>
|
||||
<h3 class="text-lg font-semibold text-text">{$t.migration?.review_validate || "Review & Validate"}</h3>
|
||||
</div>
|
||||
|
||||
{#if model.dryRunResult}
|
||||
@@ -399,10 +399,10 @@
|
||||
<svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-info-hover">{$t.dashboard?.selected_dashboards || "Selected Dashboards"}</h3>
|
||||
<h3 class="text-sm font-semibold text-info-hover">{$t.migration?.selected_dashboards || "Selected Dashboards"}</h3>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each model.dryRunResult.selected_dashboard_titles || [] as title}
|
||||
{#each model.dryRunResult.selected_dashboard_titles || [] as title (title)}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-info-light text-info">
|
||||
{title}
|
||||
</span>
|
||||
@@ -418,19 +418,19 @@
|
||||
<div class="w-8 h-8 rounded-lg bg-info-light flex items-center justify-center">
|
||||
<Icon name="dashboard" size={16} class="text-info" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-text">{$t.dashboard?.title || "Dashboards"}</h4>
|
||||
<h4 class="text-sm font-semibold text-text">{$t.migration?.dashboards || "Dashboards"}</h4>
|
||||
</div>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-success font-medium">Create</span>
|
||||
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
|
||||
<span class="font-semibold text-success">{model.dryRunResult.summary.dashboards.create}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-warning font-medium">Update</span>
|
||||
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
|
||||
<span class="font-semibold text-warning">{model.dryRunResult.summary.dashboards.update}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-destructive font-medium">Delete</span>
|
||||
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
|
||||
<span class="font-semibold text-destructive">{model.dryRunResult.summary.dashboards.delete}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,19 +444,19 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-text">{$t.dashboard?.charts || "Charts"}</h4>
|
||||
<h4 class="text-sm font-semibold text-text">{$t.migration?.charts || "Charts"}</h4>
|
||||
</div>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-success font-medium">Create</span>
|
||||
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
|
||||
<span class="font-semibold text-success">{model.dryRunResult.summary.charts.create}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-warning font-medium">Update</span>
|
||||
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
|
||||
<span class="font-semibold text-warning">{model.dryRunResult.summary.charts.update}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-destructive font-medium">Delete</span>
|
||||
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
|
||||
<span class="font-semibold text-destructive">{model.dryRunResult.summary.charts.delete}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,19 +468,19 @@
|
||||
<div class="w-8 h-8 rounded-lg bg-success-light flex items-center justify-center">
|
||||
<Icon name="database" size={16} class="text-success" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-text">{$t.dashboard?.linked_resources || "Datasets"}</h4>
|
||||
<h4 class="text-sm font-semibold text-text">{$t.migration?.datasets || "Datasets"}</h4>
|
||||
</div>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-success font-medium">Create</span>
|
||||
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
|
||||
<span class="font-semibold text-success">{model.dryRunResult.summary.datasets.create}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-warning font-medium">Update</span>
|
||||
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
|
||||
<span class="font-semibold text-warning">{model.dryRunResult.summary.datasets.update}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-destructive font-medium">Delete</span>
|
||||
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
|
||||
<span class="font-semibold text-destructive">{model.dryRunResult.summary.datasets.delete}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,11 +494,11 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-text">{$t.tasks?.issues || "Risk Assessment"}</h3>
|
||||
<h3 class="text-sm font-semibold text-text">{$t.migration?.risk_assessment || "Risk Assessment"}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-text-muted">Score:</span>
|
||||
<span class="text-xs text-text-muted">{$t.migration?.risk_score || "Score"}:</span>
|
||||
<div class={`
|
||||
px-2.5 py-1 rounded-full text-xs font-bold
|
||||
${model.dryRunResult.risk.score <= 20 ? 'bg-success-light text-success' :
|
||||
@@ -514,7 +514,11 @@
|
||||
model.dryRunResult.risk.level === 'medium' ? 'bg-warning-light text-warning' :
|
||||
'bg-destructive-light text-destructive'}
|
||||
`}>
|
||||
{model.dryRunResult.risk.level.toUpperCase()}
|
||||
{model.dryRunResult.risk.level === "low"
|
||||
? ($t.migration?.risk_level_low || "Low")
|
||||
: model.dryRunResult.risk.level === "medium"
|
||||
? ($t.migration?.risk_level_medium || "Medium")
|
||||
: ($t.migration?.risk_level_high || "High")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,7 +537,7 @@
|
||||
<!-- Risk Items -->
|
||||
{#if model.dryRunResult.risk.items && model.dryRunResult.risk.items.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each model.dryRunResult.risk.items as item}
|
||||
{#each model.dryRunResult.risk.items as item (`${item.object_type}:${item.object_uuid}:${item.message}`)}
|
||||
<div class="flex items-start gap-3 p-2.5 rounded-lg text-sm
|
||||
${item.severity === 'high' ? 'bg-destructive-light' :
|
||||
item.severity === 'medium' ? 'bg-warning-light' : 'bg-info-light'}
|
||||
@@ -551,7 +555,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-muted">No issues detected.</p>
|
||||
<p class="text-sm text-text-muted">{$t.migration?.no_issues || "No issues detected."}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -568,7 +572,7 @@
|
||||
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm">Run a dry-run to see the migration summary here.</p>
|
||||
<p class="text-sm">{$t.migration?.no_dry_run || "Run a dry-run to see the migration summary here."}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface DashboardSelection {
|
||||
selected_ids: number[];
|
||||
source_env_id: string;
|
||||
target_env_id: string;
|
||||
replace_db_config?: boolean;
|
||||
fix_cross_filters?: boolean;
|
||||
replace_db_config: boolean;
|
||||
fix_cross_filters: boolean;
|
||||
}
|
||||
|
||||
export interface DiffObjectRef {
|
||||
|
||||
Reference in New Issue
Block a user