diff --git a/source/commands/env/data-migration.tsx b/source/commands/env/data-migration.tsx new file mode 100644 index 00000000..e92e66d6 --- /dev/null +++ b/source/commands/env/data-migration.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { option } from 'pastel'; +import zod from 'zod'; +import { type infer as zInfer } from 'zod'; +import { AuthProvider } from '../../components/AuthProvider.js'; +import DataMigrationComponent from '../../components/env/DataMigrationComponent.js'; + +export const description = + 'Migrate users and data from one environment to another'; + +export const options = zod.object({ + key: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: API Key to be used for the data migration (should be at least a project level key). If not set, CLI lets you select one', + alias: 'k', + }), + ), + source: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: Environment ID to migrate data from. If not set, the CLI lets you select one.', + }), + ), + target: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: Environment ID to migrate data to. If not set, the CLI lets you select one.', + }), + ), + skipResources: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: Skip migration of resource instances, attributes, and tuples.', + }), + ), + skipUsers: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: Skip migration of users, role assignments, and user attributes.', + }), + ), + conflictStrategy: zod + .enum(['override', 'fail']) + .default('override') + .optional() + .describe( + option({ + description: + "Optional: Strategy to handle migration conflicts. Default is 'override'.", + }), + ), +}); + +type Props = { + readonly options: zInfer; +}; + +export default function DataMigration({ + options: { key, source, target, skipResources, skipUsers, conflictStrategy }, +}: Props) { + return ( + + + + ); +} diff --git a/source/components/env/DataMigrationComponent.tsx b/source/components/env/DataMigrationComponent.tsx new file mode 100644 index 00000000..37016632 --- /dev/null +++ b/source/components/env/DataMigrationComponent.tsx @@ -0,0 +1,345 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Text, Box } from 'ink'; +import Spinner from 'ink-spinner'; +import SelectInput from 'ink-select-input'; +import { useEnvironmentApi } from '../../hooks/useEnvironmentApi.js'; +import { useProjectAPI } from '../../hooks/useProjectAPI.js'; +import { useAuth } from '../AuthProvider.js'; +import { ActiveState } from '../EnvironmentSelection.js'; +import { useDataMigration } from '../../hooks/useDataMigration.js'; + +type Props = { + source?: string; + target?: string; + skipResources?: boolean; + skipUsers?: boolean; + conflictStrategy?: 'override' | 'fail'; + onComplete?: () => void; +}; + +const MIGRATION_STATE = { + LOADING: 'loading', + SELECT_PROJECT: 'select-project', + SELECT_SOURCE: 'select-source', + SELECT_TARGET: 'select-target', + MIGRATING: 'migrating', + DONE: 'done', + ERROR: 'error', +} as const; + +// Common UI labels and messages +const WARNING_TEXT = 'Warning:'; +const UNKNOWN_ERROR = 'Unknown error'; +const SAME_ENV_ERROR = 'Source and target environments cannot be the same'; + +type MigrationState = (typeof MIGRATION_STATE)[keyof typeof MIGRATION_STATE]; + +const DataMigrationComponent: React.FC = ({ + source, + target, + conflictStrategy = 'override', + onComplete, +}) => { + const { scope } = useAuth(); + const { getEnvironments } = useEnvironmentApi(); + const { getProjects } = useProjectAPI(); + + const [sourceEnv, setSourceEnv] = useState(source || null); + const [targetEnv, setTargetEnv] = useState(target || null); + const [environments, setEnvironments] = useState([]); + const [projects, setProjects] = useState([]); + const [activeProject, setActiveProject] = useState(null); + const [state, setState] = useState(MIGRATION_STATE.LOADING); + const [error, setError] = useState(null); + const [migrationStats, setMigrationStats] = useState<{ + users: { total: number; success: number; failed: number }; + roleAssignments: { total: number; success: number; failed: number }; + } | null>(null); + + const { migrateUsers, migrateRoleAssignments } = useDataMigration(); + + // Handle completion & exit + useEffect(() => { + if (state === MIGRATION_STATE.DONE && migrationStats) { + console.log('Migration completed. Final results:'); + console.log( + `Users: ${migrationStats.users.success}/${migrationStats.users.total}`, + ); + console.log( + `Role Assignments: ${migrationStats.roleAssignments.success}/${migrationStats.roleAssignments.total}`, + ); + if (onComplete) { + onComplete(); + } else { + setTimeout(() => { + console.log('Exiting process...'); + process.exit(0); + }, 2000); + } + } + if (state === MIGRATION_STATE.ERROR) { + setTimeout(() => { + console.log('Exiting with error...'); + process.exit(1); + }, 2000); + } + }, [state, migrationStats, onComplete]); + + // Load projects + useEffect(() => { + const fetchProjects = async () => { + if (scope.project_id) { + setActiveProject(scope.project_id); + setState(MIGRATION_STATE.SELECT_SOURCE); + return; + } + try { + const { data: projectsData, error: projectsError } = + await getProjects(); + if (projectsError) { + setError(`Failed to load projects: ${projectsError}`); + setState(MIGRATION_STATE.ERROR); + return; + } + if (!projectsData || projectsData.length === 0) { + setError('No projects found'); + setState(MIGRATION_STATE.ERROR); + return; + } + if (projectsData.length === 1) { + setActiveProject(projectsData[0]!.id); + setState(MIGRATION_STATE.SELECT_SOURCE); + } else { + setProjects(projectsData.map(p => ({ label: p.name, value: p.id }))); + setState(MIGRATION_STATE.SELECT_PROJECT); + } + } catch (e) { + setError( + 'Error loading projects: ' + + (e instanceof Error ? e.message : UNKNOWN_ERROR), + ); + setState(MIGRATION_STATE.ERROR); + } + }; + fetchProjects(); + }, [getProjects, scope.project_id]); + + // Load environments + useEffect(() => { + const fetchEnvironments = async () => { + if (!activeProject) return; + try { + const { data: envData, error: envError } = + await getEnvironments(activeProject); + if (envError) { + setError(`Failed to load environments: ${envError}`); + setState(MIGRATION_STATE.ERROR); + return; + } + if (!envData || envData.length < 2) { + setError('You need at least two environments to perform migration'); + setState(MIGRATION_STATE.ERROR); + return; + } + setEnvironments( + envData.map(env => ({ label: env.name, value: env.id })), + ); + if (source) { + const found = envData.find(e => e.id === source || e.key === source); + if (found) { + setSourceEnv(found.id); + setState(MIGRATION_STATE.SELECT_TARGET); + } else if (state === MIGRATION_STATE.LOADING) { + setState(MIGRATION_STATE.SELECT_SOURCE); + } + } else if (state === MIGRATION_STATE.LOADING) { + setState(MIGRATION_STATE.SELECT_SOURCE); + } + if (target && sourceEnv) { + const found = envData.find(e => e.id === target || e.key === target); + if (found && found.id !== sourceEnv) { + setTargetEnv(found.id); + setState(MIGRATION_STATE.MIGRATING); + } + } + } catch (e) { + setError( + 'Error loading environments: ' + + (e instanceof Error ? e.message : UNKNOWN_ERROR), + ); + setState(MIGRATION_STATE.ERROR); + } + }; + if ( + activeProject && + ( + [ + MIGRATION_STATE.SELECT_SOURCE, + MIGRATION_STATE.LOADING, + ] as MigrationState[] + ).includes(state) + ) { + fetchEnvironments(); + } + }, [activeProject, getEnvironments, source, state, target, sourceEnv]); + + // Perform migration + useEffect(() => { + const performMigration = async () => { + if (state !== MIGRATION_STATE.MIGRATING || !sourceEnv || !targetEnv) + return; + try { + console.log(`DEBUG - Using project ID: ${scope.project_id}`); + console.log( + `DEBUG - Source env: ${sourceEnv}, Target env: ${targetEnv}`, + ); + let userStats = { total: 0, success: 0, failed: 0 }; + let assignmentStats = { total: 0, success: 0, failed: 0 }; + try { + console.log('Migrating users...'); + userStats = await migrateUsers( + sourceEnv, + targetEnv, + conflictStrategy, + ); + console.log('Waiting for user data to settle...'); + await new Promise(r => setTimeout(r, 1000)); + } catch (uErr) { + console.error(WARNING_TEXT, uErr); + } + try { + console.log('Migrating role assignments...'); + assignmentStats = await migrateRoleAssignments( + sourceEnv, + targetEnv, + conflictStrategy, + ); + } catch (aErr) { + console.error(WARNING_TEXT, aErr); + } + setMigrationStats({ + users: userStats, + roleAssignments: assignmentStats, + }); + setState(MIGRATION_STATE.DONE); + } catch (e) { + setError( + 'Migration failed: ' + + (e instanceof Error ? e.message : UNKNOWN_ERROR), + ); + setState(MIGRATION_STATE.ERROR); + } + }; + performMigration(); + }, [ + state, + sourceEnv, + targetEnv, + migrateUsers, + migrateRoleAssignments, + conflictStrategy, + scope.project_id, + ]); + + const handleProjectSelect = useCallback((item: { value: string }) => { + setActiveProject(item.value); + setState(MIGRATION_STATE.SELECT_SOURCE); + }, []); + + const handleSourceSelect = useCallback((item: { value: string }) => { + setSourceEnv(item.value); + setState(MIGRATION_STATE.SELECT_TARGET); + }, []); + + const handleTargetSelect = useCallback( + (item: { value: string }) => { + if (item.value === sourceEnv) { + setError(`${WARNING_TEXT} ${SAME_ENV_ERROR}`); + } else { + setTargetEnv(item.value); + setState(MIGRATION_STATE.MIGRATING); + } + }, + [sourceEnv], + ); + + return ( + + {state === MIGRATION_STATE.LOADING && ( + + Loading... + + )} + + {state === MIGRATION_STATE.SELECT_PROJECT && ( + <> + Select project: + + + )} + + {state === MIGRATION_STATE.SELECT_SOURCE && ( + <> + Select source environment: + + + )} + + {state === MIGRATION_STATE.SELECT_TARGET && sourceEnv && ( + <> + Select target environment: + e.value !== sourceEnv)} + onSelect={handleTargetSelect} + /> + + )} + + {state === MIGRATION_STATE.MIGRATING && ( + + Migrating data from{' '} + {environments.find(e => e.value === sourceEnv)?.label} to{' '} + {environments.find(e => e.value === targetEnv)?.label}... + + )} + + {state === MIGRATION_STATE.DONE && migrationStats && ( + <> + Migration completed successfully! + + Migration Summary: + + Users: {migrationStats.users.success}/{migrationStats.users.total}{' '} + migrated successfully + + + Role Assignments: {migrationStats.roleAssignments.success}/ + {migrationStats.roleAssignments.total} migrated successfully + + {migrationStats.users.failed > 0 && ( + + {WARNING_TEXT} {migrationStats.users.failed} users failed to + migrate + + )} + {migrationStats.roleAssignments.failed > 0 && ( + + {WARNING_TEXT} {migrationStats.roleAssignments.failed} role + assignments failed to migrate + + )} + + + )} + + {state === MIGRATION_STATE.ERROR && error && ( + <> + Error: {error} + Process will exit in a few seconds... + + )} + + ); +}; + +export default DataMigrationComponent; diff --git a/source/hooks/migration/types.ts b/source/hooks/migration/types.ts new file mode 100644 index 00000000..e5a2e624 --- /dev/null +++ b/source/hooks/migration/types.ts @@ -0,0 +1,49 @@ +export interface MigrationStats { + total: number; + success: number; + failed: number; + details?: string[]; +} + +export interface ResourceAction { + name: string; + description?: string; +} + +export interface ResourceAttribute { + type: string; + description?: string; +} + +export interface Resource { + key: string; + name?: string; + description?: string; + actions?: Record; + attributes?: Record; +} + +export interface User { + key: string; + email?: string; + first_name?: string; + last_name?: string; + attributes?: Record; +} + +export interface Role { + key: string; + name: string; + description?: string; + permissions?: string[]; + resource?: string; +} + +export interface RoleAssignment { + user: string | { key: string; [key: string]: unknown }; + role: string | { key: string; [key: string]: unknown }; + tenant?: string | { key: string; [key: string]: unknown }; + resource_instance?: string; +} + +export type ConflictStrategy = 'override' | 'fail'; diff --git a/source/hooks/migration/useMigrateResources.ts b/source/hooks/migration/useMigrateResources.ts new file mode 100644 index 00000000..9968dc10 --- /dev/null +++ b/source/hooks/migration/useMigrateResources.ts @@ -0,0 +1,230 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import { components } from '../../lib/api/v1.js'; + +type ResourceRead = components['schemas']['ResourceRead']; +type ResourceCreate = components['schemas']['ResourceCreate']; +type ResourceUpdatePayload = components['schemas']['ResourceUpdate']; +type ActionBlockEditable = components['schemas']['ActionBlockEditable']; +type AttributeBlockEditable = components['schemas']['AttributeBlockEditable']; +type RelationBlockRead = components['schemas']['RelationBlockRead']; + +const useMigrateResources = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + const migrateResources = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const cleanResourceDataForUpdate = ( + resource: ResourceRead, + ): ResourceUpdatePayload => { + const cleaned: ResourceUpdatePayload = { + name: resource.name || resource.key || '', + description: resource.description || '', + actions: {}, + attributes: {}, + relations: {}, + urn: resource.urn || undefined, + }; + + if (resource.actions) { + for (const actionKey in resource.actions) { + const action = resource.actions[actionKey]; + if (action) { + cleaned.actions![actionKey] = { + name: action.name || actionKey, + description: action.description || '', + attributes: action.attributes, + }; + } + } + } + + if (resource.attributes) { + for (const attrKey in resource.attributes) { + const attr = resource.attributes[attrKey]; + if (attr) { + cleaned.attributes![attrKey] = { + type: attr.type, + description: attr.description || '', + }; + } + } + } + + if (resource.relations) { + for (const relationKey in resource.relations) { + const relationInfo = resource.relations[relationKey]; + if (relationInfo && relationInfo.resource) { + cleaned.relations![relationKey] = relationInfo.resource; + } + } + } + return cleaned; + }; + + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + + try { + if (!scope.project_id) throw new Error('Project ID missing'); + + const { data: sourceResResp, error: sourceErr } = + await authenticatedApiClient().GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + { env_id: sourceEnvId }, + ); + + if (sourceErr) { + stats.details?.push(`Get source error: ${sourceErr}`); + return stats; + } + if (!sourceResResp) { + stats.details?.push('No source resources'); + return stats; + } + + const sourceResources: ReadonlyArray = Array.isArray( + sourceResResp, + ) + ? sourceResResp + : sourceResResp.data || []; + + const { data: targetResResp } = await authenticatedApiClient().GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + { env_id: targetEnvId }, + ); + const targetResources: ReadonlyArray = targetResResp + ? Array.isArray(targetResResp) + ? targetResResp + : targetResResp.data || [] + : []; + const targetResourceKeys = new Set(targetResources.map(r => r.key)); + + stats.total = sourceResources.length; + + for (const resource of sourceResources) { + try { + if (!resource?.key) { + stats.failed++; + continue; + } + + const resourceDataForPatch = cleanResourceDataForUpdate(resource); + + // Construct payload matching ResourceCreate schema for POST call + const resourceDataForPost: ResourceCreate = { + key: resource.key, + name: resource.name || resource.key, + description: resource.description || undefined, + urn: resource.urn || undefined, + actions: Object.entries(resource.actions || {}).reduce( + (acc, [key, action]) => { + acc[key] = { + name: action.name || key, + description: action.description || '', + attributes: action.attributes, + }; + return acc; + }, + {} as { [key: string]: ActionBlockEditable }, + ), + + attributes: Object.entries(resource.attributes || {}).reduce( + (acc, [key, attr]) => { + acc[key] = { + type: attr.type, + description: attr.description || '', + }; + return acc; + }, + {} as { [key: string]: AttributeBlockEditable }, + ), + + roles: resource.roles as + | { [key: string]: components['schemas']['RoleBlockEditable'] } + | undefined, + // Map relations: Transform from RelationBlockRead to {[key: string]: string} + relations: Object.entries(resource.relations || {}).reduce( + (acc, [key, relationInfo]) => { + const relInfo = relationInfo as RelationBlockRead; + if (relInfo && relInfo.resource) { + acc[key] = relInfo.resource; + } + return acc; + }, + {} as { [key: string]: string }, + ), + }; + + if (targetResourceKeys.has(resource.key)) { + if (conflictStrategy === 'override') { + try { + const updateResult = await authenticatedApiClient().PATCH( + `/v2/schema/{proj_id}/{env_id}/resources/{resource_id}`, + { resource_id: resource.key }, + resourceDataForPatch, + undefined, + ); + if (updateResult.error) { + stats.failed++; + stats.details?.push(`Update error: ${updateResult.error}`); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push(`Update exception: ${error}`); + } + } else { + stats.failed++; + stats.details?.push(`Conflict: ${resource.key}`); + } + } else { + try { + const createResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/resources`, + { env_id: targetEnvId }, + resourceDataForPost, + undefined, + ); + if (createResult.error) { + stats.failed++; + stats.details?.push(`Create error: ${createResult.error}`); + } else { + stats.success++; + targetResourceKeys.add(resource.key); + } + } catch (error) { + stats.failed++; + stats.details?.push(`Create exception: ${error}`); + } + } + } catch (resourceError) { + stats.failed++; + stats.details?.push(`Processing error: ${resourceError}`); + } + } + return stats; + } catch (err) { + stats.details?.push(`Migration error: ${err}`); + return stats; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + return { migrateResources }; +}; + +export default useMigrateResources; diff --git a/source/hooks/migration/useMigrateRoleAssignments.ts b/source/hooks/migration/useMigrateRoleAssignments.ts new file mode 100644 index 00000000..788abf21 --- /dev/null +++ b/source/hooks/migration/useMigrateRoleAssignments.ts @@ -0,0 +1,210 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import useMigrateRoles from './useMigrateRoles.js'; + +const ERROR_ROLE_DOES_NOT_EXIST = 'role does not exist'; +const ERROR_FAILED_TO_ASSIGN = 'Failed to assign role '; +const ERROR_ROLE_ASSIGNMENT = 'Role assignment migration error: '; +const ERROR_CREATING_ASSIGNMENT = 'Error creating assignment: '; +const ERROR_PROCESSING_ASSIGNMENT = 'Error processing assignment: '; +const ERROR_NO_ROLE_ASSIGNMENTS = + 'No role assignments found in source environment'; +const ERROR_NO_PROJECT_ID = 'Project ID is not available in the current scope'; +const ERROR_GETTING_ASSIGNMENTS = 'Error getting role assignments: '; +const UNKNOWN_ERROR = 'Unknown error'; + +const useMigrateRoleAssignments = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + const { getRoles } = useMigrateRoles(); + + const migrateRoleAssignments = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + + try { + if (!scope.project_id) { + throw new Error(ERROR_NO_PROJECT_ID); + } + + // Get role assignments + const { data: roleAssignmentsResponse, error: roleAssignmentsError } = + await authenticatedApiClient().GET( + `/v2/facts/{proj_id}/{env_id}/role_assignments`, + { + env_id: sourceEnvId, + }, + undefined, + { per_page: 100 }, + ); + + if (roleAssignmentsError) { + stats.details?.push( + `${ERROR_GETTING_ASSIGNMENTS}${roleAssignmentsError}`, + ); + return stats; + } + + if (!roleAssignmentsResponse) { + stats.details?.push(ERROR_NO_ROLE_ASSIGNMENTS); + return stats; + } + + // Handle different response formats + const assignments = Array.isArray(roleAssignmentsResponse) + ? roleAssignmentsResponse + : roleAssignmentsResponse && + typeof roleAssignmentsResponse === 'object' && + 'data' in roleAssignmentsResponse && + Array.isArray(roleAssignmentsResponse.data) + ? roleAssignmentsResponse.data + : []; + + // Filter for top-level assignments only + const topLevelAssignments = assignments.filter( + assignment => assignment && !assignment.resource_instance, + ); + + stats.total = topLevelAssignments.length; + + // Get the list of valid roles in the target environment + const { roles: targetRoles } = await getRoles(targetEnvId); + + // Create a set of valid role keys for tracking + const validRoleKeys = new Set( + targetRoles?.map(role => role?.key).filter(Boolean), + ); + + // Process each role assignment + for (let i = 0; i < topLevelAssignments.length; i++) { + const assignment = topLevelAssignments[i]; + + try { + if (!assignment || !assignment.user || !assignment.role) { + stats.failed++; + continue; + } + + // Extract string values for role and user + const roleKey = + typeof assignment.role === 'object' + ? assignment.role.key + : String(assignment.role); + + const userKey = + typeof assignment.user === 'object' + ? assignment.user.key + : String(assignment.user); + + const tenantKey = + assignment.tenant && typeof assignment.tenant === 'object' + ? assignment.tenant.key + : String(assignment.tenant || 'default'); + + // Check if role exists in target environment + if (!validRoleKeys.has(roleKey)) { + // Try to create the role on-the-fly as a fallback + try { + const createRoleResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/roles`, + { + env_id: targetEnvId, + }, + { + key: roleKey, + name: roleKey, + description: `Auto-created role during migration`, + }, + ); + + if (!createRoleResult.error) { + validRoleKeys.add(roleKey); + } + } catch { + // Continue with assignment attempt even if role creation fails + } + } + + // Create assignment object with proper typing + const assignmentData = { + user: userKey, + role: roleKey, + tenant: tenantKey, + }; + + try { + const createResult = await authenticatedApiClient().POST( + `/v2/facts/{proj_id}/{env_id}/role_assignments`, + { + env_id: targetEnvId, + }, + assignmentData, + ); + + if (createResult.error) { + // Handle specific error cases + if ( + createResult.error.includes && + (createResult.error.includes("could not find 'Role'") || + createResult.error.includes(ERROR_ROLE_DOES_NOT_EXIST)) + ) { + stats.failed++; + stats.details?.push( + `${ERROR_FAILED_TO_ASSIGN}${roleKey}: ${ERROR_ROLE_DOES_NOT_EXIST}`, + ); + } else if ( + createResult.error.includes && + createResult.error.includes('already exists') && + conflictStrategy === 'override' + ) { + // Consider as success if it already exists and override is enabled + stats.success++; + } else { + stats.failed++; + stats.details?.push( + `${ERROR_FAILED_TO_ASSIGN}${roleKey}: ${createResult.error}`, + ); + } + } else { + stats.success++; + } + } catch (createError) { + stats.failed++; + stats.details?.push( + `${ERROR_CREATING_ASSIGNMENT}${createError instanceof Error ? createError.message : UNKNOWN_ERROR}`, + ); + } + } catch (assignmentError) { + stats.failed++; + stats.details?.push( + `${ERROR_PROCESSING_ASSIGNMENT}${assignmentError instanceof Error ? assignmentError.message : UNKNOWN_ERROR}`, + ); + } + } + + return stats; + } catch (err) { + stats.details?.push( + `${ERROR_ROLE_ASSIGNMENT}${err instanceof Error ? err.message : UNKNOWN_ERROR}`, + ); + return stats; + } + }, + [authenticatedApiClient, getRoles, scope.project_id], + ); + + return { migrateRoleAssignments }; +}; + +export default useMigrateRoleAssignments; diff --git a/source/hooks/migration/useMigrateRoles.ts b/source/hooks/migration/useMigrateRoles.ts new file mode 100644 index 00000000..5c331364 --- /dev/null +++ b/source/hooks/migration/useMigrateRoles.ts @@ -0,0 +1,186 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import { components } from '../../lib/api/v1.js'; + +type RoleRead = components['schemas']['RoleRead']; +type RoleCreate = components['schemas']['RoleCreate']; +type RoleUpdatePayload = components['schemas']['RoleUpdate']; + +const useMigrateRoles = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + const getRoles = useCallback( + async ( + environmentId: string, + ): Promise<{ roles: RoleRead[]; error: string | null }> => { + if (!scope.project_id) return { roles: [], error: 'Project ID missing' }; + const rolesEndpoint = `/v2/schema/{proj_id}/{env_id}/roles`; + try { + const { data: rolesResponse, error } = + await authenticatedApiClient().GET(rolesEndpoint, { + env_id: environmentId, + }); + if (error) return { roles: [], error }; + if (rolesResponse) { + const roles = Array.isArray(rolesResponse) + ? rolesResponse + : rolesResponse.data || []; + if (roles.length > 0) { + return { roles: roles as RoleRead[], error: null }; + } + } + return { roles: [], error: 'No roles found' }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unknown error getting roles'; + return { roles: [], error: message }; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + const migrateRoles = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const cleanRoleDataForUpdate = (role: RoleRead): RoleUpdatePayload => { + // Uses the RoleRead type which has optional extends/attributes + return { + name: role.name || role.key, + description: role.description || undefined, + permissions: role.permissions || undefined, + extends: role.extends || undefined, + attributes: role.attributes || undefined, + }; + }; + + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + try { + if (!scope.project_id) throw new Error('Project ID missing'); + + const { roles: sourceRoles, error: sourceRolesError } = + await getRoles(sourceEnvId); + if (sourceRolesError) { + stats.details?.push(`Get source roles error: ${sourceRolesError}`); + return stats; + } + if (!sourceRoles || sourceRoles.length === 0) { + stats.details?.push('No source roles found'); + return stats; + } + + const { roles: targetRoles } = await getRoles(targetEnvId); + const targetRoleKeys = new Set( + targetRoles?.map(role => role.key).filter(Boolean), + ); + stats.total = sourceRoles.length; + + for (const role of sourceRoles) { + try { + if (!role?.key) { + stats.failed++; + continue; + } + + const roleDataForPost: RoleCreate = { + key: role.key, + name: role.name || role.key, + description: role.description || undefined, + permissions: role.permissions || undefined, + extends: role.extends || undefined, + attributes: role.attributes || undefined, + + granted_to: role.granted_to as + | components['schemas']['DerivedRoleBlockEdit'] + | undefined, + }; + const roleDataForPatch = cleanRoleDataForUpdate(role); + + if (targetRoleKeys.has(role.key)) { + if (conflictStrategy === 'override') { + try { + const updateEndpoint = `/v2/schema/{proj_id}/{env_id}/roles/{role_id}`; + const updateResult = await authenticatedApiClient().PATCH( + updateEndpoint, + { role_id: role.key }, + roleDataForPatch, + undefined, + ); + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Update role error ${role.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Update role exception ${role.key}: ${error}`, + ); + } + } else { + stats.failed++; + stats.details?.push(`Conflict role: ${role.key}`); + } + } else { + try { + const createResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/roles`, + { env_id: targetEnvId }, + roleDataForPost, + undefined, + ); + if (createResult.error) { + stats.failed++; + const errorMessage = + typeof createResult.error === 'string' + ? createResult.error + : JSON.stringify(createResult.error); + stats.details?.push( + `Create role error ${role.key}: ${errorMessage}`, + ); + } else { + stats.success++; + targetRoleKeys.add(role.key); + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Create role exception ${role.key}: ${error}`, + ); + } + } + } catch (roleError) { + stats.failed++; + stats.details?.push( + `Processing role error ${role.key}: ${roleError}`, + ); + } + } + return stats; + } catch (err) { + stats.details?.push(`Role migration error: ${err}`); + return stats; + } + }, + [getRoles, authenticatedApiClient, scope.project_id], + ); + + return { migrateRoles, getRoles }; +}; + +export default useMigrateRoles; diff --git a/source/hooks/migration/useMigrateUsers.ts b/source/hooks/migration/useMigrateUsers.ts new file mode 100644 index 00000000..f79221a4 --- /dev/null +++ b/source/hooks/migration/useMigrateUsers.ts @@ -0,0 +1,163 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import { components } from '../../lib/api/v1.js'; + +// Define a type for the API response +interface ApiResponse { + data?: T; + error: string | null; + response?: Response; + status?: number; +} + +const useMigrateUsers = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + const migrateUsers = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + try { + if (!scope.project_id) throw new Error('Project ID missing'); + + const { data: sourceUsersResponse } = + await authenticatedApiClient().GET( + `/v2/facts/{proj_id}/{env_id}/users`, + { env_id: sourceEnvId }, + undefined, + { per_page: 100 }, + ); + + if (!sourceUsersResponse) { + stats.details?.push('No source users'); + return stats; + } + + const users: ReadonlyArray = + Array.isArray(sourceUsersResponse) + ? sourceUsersResponse + : sourceUsersResponse.data || []; + + stats.total = users.length; + + // Get the API client + const client = authenticatedApiClient(); + + const post = client.POST as ( + path: string, + pathParams: Record, + body: unknown, + queryParams?: Record, + ) => Promise>; + + const put = client.PUT as ( + path: string, + pathParams: Record, + body: unknown, + queryParams?: Record, + ) => Promise>; + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + try { + if (!user || !user.key) { + stats.failed++; + continue; + } + + // Construct payload matching UserCreate schema + const userData: components['schemas']['UserCreate'] = { + key: user.key, + email: user.email || undefined, + first_name: user.first_name || undefined, + last_name: user.last_name || undefined, + attributes: user.attributes || {}, + }; + + try { + const createResult = await post( + `/v2/facts/{proj_id}/{env_id}/users`, + { env_id: targetEnvId }, + userData, + undefined, + ); + + if (createResult.error) { + const errorMessage = + typeof createResult.error === 'string' + ? createResult.error + : JSON.stringify(createResult.error); + + if ( + errorMessage.includes('already exists') && + conflictStrategy === 'override' + ) { + try { + const updateResult = await put( + `/v2/facts/{proj_id}/{env_id}/users/{user_id}`, + { env_id: targetEnvId, user_id: user.key }, + userData, + undefined, + ); + + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Update user error ${user.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Update user exception ${user.key}: ${error}`, + ); + } + } else { + stats.failed++; + stats.details?.push( + `Create user error ${user.key}: ${errorMessage}`, + ); + } + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Create/Update user exception ${user.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } catch (userError) { + const userKey = user?.key || `unknown_user_at_index_${i}`; + stats.failed++; + stats.details?.push( + `Processing user error ${userKey}: ${userError instanceof Error ? userError.message : 'Unknown error'}`, + ); + } + } + return stats; + } catch (err) { + stats.details?.push(`User migration error: ${err}`); + return stats; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + return { migrateUsers }; +}; + +export default useMigrateUsers; diff --git a/source/hooks/useDataMigration.tsx b/source/hooks/useDataMigration.tsx new file mode 100644 index 00000000..0aec6970 --- /dev/null +++ b/source/hooks/useDataMigration.tsx @@ -0,0 +1,127 @@ +import { useCallback } from 'react'; +import useMigrateUsers from './migration/useMigrateUsers.js'; +import useMigrateRoles from './migration/useMigrateRoles.js'; +import useMigrateResources from './migration/useMigrateResources.js'; +import useMigrateRoleAssignments from './migration/useMigrateRoleAssignments.js'; +import { ConflictStrategy } from './migration/types.js'; + +export const useDataMigration = () => { + const { migrateUsers } = useMigrateUsers(); + const { migrateRoles } = useMigrateRoles(); + const { migrateResources } = useMigrateResources(); + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + + const migrateAllData = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + options: { + skipUsers?: boolean; + skipResources?: boolean; + conflictStrategy?: ConflictStrategy; + } = {}, + ) => { + const { + skipUsers = false, + skipResources = false, + conflictStrategy = 'override', + } = options; + + const results = { + users: { total: 0, success: 0, failed: 0 }, + roles: { total: 0, success: 0, failed: 0 }, + resources: { total: 0, success: 0, failed: 0 }, + roleAssignments: { total: 0, success: 0, failed: 0 }, + }; + + try { + // First, migrate resources if not skipped (resources should come first) + if (!skipResources) { + console.log('Migrating resources...'); + const resourceStats = await migrateResources( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.resources = { + total: resourceStats.total, + success: resourceStats.success, + failed: resourceStats.failed, + }; + console.log( + `Resources migrated: ${resourceStats.success}/${resourceStats.total}`, + ); + } + + // Then, migrate users if not skipped + if (!skipUsers) { + console.log('Migrating users...'); + const userStats = await migrateUsers( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.users = { + total: userStats.total, + success: userStats.success, + failed: userStats.failed, + }; + console.log( + `Users migrated: ${userStats.success}/${userStats.total}`, + ); + + // Migrate roles (needed for role assignments) + console.log('Migrating roles...'); + const roleStats = await migrateRoles( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.roles = { + total: roleStats.total, + success: roleStats.success, + failed: roleStats.failed, + }; + console.log( + `Roles migrated: ${roleStats.success}/${roleStats.total}`, + ); + + // Add a small delay to ensure users and roles are processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Finally, migrate role assignments + console.log('Migrating role assignments...'); + const roleAssignmentStats = await migrateRoleAssignments( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.roleAssignments = { + total: roleAssignmentStats.total, + success: roleAssignmentStats.success, + failed: roleAssignmentStats.failed, + }; + console.log( + `Role assignments migrated: ${roleAssignmentStats.success}/${roleAssignmentStats.total}`, + ); + } + + return results; + } catch (error) { + console.error('Migration error:', error); + throw error; + } + }, + [migrateUsers, migrateRoles, migrateResources, migrateRoleAssignments], + ); + + return { + migrateAllData, + migrateUsers, + migrateRoles, + migrateResources, + migrateRoleAssignments, + }; +}; + +export default useDataMigration; diff --git a/tests/components/env/DataMigrationComponent.test.tsx b/tests/components/env/DataMigrationComponent.test.tsx new file mode 100644 index 00000000..d20880c2 --- /dev/null +++ b/tests/components/env/DataMigrationComponent.test.tsx @@ -0,0 +1,236 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock React hooks +vi.mock('react', () => { + const originalModule = vi.importActual('react'); + return { + ...originalModule, + useCallback: (callback: any) => callback, + useMemo: (factory: any) => factory(), + }; +}); + +// Create mock functions +const mockMigrateUsers = vi.fn(); +const mockMigrateRoles = vi.fn(); +const mockMigrateResources = vi.fn(); +const mockMigrateRoleAssignments = vi.fn(); + +// Mock dependencies +vi.mock('../../../source/hooks/migration/useMigrateUsers', () => ({ + default: () => ({ + migrateUsers: mockMigrateUsers, + }), +})); + +vi.mock('../../../source/hooks/migration/useMigrateRoles', () => ({ + default: () => ({ + migrateRoles: mockMigrateRoles, + }), +})); + +vi.mock('../../../source/hooks/migration/useMigrateResources', () => ({ + default: () => ({ + migrateResources: mockMigrateResources, + }), +})); + +vi.mock('../../../source/hooks/migration/useMigrateRoleAssignments', () => ({ + default: () => ({ + migrateRoleAssignments: mockMigrateRoleAssignments, + }), +})); + +vi.mock('../../../source/hooks/useDataMigration', async importOriginal => { + const mod = await importOriginal(); + return { + ...mod, + default: () => { + const original = mod.default(); + const originalMigrateAllData = original.migrateAllData; + + return { + ...original, + migrateAllData: async (sourceEnvId, targetEnvId, options) => { + const originalSetTimeout = global.setTimeout; + global.setTimeout = fn => { + fn(); + return 0 as any; + }; + + try { + return await originalMigrateAllData( + sourceEnvId, + targetEnvId, + options, + ); + } finally { + global.setTimeout = originalSetTimeout; + } + }, + }; + }, + }; +}); + +import useDataMigration from '../../../source/hooks/useDataMigration'; + +describe('useDataMigration', () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Mock console methods + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should migrate all data in correct order', async () => { + mockMigrateResources.mockResolvedValue({ total: 5, success: 5, failed: 0 }); + mockMigrateUsers.mockResolvedValue({ total: 10, success: 10, failed: 0 }); + mockMigrateRoles.mockResolvedValue({ total: 3, success: 3, failed: 0 }); + mockMigrateRoleAssignments.mockResolvedValue({ + total: 8, + success: 8, + failed: 0, + }); + + const { migrateAllData } = useDataMigration(); + const result = await migrateAllData('source-env', 'target-env'); + + // Verify results + expect(result).toEqual({ + resources: { total: 5, success: 5, failed: 0 }, + users: { total: 10, success: 10, failed: 0 }, + roles: { total: 3, success: 3, failed: 0 }, + roleAssignments: { total: 8, success: 8, failed: 0 }, + }); + + // Verify calls were made with correct parameters + expect(mockMigrateResources).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'override', + ); + expect(mockMigrateUsers).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'override', + ); + expect(mockMigrateRoles).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'override', + ); + expect(mockMigrateRoleAssignments).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'override', + ); + + // Basic order verification based on mock calls + const resourcesCallIndex = mockMigrateResources.mock.invocationCallOrder[0]; + const usersCallIndex = mockMigrateUsers.mock.invocationCallOrder[0]; + const rolesCallIndex = mockMigrateRoles.mock.invocationCallOrder[0]; + const roleAssignmentsCallIndex = + mockMigrateRoleAssignments.mock.invocationCallOrder[0]; + + expect(resourcesCallIndex).toBeLessThan(usersCallIndex); + expect(usersCallIndex).toBeLessThan(rolesCallIndex); + expect(rolesCallIndex).toBeLessThan(roleAssignmentsCallIndex); + }, 10000); + + it('should respect skipResources option', async () => { + mockMigrateUsers.mockResolvedValue({ total: 10, success: 10, failed: 0 }); + mockMigrateRoles.mockResolvedValue({ total: 3, success: 3, failed: 0 }); + mockMigrateRoleAssignments.mockResolvedValue({ + total: 8, + success: 8, + failed: 0, + }); + + const { migrateAllData } = useDataMigration(); + const result = await migrateAllData('source-env', 'target-env', { + skipResources: true, + }); + + // Verify resources were skipped + expect(mockMigrateResources).not.toHaveBeenCalled(); + expect(mockMigrateUsers).toHaveBeenCalled(); + expect(mockMigrateRoles).toHaveBeenCalled(); + expect(mockMigrateRoleAssignments).toHaveBeenCalled(); + expect(result.resources).toEqual({ total: 0, success: 0, failed: 0 }); + }, 10000); + + it('should respect skipUsers option', async () => { + mockMigrateResources.mockResolvedValue({ total: 5, success: 5, failed: 0 }); + + const { migrateAllData } = useDataMigration(); + const result = await migrateAllData('source-env', 'target-env', { + skipUsers: true, + }); + + // Verify users were skipped + expect(mockMigrateResources).toHaveBeenCalled(); + expect(mockMigrateUsers).not.toHaveBeenCalled(); + expect(mockMigrateRoles).not.toHaveBeenCalled(); + expect(mockMigrateRoleAssignments).not.toHaveBeenCalled(); + expect(result.users).toEqual({ total: 0, success: 0, failed: 0 }); + }); + + it('should respect conflictStrategy option', async () => { + mockMigrateResources.mockResolvedValue({ total: 5, success: 5, failed: 0 }); + mockMigrateUsers.mockResolvedValue({ total: 10, success: 10, failed: 0 }); + mockMigrateRoles.mockResolvedValue({ total: 3, success: 3, failed: 0 }); + mockMigrateRoleAssignments.mockResolvedValue({ + total: 8, + success: 8, + failed: 0, + }); + + const { migrateAllData } = useDataMigration(); + await migrateAllData('source-env', 'target-env', { + conflictStrategy: 'fail', + }); + + // Verify conflict strategy was passed to all migrations + expect(mockMigrateResources).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'fail', + ); + expect(mockMigrateUsers).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'fail', + ); + expect(mockMigrateRoles).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'fail', + ); + expect(mockMigrateRoleAssignments).toHaveBeenCalledWith( + 'source-env', + 'target-env', + 'fail', + ); + }, 10000); + + it('should handle errors during migration', async () => { + // Make resources migration throw an error + mockMigrateResources.mockRejectedValue( + new Error('Resources migration failed'), + ); + + const { migrateAllData } = useDataMigration(); + await expect(migrateAllData('source-env', 'target-env')).rejects.toThrow( + 'Resources migration failed', + ); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith( + 'Migration error:', + expect.any(Error), + ); + }); +}); diff --git a/tests/hooks/migration/useMigrateResources.test.ts b/tests/hooks/migration/useMigrateResources.test.ts new file mode 100644 index 00000000..a74cad9c --- /dev/null +++ b/tests/hooks/migration/useMigrateResources.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock React first - this is critical +vi.mock('react', () => { + const originalModule = vi.importActual('react'); + return { + ...originalModule, + // Make useCallback just return the callback function + useCallback: (callback: any) => callback, + // Make useMemo just return the value + useMemo: (factory: any) => factory(), + }; +}); + +// Create mock functions +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); + +// Mock all required dependencies +vi.mock('../../../source/hooks/useClient', () => ({ + default: () => ({ + authenticatedApiClient: () => ({ + GET: mockGet, + POST: mockPost, + PATCH: mockPatch, + }), + }), +})); + +vi.mock('../../../source/components/AuthProvider', () => ({ + useAuth: () => ({ + scope: { + project_id: 'test-project', + }, + }), +})); + +// Import the module under test AFTER mocking dependencies +import useMigrateResources from '../../../source/hooks/migration/useMigrateResources'; + +describe('useMigrateResources', () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + it('should migrate resources successfully', async () => { + // Mock source resources + mockGet.mockResolvedValueOnce({ + data: [ + { + key: 'document', + name: 'Document', + description: 'Document resource', + actions: { read: { name: 'Read', description: 'Read document' } }, + attributes: { + status: { type: 'string', description: 'Document status' }, + }, + relations: { owner: { resource: 'user' } }, + }, + { + key: 'project', + name: 'Project', + description: 'Project resource', + actions: { edit: { name: 'Edit', description: 'Edit project' } }, + }, + ], + error: null, + }); + + // Mock target resources (empty) + mockGet.mockResolvedValueOnce({ + data: [], + error: null, + }); + + // Mock successful POST responses + mockPost + .mockResolvedValueOnce({ error: null }) + .mockResolvedValueOnce({ error: null }); + + const { migrateResources } = useMigrateResources(); + const result = await migrateResources('source-env', 'target-env'); + + expect(result.total).toBe(2); + expect(result.success).toBe(2); + expect(result.failed).toBe(0); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('should handle conflict with override strategy', async () => { + // Mock source resources + const resource = { + key: 'document', + name: 'Document', + description: 'Updated description', + actions: { read: { name: 'Read', description: 'Read document' } }, + }; + + mockGet.mockResolvedValueOnce({ + data: [resource], + error: null, + }); + + // Mock target resources (contains the same resource) + mockGet.mockResolvedValueOnce({ + data: [{ ...resource, description: 'Old description' }], + error: null, + }); + + // Mock successful PATCH response + mockPatch.mockResolvedValueOnce({ error: null }); + + const { migrateResources } = useMigrateResources(); + const result = await migrateResources( + 'source-env', + 'target-env', + 'override', + ); + + expect(result.total).toBe(1); + expect(result.success).toBe(1); + expect(result.failed).toBe(0); + expect(mockPatch).toHaveBeenCalledTimes(1); + }); + + it('should handle conflict with fail strategy', async () => { + // Mock source resources + const resource = { + key: 'document', + name: 'Document', + description: 'New description', + }; + + mockGet.mockResolvedValueOnce({ + data: [resource], + error: null, + }); + + // Mock target resources (contains the same resource) + mockGet.mockResolvedValueOnce({ + data: [{ ...resource, description: 'Old description' }], + error: null, + }); + + const { migrateResources } = useMigrateResources(); + const result = await migrateResources('source-env', 'target-env', 'fail'); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); + expect(result.failed).toBe(1); + expect(mockPatch).not.toHaveBeenCalled(); + }); + + it('should handle API errors', async () => { + // Mock source resources + mockGet.mockResolvedValueOnce({ + data: [{ key: 'document', name: 'Document' }], + error: null, + }); + + // Mock target resources (empty) + mockGet.mockResolvedValueOnce({ + data: [], + error: null, + }); + + // Mock POST error + mockPost.mockResolvedValueOnce({ error: 'API error' }); + + const { migrateResources } = useMigrateResources(); + const result = await migrateResources('source-env', 'target-env'); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); + expect(result.failed).toBe(1); + + expect(result.details?.[0]).toMatch('Create error'); + }); +}); diff --git a/tests/hooks/migration/useMigrateRoleAssignments.test.ts b/tests/hooks/migration/useMigrateRoleAssignments.test.ts new file mode 100644 index 00000000..7bf5b282 --- /dev/null +++ b/tests/hooks/migration/useMigrateRoleAssignments.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('react', () => { + const originalModule = vi.importActual('react'); + return { + ...originalModule, + // Make useCallback just return the callback function + useCallback: (callback: any) => callback, + // Make useMemo just return the value + useMemo: (factory: any) => factory(), + }; +}); + +// Create mock functions +const mockGet = vi.fn(); +const mockPost = vi.fn(); + +// Mock all required dependencies +vi.mock('../../../source/hooks/useClient', () => ({ + default: () => ({ + authenticatedApiClient: () => ({ + GET: mockGet, + POST: mockPost, + }), + }), +})); + +vi.mock('../../../source/components/AuthProvider', () => ({ + useAuth: () => ({ + scope: { + project_id: 'test-project', + }, + }), +})); + +vi.mock('../../../source/hooks/migration/useMigrateRoles', () => ({ + default: () => ({ + getRoles: () => + Promise.resolve({ + roles: [{ key: 'admin' }, { key: 'editor' }], + error: null, + }), + }), +})); + +import useMigrateRoleAssignments from '../../../source/hooks/migration/useMigrateRoleAssignments'; + +describe('useMigrateRoleAssignments', () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + it('should migrate role assignments successfully', async () => { + // Mock source role assignments + mockGet.mockResolvedValueOnce({ + data: [ + { user: 'user1', role: 'admin', tenant: 'default' }, + { user: 'user2', role: 'editor', tenant: 'default' }, + ], + error: null, + }); + + // Mock successful POST responses + mockPost + .mockResolvedValueOnce({ error: null }) + .mockResolvedValueOnce({ error: null }); + + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + const result = await migrateRoleAssignments('source-env', 'target-env'); + + expect(result.total).toBe(2); + expect(result.success).toBe(2); + expect(result.failed).toBe(0); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('should handle role that does not exist in target environment', async () => { + // Mock source role assignments + mockGet.mockResolvedValueOnce({ + data: [{ user: 'user1', role: 'nonexistent', tenant: 'default' }], + error: null, + }); + + // Mock POST attempt with error for non-existent role + mockPost.mockResolvedValueOnce({ error: "could not find 'Role'" }); + + // Then mock POST attempt to create the role + mockPost.mockResolvedValueOnce({ error: null }); + + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + const result = await migrateRoleAssignments('source-env', 'target-env'); + + expect(result.total).toBe(1); + expect(result.failed).toBe(0); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('should handle conflicts with override strategy', async () => { + // Mock source role assignments + mockGet.mockResolvedValueOnce({ + data: [{ user: 'user1', role: 'admin', tenant: 'default' }], + error: null, + }); + + // Mock POST response indicating the assignment already exists + mockPost.mockResolvedValueOnce({ error: 'already exists' }); + + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + const result = await migrateRoleAssignments( + 'source-env', + 'target-env', + 'override', + ); + + expect(result.total).toBe(1); + expect(result.success).toBe(1); + expect(result.failed).toBe(0); + }); + + it('should handle conflicts with fail strategy', async () => { + // Mock source role assignments + mockGet.mockResolvedValueOnce({ + data: [{ user: 'user1', role: 'admin', tenant: 'default' }], + error: null, + }); + + // Mock POST response indicating the assignment already exists + mockPost.mockResolvedValueOnce({ error: 'already exists' }); + + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + const result = await migrateRoleAssignments( + 'source-env', + 'target-env', + 'fail', + ); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); + expect(result.failed).toBe(1); + }); + + it('should handle API errors', async () => { + // Mock source role assignments + mockGet.mockResolvedValueOnce({ + data: [{ user: 'user1', role: 'admin', tenant: 'default' }], + error: null, + }); + + // Mock POST error with a distinct error message that won't trigger special handling + mockPost.mockResolvedValueOnce({ + error: 'Generic API error that does not match any special conditions', + }); + + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + const result = await migrateRoleAssignments('source-env', 'target-env'); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); // No successes + expect(result.failed).toBe(1); // One failure + }); + + it('should filter out invalid assignments', async () => { + // Mock source role assignments with some invalid ones + mockGet.mockResolvedValueOnce({ + data: [ + { user: 'user1', role: 'admin', tenant: 'default' }, // Valid + { /* missing user */ role: 'admin', tenant: 'default' }, // Invalid + { user: 'user2', /* missing role */ tenant: 'default' }, // Invalid + ], + error: null, + }); + + // Mock successful POST response for the valid assignment + mockPost.mockResolvedValueOnce({ error: null }); // Ensure this returns success + + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + const result = await migrateRoleAssignments('source-env', 'target-env'); + + expect(result.total).toBe(3); + expect(result.success).toBe(1); + expect(result.failed).toBe(2); + expect(mockPost).toHaveBeenCalledTimes(1); // Only one valid POST + }); +}); diff --git a/tests/hooks/migration/useMigrateRoles.test.ts b/tests/hooks/migration/useMigrateRoles.test.ts new file mode 100644 index 00000000..0608f7b2 --- /dev/null +++ b/tests/hooks/migration/useMigrateRoles.test.ts @@ -0,0 +1,171 @@ +// tests/hooks/migration/useMigrateRoles.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock React first - this is critical +vi.mock('react', () => { + const originalModule = vi.importActual('react'); + return { + ...originalModule, + // Make useCallback just return the callback function + useCallback: (callback: any) => callback, + // Make useMemo just return the value + useMemo: (factory: any) => factory(), + }; +}); + +// Create mock functions +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); + +// Mock all required dependencies +vi.mock('../../../source/hooks/useClient', () => ({ + default: () => ({ + authenticatedApiClient: () => ({ + GET: mockGet, + POST: mockPost, + PATCH: mockPatch, + }), + }), +})); + +vi.mock('../../../source/components/AuthProvider', () => ({ + useAuth: () => ({ + scope: { + project_id: 'test-project', + }, + }), +})); + +// Import the module under test AFTER mocking dependencies +import useMigrateRoles from '../../../source/hooks/migration/useMigrateRoles'; + +describe('useMigrateRoles', () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + describe('getRoles', () => { + it('should fetch roles successfully', async () => { + const mockRoles = [ + { key: 'admin', name: 'Admin', description: 'Administrator role' }, + { key: 'editor', name: 'Editor', description: 'Editor role' }, + ]; + + mockGet.mockResolvedValueOnce({ data: mockRoles, error: null }); + + const { getRoles } = useMigrateRoles(); + const result = await getRoles('env-id'); + + expect(result.roles).toEqual(mockRoles); + expect(result.error).toBeNull(); + expect(mockGet).toHaveBeenCalledWith( + '/v2/schema/{proj_id}/{env_id}/roles', + { env_id: 'env-id' }, + ); + }); + + it('should handle empty roles', async () => { + mockGet.mockResolvedValueOnce({ data: [], error: null }); + + const { getRoles } = useMigrateRoles(); + const result = await getRoles('env-id'); + + expect(result.roles).toEqual([]); + expect(result.error).toBe('No roles found'); + }); + + it('should handle API errors', async () => { + mockGet.mockResolvedValueOnce({ data: null, error: 'API error' }); + + const { getRoles } = useMigrateRoles(); + const result = await getRoles('env-id'); + + expect(result.roles).toEqual([]); + expect(result.error).toBe('API error'); + }); + }); + + describe('migrateRoles', () => { + it('should migrate roles successfully', async () => { + // Mock source roles + mockGet.mockResolvedValueOnce({ + data: [ + { key: 'admin', name: 'Admin', description: 'Administrator role' }, + { key: 'editor', name: 'Editor', description: 'Editor role' }, + ], + error: null, + }); + + // Mock target roles (empty) + mockGet.mockResolvedValueOnce({ data: [], error: null }); + + // Mock successful POST responses + mockPost + .mockResolvedValueOnce({ error: null }) + .mockResolvedValueOnce({ error: null }); + + const { migrateRoles } = useMigrateRoles(); + const result = await migrateRoles('source-env', 'target-env'); + + expect(result.total).toBe(2); + expect(result.success).toBe(2); + expect(result.failed).toBe(0); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('should handle conflict with override strategy', async () => { + // Mock source roles + mockGet.mockResolvedValueOnce({ + data: [ + { key: 'admin', name: 'Admin', description: 'Administrator role' }, + ], + error: null, + }); + + // Mock target roles (contains the same role) + mockGet.mockResolvedValueOnce({ + data: [{ key: 'admin', name: 'Admin', description: 'Old description' }], + error: null, + }); + + // Mock successful PATCH response + mockPatch.mockResolvedValueOnce({ error: null }); + + const { migrateRoles } = useMigrateRoles(); + const result = await migrateRoles('source-env', 'target-env', 'override'); + + expect(result.total).toBe(1); + expect(result.success).toBe(1); + expect(result.failed).toBe(0); + expect(mockPost).not.toHaveBeenCalled(); + expect(mockPatch).toHaveBeenCalledTimes(1); + }); + + it('should handle conflict with fail strategy', async () => { + // Mock source roles + mockGet.mockResolvedValueOnce({ + data: [ + { key: 'admin', name: 'Admin', description: 'Administrator role' }, + ], + error: null, + }); + + // Mock target roles (contains the same role) + mockGet.mockResolvedValueOnce({ + data: [{ key: 'admin', name: 'Admin', description: 'Old description' }], + error: null, + }); + + const { migrateRoles } = useMigrateRoles(); + const result = await migrateRoles('source-env', 'target-env', 'fail'); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); + expect(result.failed).toBe(1); + expect(mockPost).not.toHaveBeenCalled(); + expect(mockPatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/hooks/migration/useMigrateUsers.test.ts b/tests/hooks/migration/useMigrateUsers.test.ts new file mode 100644 index 00000000..b83071b8 --- /dev/null +++ b/tests/hooks/migration/useMigrateUsers.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock React first - this is critical +vi.mock('react', () => { + const originalModule = vi.importActual('react'); + return { + ...originalModule, + // Make useCallback just return the callback function + useCallback: (callback: any) => callback, + // Make useMemo just return the value + useMemo: (factory: any) => factory(), + }; +}); + +// Create mock functions +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPut = vi.fn(); + +// Mock all required dependencies +vi.mock('../../../source/hooks/useClient', () => ({ + default: () => ({ + authenticatedApiClient: () => ({ + GET: mockGet, + POST: mockPost, + PUT: mockPut, + }), + }), +})); + +vi.mock('../../../source/components/AuthProvider', () => ({ + useAuth: () => ({ + scope: { + project_id: 'test-project', + }, + }), +})); + +// Import the module under test AFTER mocking dependencies +import useMigrateUsers from '../../../source/hooks/migration/useMigrateUsers'; + +describe('useMigrateUsers', () => { + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + }); + + it('should handle empty user list', async () => { + // Mock null response data + mockGet.mockResolvedValueOnce({ data: null, error: null }); + + const { migrateUsers } = useMigrateUsers(); + const result = await migrateUsers('source-env', 'target-env'); + + expect(result.total).toBe(0); + expect(result.success).toBe(0); + expect(result.failed).toBe(0); + expect(result.details).toContain('No source users'); + }); + + it('should migrate users successfully', async () => { + // Mock source users + mockGet.mockResolvedValueOnce({ + data: [ + { + key: 'user1', + email: 'user1@example.com', + first_name: 'User', + last_name: 'One', + }, + { + key: 'user2', + email: 'user2@example.com', + first_name: 'User', + last_name: 'Two', + }, + ], + error: null, + }); + + // Mock successful POST responses + mockPost + .mockResolvedValueOnce({ error: null }) + .mockResolvedValueOnce({ error: null }); + + const { migrateUsers } = useMigrateUsers(); + const result = await migrateUsers('source-env', 'target-env'); + + expect(result.total).toBe(2); + expect(result.success).toBe(2); + expect(result.failed).toBe(0); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('should handle conflict with override strategy', async () => { + // Mock source users + mockGet.mockResolvedValueOnce({ + data: [ + { + key: 'user1', + email: 'user1@example.com', + first_name: 'User', + last_name: 'One', + }, + ], + error: null, + }); + + // Mock POST response with conflict + mockPost.mockResolvedValueOnce({ + error: 'User with key user1 already exists', + }); + + // Mock successful PUT response for override + mockPut.mockResolvedValueOnce({ error: null }); + + const { migrateUsers } = useMigrateUsers(); + const result = await migrateUsers('source-env', 'target-env', 'override'); + + expect(result.total).toBe(1); + expect(result.success).toBe(1); + expect(result.failed).toBe(0); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledTimes(1); + }); + + it('should handle conflict with fail strategy', async () => { + // Mock source users + mockGet.mockResolvedValueOnce({ + data: [ + { + key: 'user1', + email: 'user1@example.com', + first_name: 'User', + last_name: 'One', + }, + ], + error: null, + }); + + // Mock POST response with conflict + mockPost.mockResolvedValueOnce({ + error: 'User with key user1 already exists', + }); + + const { migrateUsers } = useMigrateUsers(); + const result = await migrateUsers('source-env', 'target-env', 'fail'); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); + expect(result.failed).toBe(1); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPut).not.toHaveBeenCalled(); + }); + + it('should handle API errors', async () => { + // Mock API error + mockGet.mockResolvedValueOnce({ data: null, error: 'API error' }); + + const { migrateUsers } = useMigrateUsers(); + const result = await migrateUsers('source-env', 'target-env'); + + expect(result.details).toContain('No source users'); + }); + + it('should handle unexpected exceptions', async () => { + // Mock source users + mockGet.mockResolvedValueOnce({ + data: [{ key: 'user1', email: 'user1@example.com' }], + error: null, + }); + + // Mock POST to throw exception + mockPost.mockImplementationOnce(() => { + throw new Error('Unexpected error'); + }); + + const { migrateUsers } = useMigrateUsers(); + const result = await migrateUsers('source-env', 'target-env'); + + expect(result.total).toBe(1); + expect(result.success).toBe(0); + expect(result.failed).toBe(1); + expect(result.details?.[0]).toContain('Unexpected error'); + }); +});