@unifiedmemory/cli 1.3.0 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Mock for token-storage.js
3
+ *
4
+ * Provides mock implementations of token storage functions
5
+ * that don't interact with the real filesystem.
6
+ */
7
+
8
+ import { vi } from 'vitest';
9
+ import mockAuthData from '../fixtures/mock-auth.json' with { type: 'json' };
10
+ import expiredAuthData from '../fixtures/expired-auth.json' with { type: 'json' };
11
+
12
+ /**
13
+ * Creates a mock token storage module with configurable behavior
14
+ * @param {Object} options - Configuration options
15
+ * @param {Object|null} options.initialToken - Initial token data to return (use null for no token)
16
+ * @param {boolean} options.expired - Whether to use expired token fixture
17
+ * @param {boolean} options.empty - Whether to start with no token
18
+ * @returns {Object} Mock token storage functions
19
+ */
20
+ export function createTokenStorageMock(options = {}) {
21
+ let tokenData;
22
+
23
+ if (options.empty) {
24
+ tokenData = null;
25
+ } else if (options.expired) {
26
+ tokenData = { ...expiredAuthData };
27
+ } else if (options.initialToken !== undefined) {
28
+ tokenData = options.initialToken;
29
+ } else {
30
+ tokenData = { ...mockAuthData };
31
+ }
32
+
33
+ return {
34
+ saveToken: vi.fn((data) => {
35
+ tokenData = data;
36
+ }),
37
+
38
+ getToken: vi.fn(() => tokenData),
39
+
40
+ clearToken: vi.fn(() => {
41
+ tokenData = null;
42
+ }),
43
+
44
+ updateSelectedOrg: vi.fn((orgData) => {
45
+ if (tokenData) {
46
+ tokenData.selectedOrg = orgData;
47
+ } else {
48
+ throw new Error('No token found. Please login first.');
49
+ }
50
+ }),
51
+
52
+ getSelectedOrg: vi.fn(() => tokenData?.selectedOrg || null),
53
+
54
+ // Helper to reset state between tests
55
+ _reset: (newData = null) => {
56
+ tokenData = newData ?? { ...mockAuthData };
57
+ },
58
+
59
+ // Helper to get current state for assertions
60
+ _getState: () => tokenData,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Creates mock token storage that returns null (no token)
66
+ */
67
+ export function createEmptyTokenStorageMock() {
68
+ return createTokenStorageMock({ empty: true });
69
+ }
70
+
71
+ /**
72
+ * Creates mock token storage with expired token
73
+ */
74
+ export function createExpiredTokenStorageMock() {
75
+ return createTokenStorageMock({ expired: true });
76
+ }
77
+
78
+ // Export fixtures for direct use in tests
79
+ export { mockAuthData, expiredAuthData };
package/tests/setup.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Vitest Global Test Setup
3
+ *
4
+ * This file runs before all tests and sets up the test environment.
5
+ * It configures mocks and ensures tests don't interact with real auth.
6
+ */
7
+
8
+ import { vi, beforeAll, afterAll, afterEach } from 'vitest';
9
+
10
+ // Ensure we're in test mode
11
+ process.env.NODE_ENV = 'test';
12
+
13
+ // Mock console.error to reduce noise in tests (optional)
14
+ // Uncomment if you want to suppress console output during tests:
15
+ // vi.spyOn(console, 'error').mockImplementation(() => {});
16
+ // vi.spyOn(console, 'log').mockImplementation(() => {});
17
+
18
+ beforeAll(() => {
19
+ // Global setup before all tests
20
+ });
21
+
22
+ afterEach(() => {
23
+ // Clean up mocks after each test
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ afterAll(() => {
28
+ // Global cleanup after all tests
29
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Unit tests for lib/config.js
3
+ *
4
+ * Tests configuration loading and validation.
5
+ * Note: The config module loads environment variables at import time,
6
+ * so we test the validateConfig function and config structure.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+
11
+ describe('config', () => {
12
+ // Store original env vars
13
+ const originalEnv = { ...process.env };
14
+
15
+ beforeEach(() => {
16
+ // Reset modules to get fresh config import
17
+ vi.resetModules();
18
+ });
19
+
20
+ afterEach(() => {
21
+ // Restore original environment
22
+ process.env = { ...originalEnv };
23
+ vi.resetModules();
24
+ });
25
+
26
+ describe('default configuration', () => {
27
+ it('should have default values when no environment variables are set', async () => {
28
+ // Clear relevant env vars
29
+ delete process.env.CLERK_CLIENT_ID;
30
+ delete process.env.CLERK_DOMAIN;
31
+ delete process.env.API_ENDPOINT;
32
+ delete process.env.REDIRECT_URI;
33
+ delete process.env.PORT;
34
+
35
+ // Fresh import to get defaults
36
+ const { config } = await import('../../lib/config.js');
37
+
38
+ expect(config.clerkClientId).toBe('nULlnomaKB9rRGP2');
39
+ expect(config.clerkDomain).toBe('clear-caiman-45.clerk.accounts.dev');
40
+ expect(config.apiEndpoint).toBe('https://rose-asp-main-1c0b114.d2.zuplo.dev');
41
+ expect(config.redirectUri).toBe('http://localhost:3333/callback');
42
+ expect(config.port).toBe(3333);
43
+ });
44
+
45
+ it('should have clerkClientSecret as undefined by default', async () => {
46
+ delete process.env.CLERK_CLIENT_SECRET;
47
+
48
+ const { config } = await import('../../lib/config.js');
49
+
50
+ expect(config.clerkClientSecret).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe('environment variable overrides', () => {
55
+ it('should use CLERK_CLIENT_ID from environment', async () => {
56
+ process.env.CLERK_CLIENT_ID = 'custom_client_id';
57
+
58
+ const { config } = await import('../../lib/config.js');
59
+
60
+ expect(config.clerkClientId).toBe('custom_client_id');
61
+ });
62
+
63
+ it('should use CLERK_DOMAIN from environment', async () => {
64
+ process.env.CLERK_DOMAIN = 'custom.clerk.dev';
65
+
66
+ const { config } = await import('../../lib/config.js');
67
+
68
+ expect(config.clerkDomain).toBe('custom.clerk.dev');
69
+ });
70
+
71
+ it('should use API_ENDPOINT from environment', async () => {
72
+ process.env.API_ENDPOINT = 'https://custom-api.example.com';
73
+
74
+ const { config } = await import('../../lib/config.js');
75
+
76
+ expect(config.apiEndpoint).toBe('https://custom-api.example.com');
77
+ });
78
+
79
+ it('should use REDIRECT_URI from environment', async () => {
80
+ process.env.REDIRECT_URI = 'http://localhost:8080/callback';
81
+
82
+ const { config } = await import('../../lib/config.js');
83
+
84
+ expect(config.redirectUri).toBe('http://localhost:8080/callback');
85
+ });
86
+
87
+ it('should use PORT from environment and parse as integer', async () => {
88
+ process.env.PORT = '8080';
89
+
90
+ const { config } = await import('../../lib/config.js');
91
+
92
+ expect(config.port).toBe(8080);
93
+ expect(typeof config.port).toBe('number');
94
+ });
95
+
96
+ it('should use CLERK_CLIENT_SECRET from environment', async () => {
97
+ process.env.CLERK_CLIENT_SECRET = 'secret_key_123';
98
+
99
+ const { config } = await import('../../lib/config.js');
100
+
101
+ expect(config.clerkClientSecret).toBe('secret_key_123');
102
+ });
103
+ });
104
+
105
+ describe('validateConfig', () => {
106
+ it('should return true for valid default configuration', async () => {
107
+ const { validateConfig } = await import('../../lib/config.js');
108
+
109
+ expect(validateConfig()).toBe(true);
110
+ });
111
+
112
+ it('should throw error for invalid API_ENDPOINT URL', async () => {
113
+ process.env.API_ENDPOINT = 'not-a-valid-url';
114
+
115
+ const { validateConfig } = await import('../../lib/config.js');
116
+
117
+ expect(() => validateConfig()).toThrow('API_ENDPOINT must be a valid URL');
118
+ });
119
+
120
+ it('should use default when CLERK_DOMAIN is empty (falsy falls back to default)', async () => {
121
+ process.env.CLERK_DOMAIN = '';
122
+
123
+ const { config, validateConfig } = await import('../../lib/config.js');
124
+
125
+ // Empty string is falsy, so it falls back to default
126
+ expect(config.clerkDomain).toBe('clear-caiman-45.clerk.accounts.dev');
127
+ expect(validateConfig()).toBe(true);
128
+ });
129
+
130
+ it('should throw error when CLERK_DOMAIN is whitespace only', async () => {
131
+ // Whitespace is truthy, so it will be used instead of default
132
+ process.env.CLERK_DOMAIN = ' ';
133
+
134
+ const { validateConfig } = await import('../../lib/config.js');
135
+
136
+ expect(() => validateConfig()).toThrow('CLERK_DOMAIN cannot be empty');
137
+ });
138
+
139
+ it('should use default when CLERK_CLIENT_ID is empty (falsy falls back to default)', async () => {
140
+ process.env.CLERK_CLIENT_ID = '';
141
+
142
+ const { config, validateConfig } = await import('../../lib/config.js');
143
+
144
+ // Empty string is falsy, so it falls back to default
145
+ expect(config.clerkClientId).toBe('nULlnomaKB9rRGP2');
146
+ expect(validateConfig()).toBe(true);
147
+ });
148
+
149
+ it('should accept valid HTTPS URL for API_ENDPOINT', async () => {
150
+ process.env.API_ENDPOINT = 'https://api.example.com/v1';
151
+
152
+ const { validateConfig } = await import('../../lib/config.js');
153
+
154
+ expect(validateConfig()).toBe(true);
155
+ });
156
+
157
+ it('should accept valid HTTP URL for API_ENDPOINT (development)', async () => {
158
+ process.env.API_ENDPOINT = 'http://localhost:8000';
159
+
160
+ const { validateConfig } = await import('../../lib/config.js');
161
+
162
+ expect(validateConfig()).toBe(true);
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Unit tests for lib/jwt-utils.js
3
+ *
4
+ * Tests JWT parsing, validation, and expiration checking functions.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import {
9
+ parseJWT,
10
+ isJWTExpired,
11
+ validateJWTStructure,
12
+ getTimeUntilExpiration,
13
+ } from '../../lib/jwt-utils.js';
14
+
15
+ describe('jwt-utils', () => {
16
+ describe('parseJWT', () => {
17
+ it('should parse a valid JWT and return the payload', () => {
18
+ // Create a valid JWT with base64url-encoded payload
19
+ const payload = { sub: 'user_123', email: 'test@test.com', exp: 9999999999 };
20
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64');
21
+ const token = `header.${encodedPayload}.signature`;
22
+
23
+ const result = parseJWT(token);
24
+
25
+ expect(result).toEqual(payload);
26
+ });
27
+
28
+ it('should return null for invalid JWT with wrong number of parts', () => {
29
+ expect(parseJWT('invalid')).toBeNull();
30
+ expect(parseJWT('only.two')).toBeNull();
31
+ expect(parseJWT('too.many.parts.here')).toBeNull();
32
+ });
33
+
34
+ it('should return null for JWT with invalid base64 payload', () => {
35
+ const token = 'header.!!!invalid-base64!!!.signature';
36
+
37
+ const result = parseJWT(token);
38
+
39
+ expect(result).toBeNull();
40
+ });
41
+
42
+ it('should return null for JWT with non-JSON payload', () => {
43
+ const encodedPayload = Buffer.from('not-json').toString('base64');
44
+ const token = `header.${encodedPayload}.signature`;
45
+
46
+ const result = parseJWT(token);
47
+
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ it('should return null for empty string', () => {
52
+ expect(parseJWT('')).toBeNull();
53
+ });
54
+
55
+ it('should handle JWT with special characters in payload', () => {
56
+ const payload = { sub: 'user_123', email: 'test+special@test.com', name: 'Test User' };
57
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64');
58
+ const token = `header.${encodedPayload}.signature`;
59
+
60
+ const result = parseJWT(token);
61
+
62
+ expect(result).toEqual(payload);
63
+ });
64
+ });
65
+
66
+ describe('isJWTExpired', () => {
67
+ beforeEach(() => {
68
+ // Mock Date.now() for consistent testing
69
+ vi.useFakeTimers();
70
+ vi.setSystemTime(new Date('2024-06-15T12:00:00.000Z'));
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.useRealTimers();
75
+ });
76
+
77
+ it('should return true for expired token', () => {
78
+ // Token expired on Jan 1, 2024
79
+ const decoded = { exp: Math.floor(new Date('2024-01-01T00:00:00.000Z').getTime() / 1000) };
80
+
81
+ expect(isJWTExpired(decoded)).toBe(true);
82
+ });
83
+
84
+ it('should return false for valid (non-expired) token', () => {
85
+ // Token expires on Dec 31, 2024
86
+ const decoded = { exp: Math.floor(new Date('2024-12-31T00:00:00.000Z').getTime() / 1000) };
87
+
88
+ expect(isJWTExpired(decoded)).toBe(false);
89
+ });
90
+
91
+ it('should return true when token has no exp claim', () => {
92
+ expect(isJWTExpired({})).toBe(true);
93
+ expect(isJWTExpired({ sub: 'user_123' })).toBe(true);
94
+ });
95
+
96
+ it('should return true for null/undefined decoded value', () => {
97
+ expect(isJWTExpired(null)).toBe(true);
98
+ expect(isJWTExpired(undefined)).toBe(true);
99
+ });
100
+
101
+ it('should respect buffer parameter', () => {
102
+ // Token expires in exactly 5 minutes from now
103
+ const fiveMinutesFromNow = Math.floor(Date.now() / 1000) + 300;
104
+ const decoded = { exp: fiveMinutesFromNow };
105
+
106
+ // Without buffer, not expired
107
+ expect(isJWTExpired(decoded, 0)).toBe(false);
108
+
109
+ // With 10-minute buffer (600000ms), considered expired
110
+ expect(isJWTExpired(decoded, 600000)).toBe(true);
111
+
112
+ // With 3-minute buffer (180000ms), not expired yet
113
+ expect(isJWTExpired(decoded, 180000)).toBe(false);
114
+ });
115
+
116
+ it('should handle edge case where exp equals current time', () => {
117
+ const now = Math.floor(Date.now() / 1000);
118
+ const decoded = { exp: now };
119
+
120
+ // Exactly at expiration time should be considered expired
121
+ expect(isJWTExpired(decoded)).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe('validateJWTStructure', () => {
126
+ it('should return truthy for valid JWT structure with sub and exp', () => {
127
+ const decoded = { sub: 'user_123', exp: 9999999999 };
128
+
129
+ expect(validateJWTStructure(decoded)).toBeTruthy();
130
+ });
131
+
132
+ it('should return falsy when sub is missing', () => {
133
+ const decoded = { exp: 9999999999, email: 'test@test.com' };
134
+
135
+ expect(validateJWTStructure(decoded)).toBeFalsy();
136
+ });
137
+
138
+ it('should return falsy when exp is missing', () => {
139
+ const decoded = { sub: 'user_123', email: 'test@test.com' };
140
+
141
+ expect(validateJWTStructure(decoded)).toBeFalsy();
142
+ });
143
+
144
+ it('should return falsy for null/undefined', () => {
145
+ expect(validateJWTStructure(null)).toBeFalsy();
146
+ expect(validateJWTStructure(undefined)).toBeFalsy();
147
+ });
148
+
149
+ it('should return falsy for non-object values', () => {
150
+ expect(validateJWTStructure('string')).toBeFalsy();
151
+ expect(validateJWTStructure(123)).toBeFalsy();
152
+ expect(validateJWTStructure([])).toBeFalsy();
153
+ });
154
+
155
+ it('should return truthy when sub and exp have any truthy value', () => {
156
+ const decoded = { sub: 'any_value', exp: 1 };
157
+
158
+ expect(validateJWTStructure(decoded)).toBeTruthy();
159
+ });
160
+
161
+ it('should return falsy when sub or exp are falsy (0, empty string)', () => {
162
+ expect(validateJWTStructure({ sub: '', exp: 9999999999 })).toBeFalsy();
163
+ expect(validateJWTStructure({ sub: 'user_123', exp: 0 })).toBeFalsy();
164
+ });
165
+ });
166
+
167
+ describe('getTimeUntilExpiration', () => {
168
+ beforeEach(() => {
169
+ vi.useFakeTimers();
170
+ vi.setSystemTime(new Date('2024-06-15T12:00:00.000Z'));
171
+ });
172
+
173
+ afterEach(() => {
174
+ vi.useRealTimers();
175
+ });
176
+
177
+ it('should return positive milliseconds for future expiration', () => {
178
+ // Expires 1 hour from now
179
+ const oneHourFromNow = Math.floor(Date.now() / 1000) + 3600;
180
+ const decoded = { exp: oneHourFromNow };
181
+
182
+ const result = getTimeUntilExpiration(decoded);
183
+
184
+ // Should be approximately 1 hour in milliseconds
185
+ expect(result).toBeCloseTo(3600000, -2); // Allow small variance
186
+ });
187
+
188
+ it('should return negative milliseconds for past expiration', () => {
189
+ // Expired 1 hour ago
190
+ const oneHourAgo = Math.floor(Date.now() / 1000) - 3600;
191
+ const decoded = { exp: oneHourAgo };
192
+
193
+ const result = getTimeUntilExpiration(decoded);
194
+
195
+ expect(result).toBeCloseTo(-3600000, -2);
196
+ });
197
+
198
+ it('should return -1 when no exp claim exists', () => {
199
+ expect(getTimeUntilExpiration({})).toBe(-1);
200
+ expect(getTimeUntilExpiration({ sub: 'user_123' })).toBe(-1);
201
+ });
202
+
203
+ it('should return -1 for null/undefined decoded value', () => {
204
+ expect(getTimeUntilExpiration(null)).toBe(-1);
205
+ expect(getTimeUntilExpiration(undefined)).toBe(-1);
206
+ });
207
+
208
+ it('should return approximately 0 when exp equals current time', () => {
209
+ const now = Math.floor(Date.now() / 1000);
210
+ const decoded = { exp: now };
211
+
212
+ const result = getTimeUntilExpiration(decoded);
213
+
214
+ expect(result).toBeCloseTo(0, -2);
215
+ });
216
+ });
217
+ });