From 31983b633fd7779a464d8da5b5d65757e9d61015 Mon Sep 17 00:00:00 2001 From: Vishwanath Martur <64204611+vishwamartur@users.noreply.github.com> Date: Mon, 25 Nov 2024 22:35:45 +0530 Subject: [PATCH] Add proper unit test coverage Related to #12 Add unit tests and integrate Jest for 90% code coverage. * **Add Jest and Update Configuration** - Add Jest as a dev dependency in `package.json`. - Update the `test` script to use Jest. - Add Jest configuration to `package.json`. * **Update GitHub Actions Workflow** - Add a step to run Jest tests in `.github/workflows/node.js.yml`. - Add a step to generate coverage reports. * **Add Unit Tests** - Add unit tests for `ApiKey` component in `source/commands/apiKey.test.tsx`. - Add unit tests for `Login` component in `source/commands/login.test.tsx`. - Add unit tests for `Logout` component in `source/commands/logout.test.tsx`. - Add unit tests for `Policy` component in `source/commands/opa/policy.test.tsx`. - Add unit tests for `Check` component in `source/commands/pdp/check.test.tsx`. - Add unit tests for `Run` component in `source/commands/pdp/run.test.tsx`. - Add unit tests for `AuthProvider` component in `source/components/AuthProvider.test.tsx`. - Add unit tests for `PDPCommand` component in `source/components/PDPCommand.test.tsx`. * **Update Documentation** - Add instructions for running tests locally in `README.md`. - Add instructions for writing new tests in `README.md`. --- .github/workflows/node.js.yml | 3 + README.md | 19 ++++ package.json | 13 ++- source/commands/apiKey.test.tsx | 53 ++++++++++ source/commands/login.test.tsx | 133 ++++++++++++++++++++++++ source/commands/logout.test.tsx | 27 +++++ source/commands/opa/policy.test.tsx | 42 ++++++++ source/commands/pdp/check.test.tsx | 56 ++++++++++ source/commands/pdp/run.test.tsx | 26 +++++ source/components/AuthProvider.test.tsx | 63 +++++++++++ source/components/PDPCommand.test.tsx | 47 +++++++++ 11 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 source/commands/apiKey.test.tsx create mode 100644 source/commands/login.test.tsx create mode 100644 source/commands/logout.test.tsx create mode 100644 source/commands/opa/policy.test.tsx create mode 100644 source/commands/pdp/check.test.tsx create mode 100644 source/commands/pdp/run.test.tsx create mode 100644 source/components/AuthProvider.test.tsx create mode 100644 source/components/PDPCommand.test.tsx diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2da32dc..88e5c99 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -28,3 +28,6 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm run lint + - run: npm test + - name: Generate coverage report + run: npm run coverage diff --git a/README.md b/README.md index 84b661a..6bbd79e 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,22 @@ $ permit-cli --help $ permit-cli api-key permit_key_.......... Key saved to './permit.key' ``` + +## Running Tests + +To run the tests locally, use the following command: + +``` +npm test +``` + +This will run all the tests and generate a coverage report in the `coverage` directory. + +## Writing Tests + +When writing new tests, follow these guidelines: + +1. Create a new test file in the appropriate directory (e.g., `source/commands` or `source/components`). +2. Use the existing test files as examples for writing your tests. +3. Ensure that your tests cover edge cases and different scenarios. +4. Run the tests locally to ensure they pass before committing your changes. diff --git a/package.json b/package.json index c694996..aa5c8be 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev": " NODE_NO_WARNINGS=1 tsc --watch", "lint": "eslint \"source/**/*.{js,ts,tsx}\"", "lint:fix": "eslint \"source/**/*.{js,ts,tsx}\" --fix", - "test": "prettier --check ./source && npx tsx --test test*.tsx", + "test": "jest --coverage", "simple-check": "npx tsx ./source/cli.tsx pdp check -u filip@permit.io -a create -r task" }, "files": [ @@ -56,6 +56,15 @@ "prettier": "^3.3.3", "ts-node": "^10.9.1", "typescript": "^5.6.3", - "typescript-eslint": "^8.11.0" + "typescript-eslint": "^8.11.0", + "jest": "^29.0.0", + "ts-jest": "^29.0.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "collectCoverage": true, + "coverageDirectory": "coverage", + "coverageReporters": ["text", "lcov"] } } diff --git a/source/commands/apiKey.test.tsx b/source/commands/apiKey.test.tsx new file mode 100644 index 0000000..b353dc8 --- /dev/null +++ b/source/commands/apiKey.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { test, expect } from '@jest/globals'; +import ApiKey, { args, options } from './apiKey'; +import keytar from 'keytar'; + +jest.mock('keytar'); + +const mockGetPassword = keytar.getPassword as jest.Mock; +const mockSetPassword = keytar.setPassword as jest.Mock; + +const defaultProps = { + args: ['save', 'permit_key_1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567'], + options: { + keyAccount: 'testAccount', + }, +}; + +test('ApiKey component - save valid key', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Key saved to secure key store.'); + expect(mockSetPassword).toHaveBeenCalledWith('Permit.io', 'testAccount', 'permit_key_1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567'); +}); + +test('ApiKey component - validate valid key', () => { + const props = { + ...defaultProps, + args: ['validate', 'permit_key_1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567'], + }; + const { lastFrame } = render(); + expect(lastFrame()).toContain('Key is valid.'); +}); + +test('ApiKey component - read key', async () => { + mockGetPassword.mockResolvedValue('permit_key_1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567'); + const props = { + ...defaultProps, + args: ['read', ''], + }; + const { lastFrame } = render(); + await new Promise(resolve => setTimeout(resolve, 0)); // Wait for useEffect + expect(lastFrame()).toContain('permit_key_1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567'); + expect(mockGetPassword).toHaveBeenCalledWith('Permit.io', 'testAccount'); +}); + +test('ApiKey component - invalid key', () => { + const props = { + ...defaultProps, + args: ['save', 'invalid_key'], + }; + const { lastFrame } = render(); + expect(lastFrame()).toContain('Key is not valid.'); +}); diff --git a/source/commands/login.test.tsx b/source/commands/login.test.tsx new file mode 100644 index 0000000..fcd906f --- /dev/null +++ b/source/commands/login.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { render, act } from 'ink-testing-library'; +import Login from './login'; +import { apiCall } from '../lib/api'; +import { authCallbackServer, browserAuth, saveAuthToken, TokenType, tokenType } from '../lib/auth'; + +jest.mock('../lib/api'); +jest.mock('../lib/auth'); + +describe('Login Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display login message initially', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Login to Permit'); + }); + + it('should display logging in message when logging in', async () => { + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Logging in...'); + }); + + it('should display organizations after logging in', async () => { + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'org1', name: 'Org 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (browserAuth as jest.Mock).mockResolvedValueOnce('verifier'); + (authCallbackServer as jest.Mock).mockResolvedValueOnce('token'); + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Select an organization'); + }); + + it('should display projects after selecting an organization', async () => { + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'org1', name: 'Org 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'project1', name: 'Project 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (browserAuth as jest.Mock).mockResolvedValueOnce('verifier'); + (authCallbackServer as jest.Mock).mockResolvedValueOnce('token'); + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Select a project'); + }); + + it('should display environments after selecting a project', async () => { + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'org1', name: 'Org 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'project1', name: 'Project 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'env1', name: 'Environment 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (browserAuth as jest.Mock).mockResolvedValueOnce('verifier'); + (authCallbackServer as jest.Mock).mockResolvedValueOnce('token'); + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Select an environment'); + }); + + it('should display success message after selecting an environment', async () => { + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'org1', name: 'Org 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'project1', name: 'Project 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: [{ id: 'env1', name: 'Environment 1' }], + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (apiCall as jest.Mock).mockResolvedValueOnce({ + response: { secret: 'secret' }, + headers: { getSetCookie: jest.fn().mockReturnValue(['cookie']) }, + }); + (browserAuth as jest.Mock).mockResolvedValueOnce('verifier'); + (authCallbackServer as jest.Mock).mockResolvedValueOnce('token'); + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Logged in as Org 1 with selected environment as Environment 1'); + }); + + it('should display error message for invalid API key', async () => { + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Invalid API Key'); + }); +}); diff --git a/source/commands/logout.test.tsx b/source/commands/logout.test.tsx new file mode 100644 index 0000000..d377c8b --- /dev/null +++ b/source/commands/logout.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, act } from 'ink-testing-library'; +import Logout from './logout'; +import { cleanAuthToken } from '../lib/auth'; + +jest.mock('../lib/auth'); + +describe('Logout Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display cleaning session message initially', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Cleaning session...'); + }); + + it('should call cleanAuthToken and display logged out message', async () => { + (cleanAuthToken as jest.Mock).mockResolvedValueOnce(undefined); + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(cleanAuthToken).toHaveBeenCalled(); + expect(lastFrame()).toContain('Logged Out'); + }); +}); diff --git a/source/commands/opa/policy.test.tsx b/source/commands/opa/policy.test.tsx new file mode 100644 index 0000000..df75af3 --- /dev/null +++ b/source/commands/opa/policy.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, act } from 'ink-testing-library'; +import Policy from './policy'; +import { loadAuthToken } from '../../lib/auth'; + +jest.mock('../../lib/auth'); + +describe('Policy Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display loading spinner initially', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Listing Policies on Opa Server=http://localhost:8181'); + }); + + it('should display policies after loading', async () => { + (loadAuthToken as jest.Mock).mockResolvedValueOnce('testApiKey'); + const mockFetch = jest.fn().mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ result: [{ id: 'policy1' }, { id: 'policy2' }] }), + status: 200, + }); + global.fetch = mockFetch as any; + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Showing 2 of 2 policies:'); + }); + + it('should display error message on request failure', async () => { + (loadAuthToken as jest.Mock).mockResolvedValueOnce('testApiKey'); + const mockFetch = jest.fn().mockRejectedValueOnce(new Error('Request failed')); + global.fetch = mockFetch as any; + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Request failed:'); + }); +}); diff --git a/source/commands/pdp/check.test.tsx b/source/commands/pdp/check.test.tsx new file mode 100644 index 0000000..4356b83 --- /dev/null +++ b/source/commands/pdp/check.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, act } from 'ink-testing-library'; +import Check from './check'; +import { keytar } from 'keytar'; + +jest.mock('keytar'); + +describe('Check Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display checking message initially', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Checking user="testUser" action=create resource=task at tenant=default'); + }); + + it('should display allowed message when access is allowed', async () => { + (keytar.getPassword as jest.Mock).mockResolvedValueOnce('testApiKey'); + const mockFetch = jest.fn().mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ allow: true }), + status: 200, + }); + global.fetch = mockFetch as any; + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('ALLOWED'); + }); + + it('should display denied message when access is denied', async () => { + (keytar.getPassword as jest.Mock).mockResolvedValueOnce('testApiKey'); + const mockFetch = jest.fn().mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ allow: false }), + status: 200, + }); + global.fetch = mockFetch as any; + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('DENIED'); + }); + + it('should display error message on request failure', async () => { + (keytar.getPassword as jest.Mock).mockResolvedValueOnce('testApiKey'); + const mockFetch = jest.fn().mockRejectedValueOnce(new Error('Request failed')); + global.fetch = mockFetch as any; + const { lastFrame } = render(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(lastFrame()).toContain('Request failed:'); + }); +}); diff --git a/source/commands/pdp/run.test.tsx b/source/commands/pdp/run.test.tsx new file mode 100644 index 0000000..05456c1 --- /dev/null +++ b/source/commands/pdp/run.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import Run from './run'; +import { AuthProvider } from '../../components/AuthProvider'; +import PDPCommand from '../../components/PDPCommand'; + +jest.mock('../../components/AuthProvider', () => ({ + AuthProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('../../components/PDPCommand', () => ({ + __esModule: true, + default: ({ opa }: { opa?: number }) =>
PDPCommand {opa}
, +})); + +describe('Run Component', () => { + it('should render AuthProvider and PDPCommand components', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('PDPCommand 8181'); + }); + + it('should render PDPCommand without opa prop', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('PDPCommand'); + }); +}); diff --git a/source/components/AuthProvider.test.tsx b/source/components/AuthProvider.test.tsx new file mode 100644 index 0000000..3d62c6a --- /dev/null +++ b/source/components/AuthProvider.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { AuthProvider, useAuth } from './AuthProvider'; +import { Text } from 'ink'; +import { act } from 'react-dom/test-utils'; + +jest.mock('../lib/auth', () => ({ + loadAuthToken: jest.fn(), +})); + +const { loadAuthToken } = require('../lib/auth'); + +describe('AuthProvider', () => { + it('should display loading message initially', () => { + loadAuthToken.mockImplementation(() => new Promise(() => {})); + const { lastFrame } = render( + + Child Component + + ); + expect(lastFrame()).toContain('Loading Token'); + }); + + it('should display error message if token loading fails', async () => { + loadAuthToken.mockRejectedValue(new Error('Failed to load token')); + let lastFrame; + await act(async () => { + const { lastFrame: frame } = render( + + Child Component + + ); + lastFrame = frame; + }); + expect(lastFrame()).toContain('Failed to load token'); + }); + + it('should render children if token loading succeeds', async () => { + loadAuthToken.mockResolvedValue('mock-token'); + let lastFrame; + await act(async () => { + const { lastFrame: frame } = render( + + Child Component + + ); + lastFrame = frame; + }); + expect(lastFrame()).toContain('Child Component'); + }); +}); + +describe('useAuth', () => { + it('should throw error if used outside AuthProvider', () => { + const TestComponent = () => { + useAuth(); + return Test; + }; + expect(() => render()).toThrow( + 'useAuth must be used within an AuthProvider' + ); + }); +}); diff --git a/source/components/PDPCommand.test.tsx b/source/components/PDPCommand.test.tsx new file mode 100644 index 0000000..5464d8b --- /dev/null +++ b/source/components/PDPCommand.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { test, expect } from '@jest/globals'; +import PDPCommand from './PDPCommand'; +import { AuthProvider } from './AuthProvider'; + +jest.mock('./AuthProvider', () => ({ + useAuth: jest.fn(), +})); + +const { useAuth } = require('./AuthProvider'); + +describe('PDPCommand Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display loading message initially', () => { + useAuth.mockReturnValue({ authToken: null }); + const { lastFrame } = render( + + + + ); + expect(lastFrame()).toContain('Loading command'); + }); + + it('should display command when authToken is available', () => { + useAuth.mockReturnValue({ authToken: 'mock-token' }); + const { lastFrame } = render( + + + + ); + expect(lastFrame()).toContain('docker run -p 7766:7000 --env PDP_API_KEY=mock-token --env PDP_DEBUG=true permitio/pdp-v2:latest'); + }); + + it('should include OPA port in command if provided', () => { + useAuth.mockReturnValue({ authToken: 'mock-token' }); + const { lastFrame } = render( + + + + ); + expect(lastFrame()).toContain('docker run -p 7766:7000 -p 8181:8181 --env PDP_API_KEY=mock-token --env PDP_DEBUG=true permitio/pdp-v2:latest'); + }); +});