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');
+ });
+});