diff --git a/source/commands/graph.tsx b/source/commands/graph.tsx new file mode 100644 index 0000000..61dae49 --- /dev/null +++ b/source/commands/graph.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { AuthProvider } from '../components/AuthProvider.js'; +import Graph from '../components/graphCommand.js'; +import { type infer as zInfer, object, string } from 'zod'; +import { option } from 'pastel'; + +export const options = object({ + apiKey: string() + .optional() + .describe( + option({ + description: 'The API key for the Permit env, project or Workspace', + }), + ), +}); + +type Props = { + options: zInfer; +}; + +export default function graph({ options }: Props) { + return ( + + + + ); +} diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts new file mode 100644 index 0000000..832764e --- /dev/null +++ b/source/components/HtmlGraphSaver.ts @@ -0,0 +1,1394 @@ +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; +import open from 'open'; + +// Define the data structure for a graph node's data +interface GraphNodeData { + id: string; + label: string; +} + +// Define a graph node; `classes` is optional +interface GraphNode { + data: GraphNodeData; + classes?: string; +} + +// Define the data structure for a graph edge's data +interface GraphEdgeData { + source: string; + target: string; + label: string; +} + +// Define a graph edge; here `classes` is required +interface GraphEdge { + data: GraphEdgeData; + classes: string; +} + +// Define the overall GraphData structure +interface GraphData { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +export const saveHTMLGraph = (graphData: GraphData) => { + const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); + const htmlTemplate = ` + + + + + + ReBAC Graph + + + + + + +
Permit ReBAC Graph
+ +
+ +
+ + + +
+ + + + + + +`; + + writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); + console.log(`Graph saved as: ${outputHTMLPath}`); + open(outputHTMLPath); +}; diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts new file mode 100644 index 0000000..efff3de --- /dev/null +++ b/source/components/generateGraphData.ts @@ -0,0 +1,168 @@ +// Define types +type ResourceInstance = { + label: string; + value: string; + id: string; + id2: string; +}; + +type Relationship = { + label: string; + objectId: string; + id: string; + subjectId: string; + Object: string; +}; + +type RoleAssignment = { + user: string; + email: string; + role: string; + resourceInstance: string; +}; + +const ResourceInstanceClass = 'resource-instance-node'; + +// Generate Graph Data +export const generateGraphData = ( + resources: ResourceInstance[], + relationships: Map, + roleAssignments: RoleAssignment[], +) => { + const nodes: { data: { id: string; label: string }; classes?: string }[] = + resources.map(resource => ({ + data: { id: resource.id, label: ` ${resource.label}`, id2: resource.id2 }, + classes: ResourceInstanceClass, + })); + + const edges: { + data: { source: string; target: string; label: string }; + classes?: string; + }[] = []; + const existingNodeIds = new Set(nodes.map(node => node.data.id)); + + relationships.forEach((relations, resourceId) => { + relations.forEach(relation => { + if (!existingNodeIds.has(relation.Object)) { + nodes.push({ + data: { id: relation.Object, label: `${relation.Object}` }, + classes: 'object-node', + }); + existingNodeIds.add(relation.Object); + } + + if (resourceId !== relation.Object) { + // Check if an edge with the same source, target, and label already exists. + const exists = edges.some( + edge => + edge.data.source === resourceId && + edge.data.target === relation.Object && + edge.data.label === `IS ${relation.label} OF`, + ); + if (!exists) { + edges.push({ + data: { + source: resourceId, + target: relation.Object, + label: `IS ${relation.label} OF`, + }, + classes: 'relationship-connection', + }); + } + } + }); + }); + relationships.forEach(relations => { + relations.forEach(relation => { + if (!existingNodeIds.has(relation.id)) { + nodes.push({ + data: { id: relation.objectId, label: `${relation.objectId}` }, + }); + existingNodeIds.add(relation.objectId); + } + + if (!existingNodeIds.has(relation.objectId)) { + nodes.push({ + data: { id: relation.objectId, label: `${relation.objectId}` }, + }); + existingNodeIds.add(relation.objectId); + } + + if (relation.subjectId !== relation.objectId) { + // Check if an edge with the same source, target, and label already exists. + const exists = edges.some( + edge => + edge.data.source === relation.subjectId && + edge.data.target === relation.objectId && + edge.data.label === relation.label, + ); + if (!exists) { + edges.push({ + data: { + source: relation.subjectId, + target: relation.objectId, + label: relation.label, + }, + classes: 'relationship-connection', // Class for orange lines + }); + } + } + }); + }); + + // add role assignments to the graph + roleAssignments.forEach(assignment => { + // Add user nodes with a specific class + if (!existingNodeIds.has(assignment.user)) { + nodes.push({ + data: { + id: assignment.user, + label: `${assignment.user} ${assignment.email}`, + }, + classes: 'user-node', + }); + existingNodeIds.add(assignment.user); + } + + if (assignment.resourceInstance !== 'No Resource Instance') { + if (!existingNodeIds.has(assignment.resourceInstance)) { + nodes.push({ + data: { + id: assignment.resourceInstance, + label: `${assignment.resourceInstance}`, + }, + classes: ResourceInstanceClass, + }); + existingNodeIds.add(assignment.resourceInstance); + } + } + // Connect user to resource instance + + if (assignment.role !== 'No Role Assigned') { + edges.push({ + data: { + source: assignment.user, + target: assignment.resourceInstance, + label: `${assignment.role}`, + }, + classes: 'user-edge', + }); + } + }); + + // Ensure that for every target ID in the edges array there is a corresponding node + edges.forEach(edge => { + if (!existingNodeIds.has(edge.data.target)) { + nodes.push({ + data: { + id: edge.data.target, + label: `Node ${edge.data.target}`, + }, + classes: ResourceInstanceClass, + }); + existingNodeIds.add(edge.data.target); + } + }); + + return { nodes, edges }; +}; diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx new file mode 100644 index 0000000..9ad9f46 --- /dev/null +++ b/source/components/graphCommand.tsx @@ -0,0 +1,406 @@ +import React, { useEffect, useState } from 'react'; +import { Text } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import { apiCall } from '../lib/api.js'; +import { saveHTMLGraph } from '../components/HtmlGraphSaver.js'; +import { generateGraphData } from '../components/generateGraphData.js'; +import zod from 'zod'; +import { option } from 'pastel'; +import { useAuth } from '../components/AuthProvider.js'; // Import useAuth +import { ActiveState } from './EnvironmentSelection.js'; + +// Define types +type Relationship = { + label: string; + id: string; + subjectId: string; + objectId: string; + Object: string; +}; + +interface APIResourceInstanceRole { + resource_instance: string; + resource: string; + role: string; +} + +interface APITenant { + tenant: string; + roles: string[]; + status: string; + resource_instance_roles?: APIResourceInstanceRole[]; +} + +interface APIUser { + key: string; + email: string; + associated_tenants?: APITenant[]; +} + +type RoleAssignment = { + user: string; + email: string; + role: string; + resourceInstance: string; +}; + +interface APIRelationship { + subject: string; + relation: string; + object: string; +} + +export const options = zod.object({ + apiKey: zod + .string() + .optional() + .describe( + option({ + description: 'The API key for the Permit env, project or Workspace', + }), + ), +}); + +type Props = { + options: zod.infer; +}; + +export default function Graph({ options }: Props) { + const { + authToken: contextAuthToken, + loading: authLoading, + error: authError, + } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [authToken, setAuthToken] = useState(null); // Store resolved authToken + const [state, setState] = useState<'project' | 'environment' | 'graph'>( + 'project', + ); + const [projects, setProjects] = useState([]); + const [environments, setEnvironments] = useState([]); + const [selectedProject, setSelectedProject] = useState( + null, + ); + const [selectedEnvironment, setSelectedEnvironment] = + useState(null); + const [noData, setNoData] = useState(false); + + // Resolve the authToken on mount + useEffect(() => { + const token = contextAuthToken || options.apiKey || null; + if (!token) { + setError('No auth token found. Please log in or provide an API key.'); + } else { + setAuthToken(token); + } + }, [contextAuthToken, options.apiKey]); + + // Fetch projects + useEffect(() => { + const fetchProjects = async () => { + if (!authToken) return; + + try { + setLoading(true); + const { response: projects } = await apiCall('v2/projects', authToken); + // Map projectsData to ActiveState[] + const mappedProjects: ActiveState[] = projects.map( + (project: { name: string; id: string }) => ({ + label: project.name, + value: project.id, + }), + ); + setProjects(mappedProjects); + setLoading(false); + } catch (err) { + console.error('Error fetching projects:', err); + setError('Failed to fetch projects.'); + setLoading(false); + } + }; + + if (state === 'project') { + fetchProjects(); + } + }, [state, authToken]); + + // Fetch environments + useEffect(() => { + const fetchEnvironments = async () => { + if (!authToken || !selectedProject) return; + + try { + setLoading(true); + const { response: environments } = await apiCall( + `v2/projects/${selectedProject.value}/envs`, + authToken, + ); + const mappedEnvironments: ActiveState[] = environments.map( + (env: { name: string; id: string }) => ({ + label: env.name, + value: env.id, + }), + ); + setEnvironments(mappedEnvironments); + setLoading(false); + } catch (err) { + console.error('Error fetching environments:', err); + setError('Failed to fetch environments.'); + setLoading(false); + } + }; + + if (state === 'environment') { + fetchEnvironments(); + } + }, [state, authToken, selectedProject]); + + // Fetch graph data + useEffect(() => { + const fetchData = async () => { + if (!authToken || !selectedProject || !selectedEnvironment) return; + + try { + setLoading(true); + + const per_Page = 100; // API limit for 100 records per page + let Page = 1; + let hasMoreData = true; + let allResourcesData: { + label: string; + value: string; + id: string; + id2: string; + key: string; + relationships?: APIRelationship[]; + }[] = []; + let allRoleAssignmentsData: RoleAssignment[] = []; + const relationsMap = new Map(); + + while (hasMoreData) { + const resourceResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances/detailed?page=${Page}&per_page=${per_Page}`, + authToken, + ); + const resourceArray = + resourceResponse.response.data || resourceResponse.response; + + const resourcesData = resourceArray.map( + (res: { + resource: string; + resource_id: string; + id: string; + key: string; + relationships?: APIRelationship[]; + }) => ({ + label: `${res.resource}#${res.resource_id}`, + value: res.id, + id: res.id, + id2: `${res.resource}:${res.key}`, + key: res.key, + relationships: res.relationships || [], + }), + ); + + allResourcesData = [...allResourcesData, ...resourcesData]; + + // Check if there are more pages to fetch + hasMoreData = resourceArray.length === per_Page; + Page++; + } + + // Create a lookup map for id2 to resource labels + const id2ToLabelMap = new Map(); + allResourcesData.forEach((resource: { id2: string; id: string }) => { + id2ToLabelMap.set(resource.id2, resource.id); + }); + + allResourcesData.forEach( + (resource: { + label: string; + value: string; + id: string; + id2: string; + key: string; + relationships?: APIRelationship[]; + }) => { + const relationsData: APIRelationship[] = + resource.relationships || []; + relationsMap.set( + resource.id, + relationsData.map((relation: APIRelationship): Relationship => { + // Check if relation.object matches any id2 + const matchedLabel = id2ToLabelMap.get(relation.object); + const matchedsubjectid = id2ToLabelMap.get(relation.subject); + + // Convert relation.relation to uppercase + const relationLabel = relation.relation + ? relation.relation.toUpperCase() + : 'UNKNOWN RELATION'; + + return { + label: relationLabel, + objectId: matchedLabel || relation.object, + Object: relation.object, + subjectId: matchedsubjectid || relation.subject, + id: resource.id, + }; + }), + ); + }, + ); + + Page = 1; + hasMoreData = true; + + while (hasMoreData) { + const roleResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/users?include_resource_instance_roles=true&page=${Page}&per_page=${per_Page}`, + authToken, + ); + + const users: APIUser[] = roleResponse.response?.data || []; + + users.forEach((user: APIUser) => { + const usernames = user.key; + const email = user.email; + + // Check if the user has associated tenants + if (user.associated_tenants?.length) { + user.associated_tenants.forEach((tenant: APITenant) => { + if (tenant.resource_instance_roles?.length) { + tenant.resource_instance_roles.forEach( + (resourceInstanceRole: APIResourceInstanceRole) => { + const resourceInstanceId = + id2ToLabelMap.get( + `${resourceInstanceRole.resource}:${resourceInstanceRole.resource_instance}`, + ) || resourceInstanceRole.resource_instance; + + allRoleAssignmentsData.push({ + user: usernames || 'Unknown User found', + email: email || '', + role: resourceInstanceRole.role || 'Unknown Role', + resourceInstance: + resourceInstanceId || 'Unknown Resource Instance', + }); + }, + ); + } else { + // Push default entry for users with no roles in the tenant + allRoleAssignmentsData.push({ + user: usernames || 'Unknown User', + email: email || '', + role: 'No Role Assigned', + resourceInstance: 'No Resource Instance', + }); + } + }); + } else { + // Push default entry for users with no associated tenants + allRoleAssignmentsData.push({ + user: usernames || 'Unknown User', + email: email || '', + role: 'No Role Assigned', + resourceInstance: 'No Resource Instance', + }); + } + }); + + // Check if there are more pages to fetch + hasMoreData = roleResponse.response.data.length === per_Page; + Page++; + } + + const graphData = generateGraphData( + allResourcesData, + relationsMap, + allRoleAssignmentsData, + ); + + // If no nodes exist, update the flag and skip saving + if (graphData.nodes.length === 0) { + setNoData(true); + setLoading(false); + return; + } + + // Ensure classes is always a string + const updatedGraphData = { + ...graphData, + edges: graphData.edges.map(edge => ({ + ...edge, + classes: edge.classes || '', + })), + }; + saveHTMLGraph(updatedGraphData); + setLoading(false); + } catch (err) { + console.error('Error fetching graph data:', err); + setError('Failed to fetch data. Check network or auth token.'); + setLoading(false); + } + }; + + if (state === 'graph') { + fetchData(); + } + }, [state, authToken, selectedProject, selectedEnvironment]); + + // Loading and error states + if (authLoading || loading) { + return ( + + {' '} + {authLoading ? 'Authenticating...' : 'Loading Permit Graph...'} + + ); + } + + if (authError || error) { + return {authError || error}; + } + + // If no graph data is present, show a specific message + if (noData) { + return Environment does not contain any data; + } + + // State rendering + if (state === 'project' && projects.length > 0) { + return ( + <> + Select a project + { + setSelectedProject(project as ActiveState); + setState('environment'); + }} + /> + + ); + } + + if (state === 'environment' && environments.length > 0) { + return ( + <> + Select an environment + { + setSelectedEnvironment(environment as ActiveState); + setState('graph'); + }} + /> + + ); + } + + if (state === 'graph') { + return Graph generated successfully and saved as HTML!; + } + + return Initializing...; +} diff --git a/tests/components/HtmlGraphSaver.test.tsx b/tests/components/HtmlGraphSaver.test.tsx new file mode 100644 index 0000000..c08b069 --- /dev/null +++ b/tests/components/HtmlGraphSaver.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { resolve } from 'path'; +import open from 'open'; +import { saveHTMLGraph } from '../../source/components/HtmlGraphSaver'; +import * as fs from 'fs'; + +// Mock the 'open' module +vi.mock('open', () => ({ + default: vi.fn(() => Promise.resolve()), +})); + +// Mock the 'fs' module; this must happen before the module under test is imported +vi.mock('fs', () => ({ + writeFileSync: vi.fn(), +})); + +describe('saveHTMLGraph', () => { + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should write the HTML file and open it', () => { + const dummyGraphData = { nodes: [], edges: [] }; + saveHTMLGraph(dummyGraphData); + + const expectedPath = resolve(process.cwd(), 'permit-graph.html'); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expectedPath, + expect.stringContaining(''), + 'utf8' + ); + + expect(consoleLogSpy).toHaveBeenCalledWith(`Graph saved as: ${expectedPath}`); + + expect(open).toHaveBeenCalledWith(expectedPath); + }); +}); diff --git a/tests/components/generateGraphData.test.tsx b/tests/components/generateGraphData.test.tsx new file mode 100644 index 0000000..0aabc88 --- /dev/null +++ b/tests/components/generateGraphData.test.tsx @@ -0,0 +1,188 @@ +// generateGraphData.test.tsx +import { describe, it, expect } from 'vitest'; +import { generateGraphData } from '../../source/components/generateGraphData'; + +// Dummy types for clarity (these match your definitions) +type ResourceInstance = { + label: string; + value: string; + id: string; + id2: string; +}; + +type Relationship = { + label: string; + objectId: string; + id: string; + subjectId: string; + Object: string; +}; + +type RoleAssignment = { + user: string; + email: string; + role: string; + resourceInstance: string; +}; + +describe('generateGraphData', () => { + it('should create resource nodes without edges when no relationships or role assignments exist', () => { + const resources: ResourceInstance[] = [ + { label: 'Resource 1', value: 'val1', id: 'r1', id2: 'r1-2' }, + ]; + const relationships = new Map(); + const roleAssignments: RoleAssignment[] = []; + + const { nodes, edges } = generateGraphData(resources, relationships, roleAssignments); + + // Check that the resource node exists with the right class + expect(nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { id: 'r1', label: ' Resource 1', id2: 'r1-2' }, + classes: 'resource-instance-node', + }), + ]), + ); + expect(edges).toHaveLength(0); + }); + + it('should create object nodes and relationship edges from relationships', () => { + const resources: ResourceInstance[] = [ + { label: 'Resource 1', value: 'val1', id: 'r1', id2: 'r1-2' }, + ]; + // Create a relationship from resource "r1" to object "obj1" + const relationship: Relationship = { + label: 'RELATES', + objectId: 'obj1', + id: 'rel1', + subjectId: 'r1', + Object: 'obj1', + }; + const relationships = new Map(); + relationships.set('r1', [relationship]); + const roleAssignments: RoleAssignment[] = []; + + const { nodes, edges } = generateGraphData(resources, relationships, roleAssignments); + + // Verify resource node exists + expect(nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { id: 'r1', label: ' Resource 1', id2: 'r1-2' }, + classes: 'resource-instance-node', + }), + ]), + ); + + // Verify object node created by relationship (should have class 'object-node') + expect(nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { id: 'obj1', label: 'obj1' }, + classes: 'object-node', + }), + ]), + ); + + // Verify relationship edge is created with the correct label + expect(edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { source: 'r1', target: 'obj1', label: 'IS RELATES OF' }, + classes: 'relationship-connection', + }), + ]), + ); + }); + + it('should create user nodes and user edges from role assignments', () => { + const resources: ResourceInstance[] = [ + { label: 'Resource 1', value: 'val1', id: 'r1', id2: 'r1-2' }, + ]; + const relationships = new Map(); + // Role assignment linking a user to resource "r1" + const roleAssignments: RoleAssignment[] = [ + { + user: 'u1', + email: 'u1@example.com', + role: 'admin', + resourceInstance: 'r1', + }, + ]; + + const { nodes, edges } = generateGraphData(resources, relationships, roleAssignments); + + // Check that a user node is created with class 'user-node' + expect(nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { id: 'u1', label: 'u1 u1@example.com' }, + classes: 'user-node', + }), + ]), + ); + + // Check that an edge is created connecting the user to the resource instance with label "admin" + expect(edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { source: 'u1', target: 'r1', label: 'admin' }, + classes: 'user-edge', + }), + ]), + ); + }); + + it('should not duplicate nodes or edges for duplicate relationships or role assignments', () => { + const resources: ResourceInstance[] = [ + { label: 'Resource 1', value: 'val1', id: 'r1', id2: 'r1-2' }, + ]; + // Duplicate relationship entries for the same connection + const relationship: Relationship = { + label: 'ASSOCIATED', + objectId: 'obj1', + id: 'rel1', + subjectId: 'r1', + Object: 'obj1', + }; + const relationships = new Map(); + relationships.set('r1', [relationship, relationship]); + const roleAssignments: RoleAssignment[] = [ + { + user: 'u1', + email: 'u1@example.com', + role: 'admin', + resourceInstance: 'r1', + }, + { + user: 'u1', + email: 'u1@example.com', + role: 'admin', + resourceInstance: 'r1', + }, + ]; + + const { nodes, edges } = generateGraphData(resources, relationships, roleAssignments); + + expect(nodes.length).toBe(5); + + const relEdges = edges.filter( + (edge) => + edge.classes === 'relationship-connection' && + edge.data.source === 'r1' && + edge.data.target === 'obj1' && + edge.data.label === 'IS ASSOCIATED OF', + ); + expect(relEdges.length).toBe(1); + + const userEdges = edges.filter( + (edge) => + edge.classes === 'user-edge' && + edge.data.source === 'u1' && + edge.data.target === 'r1' && + edge.data.label === 'admin', + ); + expect(userEdges.length).toBe(2); + }); +}); diff --git a/tests/components/graphCommand.test.tsx b/tests/components/graphCommand.test.tsx new file mode 100644 index 0000000..d9c61f1 --- /dev/null +++ b/tests/components/graphCommand.test.tsx @@ -0,0 +1,125 @@ +vi.mock('../../source/lib/api.js', () => ({ + apiCall: vi.fn(), // This will be our mock function. +})); + +vi.mock('../../source/components/HtmlGraphSaver.js', () => ({ + saveHTMLGraph: vi.fn(), +})); + +vi.mock('../../source/components/generateGraphData.js', () => ({ + generateGraphData: vi.fn(), +})); + +vi.mock('../../source/components/AuthProvider.js', () => ({ + // Return a dummy AuthProvider that simply renders its children. + AuthProvider: ({ children }) => children, + useAuth: () => ({ + authToken: 'test-token', + loading: false, + error: null, + }), +})); + + +import React from 'react'; +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Graph from '../../source/commands/graph.js'; // Adjust path as needed +import { apiCall } from '../../source/lib/api.js'; +import { saveHTMLGraph } from '../../source/components/HtmlGraphSaver.js'; +import { generateGraphData } from '../../source/components/generateGraphData.js'; + +const projectsData = [{ name: 'Project 1', id: 'p1' }]; +const environmentsData = [{ name: 'Environment 1', id: 'e1' }]; +const resourceInstancesData = [ + { + resource: 'Resource', + resource_id: 'r1', + id: 'r1', + key: 'k1', + relationships: [], + }, +]; +const usersData: any[] = []; + +// Helper function: Polls instance.lastFrame() until condition(output) is true or timeout. +const waitForOutput = async ( + instance: ReturnType, + condition: (output: string) => boolean, + timeout = 100000 +) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + const output = instance.lastFrame() || ''; + if (condition(output)) { + return output; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('Timeout waiting for output'); +}; + +describe('Graph component', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it( + 'completes the interactive flow and renders the success message', + async () => { + (apiCall as any) + .mockResolvedValueOnce({ response: projectsData }) // Projects API call + .mockResolvedValueOnce({ response: environmentsData }) // Environments API call + .mockResolvedValueOnce({ + response: { data: resourceInstancesData }, + }) // Resource instances API call + .mockResolvedValueOnce({ + response: { data: usersData }, + }); // User roles API call + + // Make generateGraphData return valid graph data (with nodes so that HTML is saved). + (generateGraphData as any).mockReturnValue({ + nodes: [{ data: { id: 'node1', label: 'Node 1' } }], + edges: [], + }); + + // Render the component with a valid API key option. + const instance = render(); + + // Wait for the "Select a project" prompt. + let output = await waitForOutput(instance, (out) => out.includes('Select a project')); + + // Simulate selecting the project. + // Send an arrow-down and then Enter twice to ensure the selection is registered. + instance.stdin.write('\u001B[B'); + await new Promise((r) => setTimeout(r, 100)); + instance.stdin.write('\r'); + await new Promise((r) => setTimeout(r, 200)); + + // Wait for the "Select an environment" prompt. + output = await waitForOutput(instance, (out) => out.includes('Select an environment')); + console.log("After environment prompt:", output); + + // Simulate selecting the environment. + instance.stdin.write('\u001B[B'); + await new Promise((r) => setTimeout(r, 100)); + instance.stdin.write('\r'); + await new Promise((r) => setTimeout(r, 200)); + + // Wait for the final success message. + output = await waitForOutput(instance, (out) => + out.includes('Graph generated successfully and saved as HTML!') + ); + + // Assert that saveHTMLGraph was called. + expect(saveHTMLGraph).toHaveBeenCalled(); + + // Optionally, verify that the first apiCall was made with the expected arguments. + expect(apiCall).toHaveBeenNthCalledWith(1, 'v2/projects', 'test-token'); + }, + 30000 + ); +}); diff --git a/tests/graph.test.tsx b/tests/graph.test.tsx new file mode 100644 index 0000000..8c4576a --- /dev/null +++ b/tests/graph.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; +import Graph from '../source/commands/graph'; + +describe('graph command', () => { + it('should render the Graph component inside AuthProvider', () => { + const options = { apiKey: 'test-api-key' }; + const { lastFrame } = render(); + + expect(lastFrame()).not.toBeNull(); + }); +});