Skip to content

Commit

Permalink
Add proper unit test coverage
Browse files Browse the repository at this point in the history
Related to permitio#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`.
  • Loading branch information
vishwamartur committed Nov 25, 2024
1 parent f70b563 commit 31983b6
Show file tree
Hide file tree
Showing 11 changed files with 480 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected] -a create -r task"
},
"files": [
Expand Down Expand Up @@ -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"]
}
}
53 changes: 53 additions & 0 deletions source/commands/apiKey.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ApiKey {...defaultProps} />);
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(<ApiKey {...props} />);
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(<ApiKey {...props} />);
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(<ApiKey {...props} />);
expect(lastFrame()).toContain('Key is not valid.');
});
133 changes: 133 additions & 0 deletions source/commands/login.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Login options={{ key: undefined, workspace: undefined }} />);
expect(lastFrame()).toContain('Login to Permit');
});

it('should display logging in message when logging in', async () => {
const { lastFrame } = render(<Login options={{ key: undefined, workspace: undefined }} />);
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(<Login options={{ key: undefined, workspace: undefined }} />);
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(<Login options={{ key: undefined, workspace: undefined }} />);
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(<Login options={{ key: undefined, workspace: undefined }} />);
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(<Login options={{ key: undefined, workspace: undefined }} />);
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(<Login options={{ key: 'invalid_key', workspace: undefined }} />);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(lastFrame()).toContain('Invalid API Key');
});
});
27 changes: 27 additions & 0 deletions source/commands/logout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Logout />);
expect(lastFrame()).toContain('Cleaning session...');
});

it('should call cleanAuthToken and display logged out message', async () => {
(cleanAuthToken as jest.Mock).mockResolvedValueOnce(undefined);
const { lastFrame } = render(<Logout />);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(cleanAuthToken).toHaveBeenCalled();
expect(lastFrame()).toContain('Logged Out');
});
});
42 changes: 42 additions & 0 deletions source/commands/opa/policy.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Policy options={{ serverUrl: 'http://localhost:8181', keyAccount: 'testAccount' }} />);
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(<Policy options={{ serverUrl: 'http://localhost:8181', keyAccount: 'testAccount' }} />);
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(<Policy options={{ serverUrl: 'http://localhost:8181', keyAccount: 'testAccount' }} />);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(lastFrame()).toContain('Request failed:');
});
});
56 changes: 56 additions & 0 deletions source/commands/pdp/check.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Check options={{ user: 'testUser', action: 'create', resource: 'task', keyAccount: 'testAccount' }} />);
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(<Check options={{ user: 'testUser', action: 'create', resource: 'task', keyAccount: 'testAccount' }} />);
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(<Check options={{ user: 'testUser', action: 'create', resource: 'task', keyAccount: 'testAccount' }} />);
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(<Check options={{ user: 'testUser', action: 'create', resource: 'task', keyAccount: 'testAccount' }} />);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(lastFrame()).toContain('Request failed:');
});
});
26 changes: 26 additions & 0 deletions source/commands/pdp/run.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>,
}));

jest.mock('../../components/PDPCommand', () => ({
__esModule: true,
default: ({ opa }: { opa?: number }) => <div>PDPCommand {opa}</div>,
}));

describe('Run Component', () => {
it('should render AuthProvider and PDPCommand components', () => {
const { lastFrame } = render(<Run options={{ opa: 8181 }} />);
expect(lastFrame()).toContain('PDPCommand 8181');
});

it('should render PDPCommand without opa prop', () => {
const { lastFrame } = render(<Run options={{}} />);
expect(lastFrame()).toContain('PDPCommand');
});
});
Loading

0 comments on commit 31983b6

Please sign in to comment.