latchkey 0.1.0
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.
- package/.nvmrc +1 -0
- package/.pre-commit-config.yaml +22 -0
- package/.prettierignore +4 -0
- package/.prettierrc +7 -0
- package/CLAUDE.md +13 -0
- package/LICENSE +7 -0
- package/README.md +167 -0
- package/dist/scripts/cryptFile.d.ts +21 -0
- package/dist/scripts/cryptFile.d.ts.map +1 -0
- package/dist/scripts/cryptFile.js +106 -0
- package/dist/scripts/cryptFile.js.map +1 -0
- package/dist/scripts/encryptFile.d.ts +21 -0
- package/dist/scripts/encryptFile.d.ts.map +1 -0
- package/dist/scripts/encryptFile.js +101 -0
- package/dist/scripts/encryptFile.js.map +1 -0
- package/dist/scripts/recordBrowserSession.d.ts +18 -0
- package/dist/scripts/recordBrowserSession.d.ts.map +1 -0
- package/dist/scripts/recordBrowserSession.js +213 -0
- package/dist/scripts/recordBrowserSession.js.map +1 -0
- package/dist/src/apiCredentialStore.d.ts +19 -0
- package/dist/src/apiCredentialStore.d.ts.map +1 -0
- package/dist/src/apiCredentialStore.js +65 -0
- package/dist/src/apiCredentialStore.js.map +1 -0
- package/dist/src/apiCredentials.d.ts +134 -0
- package/dist/src/apiCredentials.d.ts.map +1 -0
- package/dist/src/apiCredentials.js +139 -0
- package/dist/src/apiCredentials.js.map +1 -0
- package/dist/src/browserConfig.d.ts +90 -0
- package/dist/src/browserConfig.d.ts.map +1 -0
- package/dist/src/browserConfig.js +259 -0
- package/dist/src/browserConfig.js.map +1 -0
- package/dist/src/browserState.d.ts +8 -0
- package/dist/src/browserState.d.ts.map +1 -0
- package/dist/src/browserState.js +21 -0
- package/dist/src/browserState.js.map +1 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +25 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/cliCommands.d.ts +29 -0
- package/dist/src/cliCommands.d.ts.map +1 -0
- package/dist/src/cliCommands.js +264 -0
- package/dist/src/cliCommands.js.map +1 -0
- package/dist/src/config.d.ts +35 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +96 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/curl.d.ts +29 -0
- package/dist/src/curl.d.ts.map +1 -0
- package/dist/src/curl.js +53 -0
- package/dist/src/curl.js.map +1 -0
- package/dist/src/encryptedStorage.d.ts +39 -0
- package/dist/src/encryptedStorage.d.ts.map +1 -0
- package/dist/src/encryptedStorage.js +128 -0
- package/dist/src/encryptedStorage.js.map +1 -0
- package/dist/src/encryption.d.ts +28 -0
- package/dist/src/encryption.d.ts.map +1 -0
- package/dist/src/encryption.js +86 -0
- package/dist/src/encryption.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +17 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/keychain.d.ts +33 -0
- package/dist/src/keychain.d.ts.map +1 -0
- package/dist/src/keychain.js +94 -0
- package/dist/src/keychain.js.map +1 -0
- package/dist/src/playwrightUtils.d.ts +27 -0
- package/dist/src/playwrightUtils.d.ts.map +1 -0
- package/dist/src/playwrightUtils.js +122 -0
- package/dist/src/playwrightUtils.js.map +1 -0
- package/dist/src/registry.d.ts +12 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +30 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/services/base.d.ts +98 -0
- package/dist/src/services/base.d.ts.map +1 -0
- package/dist/src/services/base.js +137 -0
- package/dist/src/services/base.js.map +1 -0
- package/dist/src/services/discord.d.ts +20 -0
- package/dist/src/services/discord.d.ts.map +1 -0
- package/dist/src/services/discord.js +55 -0
- package/dist/src/services/discord.js.map +1 -0
- package/dist/src/services/dropbox.d.ts +23 -0
- package/dist/src/services/dropbox.d.ts.map +1 -0
- package/dist/src/services/dropbox.js +136 -0
- package/dist/src/services/dropbox.js.map +1 -0
- package/dist/src/services/github.d.ts +23 -0
- package/dist/src/services/github.d.ts.map +1 -0
- package/dist/src/services/github.js +110 -0
- package/dist/src/services/github.js.map +1 -0
- package/dist/src/services/index.d.ts +12 -0
- package/dist/src/services/index.d.ts.map +1 -0
- package/dist/src/services/index.js +11 -0
- package/dist/src/services/index.js.map +1 -0
- package/dist/src/services/linear.d.ts +23 -0
- package/dist/src/services/linear.d.ts.map +1 -0
- package/dist/src/services/linear.js +110 -0
- package/dist/src/services/linear.js.map +1 -0
- package/dist/src/services/slack.d.ts +21 -0
- package/dist/src/services/slack.d.ts.map +1 -0
- package/dist/src/services/slack.js +67 -0
- package/dist/src/services/slack.js.map +1 -0
- package/dist/tests/apiCredentialStore.test.d.ts +2 -0
- package/dist/tests/apiCredentialStore.test.d.ts.map +1 -0
- package/dist/tests/apiCredentialStore.test.js +130 -0
- package/dist/tests/apiCredentialStore.test.js.map +1 -0
- package/dist/tests/apiCredentials.test.d.ts +2 -0
- package/dist/tests/apiCredentials.test.d.ts.map +1 -0
- package/dist/tests/apiCredentials.test.js +169 -0
- package/dist/tests/apiCredentials.test.js.map +1 -0
- package/dist/tests/cli.test.d.ts +2 -0
- package/dist/tests/cli.test.d.ts.map +1 -0
- package/dist/tests/cli.test.js +584 -0
- package/dist/tests/cli.test.js.map +1 -0
- package/dist/tests/encryptedStorage.test.d.ts +2 -0
- package/dist/tests/encryptedStorage.test.d.ts.map +1 -0
- package/dist/tests/encryptedStorage.test.js +126 -0
- package/dist/tests/encryptedStorage.test.js.map +1 -0
- package/dist/tests/encryption.test.d.ts +2 -0
- package/dist/tests/encryption.test.d.ts.map +1 -0
- package/dist/tests/encryption.test.js +121 -0
- package/dist/tests/encryption.test.js.map +1 -0
- package/dist/tests/lint.test.d.ts +2 -0
- package/dist/tests/lint.test.d.ts.map +1 -0
- package/dist/tests/lint.test.js +18 -0
- package/dist/tests/lint.test.js.map +1 -0
- package/dist/tests/registry.test.d.ts +2 -0
- package/dist/tests/registry.test.d.ts.map +1 -0
- package/dist/tests/registry.test.js +85 -0
- package/dist/tests/registry.test.js.map +1 -0
- package/dist/tests/servicesAgainstRecordings.test.d.ts +20 -0
- package/dist/tests/servicesAgainstRecordings.test.d.ts.map +1 -0
- package/dist/tests/servicesAgainstRecordings.test.js +157 -0
- package/dist/tests/servicesAgainstRecordings.test.js.map +1 -0
- package/dist/tests/typecheck.test.d.ts +2 -0
- package/dist/tests/typecheck.test.d.ts.map +1 -0
- package/dist/tests/typecheck.test.js +18 -0
- package/dist/tests/typecheck.test.js.map +1 -0
- package/docs/development.md +94 -0
- package/eslint.config.js +30 -0
- package/integrations/SKILL.md +62 -0
- package/package.json +68 -0
- package/scripts/cryptFile.ts +123 -0
- package/scripts/recordBrowserSession.ts +280 -0
- package/scripts/tsconfig.json +10 -0
- package/src/apiCredentialStore.ts +87 -0
- package/src/apiCredentials.ts +180 -0
- package/src/cli.ts +32 -0
- package/src/cliCommands.ts +321 -0
- package/src/config.ts +115 -0
- package/src/curl.ts +78 -0
- package/src/encryptedStorage.ts +161 -0
- package/src/encryption.ts +106 -0
- package/src/index.ts +65 -0
- package/src/keychain.ts +105 -0
- package/src/playwrightUtils.ts +143 -0
- package/src/registry.ts +35 -0
- package/src/services/base.ts +234 -0
- package/src/services/discord.ts +73 -0
- package/src/services/dropbox.ts +173 -0
- package/src/services/github.ts +139 -0
- package/src/services/index.ts +13 -0
- package/src/services/linear.ts +134 -0
- package/src/services/slack.ts +85 -0
- package/tests/apiCredentialStore.test.ts +162 -0
- package/tests/apiCredentials.test.ts +195 -0
- package/tests/cli.test.ts +798 -0
- package/tests/encryptedStorage.test.ts +173 -0
- package/tests/encryption.test.ts +169 -0
- package/tests/lint.test.ts +19 -0
- package/tests/registry.test.ts +103 -0
- package/tests/servicesAgainstRecordings.test.ts +230 -0
- package/tests/typecheck.test.ts +19 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { execSync, ExecSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import {
|
|
8
|
+
extractUrlFromCurlArguments,
|
|
9
|
+
registerCommands,
|
|
10
|
+
type CliDependencies,
|
|
11
|
+
} from '../src/cliCommands.js';
|
|
12
|
+
import { EncryptedStorage } from '../src/encryptedStorage.js';
|
|
13
|
+
import { Config } from '../src/config.js';
|
|
14
|
+
import { Registry } from '../src/registry.js';
|
|
15
|
+
import { SlackApiCredentials, ApiCredentialStatus } from '../src/apiCredentials.js';
|
|
16
|
+
import type { Service } from '../src/services/base.js';
|
|
17
|
+
import type { CurlResult } from '../src/curl.js';
|
|
18
|
+
|
|
19
|
+
// Use a fixed test key for deterministic test behavior (32 bytes = 256 bits, base64 encoded)
|
|
20
|
+
const TEST_ENCRYPTION_KEY = 'dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=';
|
|
21
|
+
|
|
22
|
+
function writeSecureFile(path: string, content: string): void {
|
|
23
|
+
const storage = new EncryptedStorage({ encryptionKeyOverride: TEST_ENCRYPTION_KEY });
|
|
24
|
+
storage.writeFile(path, content);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readSecureFile(path: string): string | null {
|
|
28
|
+
const storage = new EncryptedStorage({ encryptionKeyOverride: TEST_ENCRYPTION_KEY });
|
|
29
|
+
return storage.readFile(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CliResult {
|
|
33
|
+
exitCode: number | null;
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ExecError {
|
|
39
|
+
status: number | null;
|
|
40
|
+
stdout: string;
|
|
41
|
+
stderr: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface StoredCredentials {
|
|
45
|
+
slack?: { objectType: string; token: string; d_cookie: string };
|
|
46
|
+
discord?: { objectType: string; token: string };
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getCliPath(): string | null {
|
|
51
|
+
const projectRoot = join(__dirname, '..');
|
|
52
|
+
// Check both possible paths based on tsconfig setup
|
|
53
|
+
const pathWithSrc = join(projectRoot, 'dist', 'src', 'cli.js');
|
|
54
|
+
const pathWithoutSrc = join(projectRoot, 'dist', 'cli.js');
|
|
55
|
+
|
|
56
|
+
if (existsSync(pathWithSrc)) {
|
|
57
|
+
return pathWithSrc;
|
|
58
|
+
}
|
|
59
|
+
if (existsSync(pathWithoutSrc)) {
|
|
60
|
+
return pathWithoutSrc;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cliPath = getCliPath();
|
|
66
|
+
|
|
67
|
+
interface TestEnv {
|
|
68
|
+
LATCHKEY_STORE: string;
|
|
69
|
+
LATCHKEY_BROWSER_STATE: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runCli(args: string[], env: TestEnv): CliResult {
|
|
73
|
+
const options: ExecSyncOptionsWithStringEncoding = {
|
|
74
|
+
cwd: join(__dirname, '..'),
|
|
75
|
+
encoding: 'utf-8',
|
|
76
|
+
env: {
|
|
77
|
+
...process.env,
|
|
78
|
+
LATCHKEY_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY,
|
|
79
|
+
...env,
|
|
80
|
+
},
|
|
81
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (!cliPath) {
|
|
86
|
+
throw new Error('CLI not built');
|
|
87
|
+
}
|
|
88
|
+
const stdout = execSync(`node ${cliPath} ${args.join(' ')}`, options);
|
|
89
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const execError = error as ExecError;
|
|
92
|
+
return {
|
|
93
|
+
exitCode: execError.status,
|
|
94
|
+
stdout: execError.stdout,
|
|
95
|
+
stderr: execError.stderr,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('extractUrlFromCurlArguments', () => {
|
|
101
|
+
it('should extract URL from simple arguments', () => {
|
|
102
|
+
const arguments_ = ['https://example.com'];
|
|
103
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://example.com');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should extract URL with http scheme', () => {
|
|
107
|
+
const arguments_ = ['http://example.com'];
|
|
108
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('http://example.com');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should extract URL after options', () => {
|
|
112
|
+
const arguments_ = ['-X', 'POST', 'https://api.example.com'];
|
|
113
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should extract URL with headers', () => {
|
|
117
|
+
const arguments_ = ['-H', 'Content-Type: application/json', 'https://api.example.com'];
|
|
118
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should extract URL with data', () => {
|
|
122
|
+
const arguments_ = ['-d', '{"key": "value"}', 'https://api.example.com'];
|
|
123
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should extract URL with long options', () => {
|
|
127
|
+
const arguments_ = ['--header', 'Authorization: Bearer token', 'https://api.example.com'];
|
|
128
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should return null when no URL is present', () => {
|
|
132
|
+
const arguments_ = ['-X', 'POST', '-H', 'Content-Type: application/json'];
|
|
133
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should return null for empty arguments', () => {
|
|
137
|
+
const arguments_: string[] = [];
|
|
138
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should handle verbose flag', () => {
|
|
142
|
+
let arguments_ = ['-v', 'https://api.example.com'];
|
|
143
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
144
|
+
|
|
145
|
+
arguments_ = ['--verbose', 'https://api.example.com'];
|
|
146
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
147
|
+
|
|
148
|
+
arguments_ = ['-v', '-X', 'POST', 'https://api.example.com'];
|
|
149
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should skip flags without values', () => {
|
|
153
|
+
const arguments_ = ['-k', '--compressed', '-s', '-i', 'https://api.example.com'];
|
|
154
|
+
expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('CLI commands with dependency injection', () => {
|
|
159
|
+
let tempDir: string;
|
|
160
|
+
let capturedArgs: string[];
|
|
161
|
+
let logs: string[];
|
|
162
|
+
let errorLogs: string[];
|
|
163
|
+
let exitCode: number | null;
|
|
164
|
+
|
|
165
|
+
function createMockConfig(overrides: Partial<Config> = {}): Config {
|
|
166
|
+
const defaultConfig = new Config(() => undefined);
|
|
167
|
+
return {
|
|
168
|
+
credentialStorePath: overrides.credentialStorePath ?? join(tempDir, 'credentials.json'),
|
|
169
|
+
browserStatePath: overrides.browserStatePath ?? join(tempDir, 'browser_state.json'),
|
|
170
|
+
curlCommand: overrides.curlCommand ?? defaultConfig.curlCommand,
|
|
171
|
+
encryptionKeyOverride: overrides.encryptionKeyOverride ?? TEST_ENCRYPTION_KEY,
|
|
172
|
+
serviceName: overrides.serviceName ?? defaultConfig.serviceName,
|
|
173
|
+
accountName: overrides.accountName ?? defaultConfig.accountName,
|
|
174
|
+
checkSensitiveFilePermissions: () => undefined,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function createMockDependencies(overrides: Partial<CliDependencies> = {}): CliDependencies {
|
|
179
|
+
const mockSlackService: Service = {
|
|
180
|
+
name: 'slack',
|
|
181
|
+
baseApiUrls: ['https://slack.com/api/'],
|
|
182
|
+
loginUrl: 'https://slack.com/signin',
|
|
183
|
+
credentialCheckCurlArguments: ['https://slack.com/api/auth.test'],
|
|
184
|
+
checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
|
|
185
|
+
getSession: vi.fn().mockReturnValue({
|
|
186
|
+
login: vi.fn().mockResolvedValue(new SlackApiCredentials('xoxc-test-token', 'test-cookie')),
|
|
187
|
+
}),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const mockRegistry = new Registry([mockSlackService]);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
registry: mockRegistry,
|
|
194
|
+
config: createMockConfig(),
|
|
195
|
+
runCurl: (args: readonly string[]): CurlResult => {
|
|
196
|
+
capturedArgs.push(...args);
|
|
197
|
+
return { returncode: 0, stdout: '', stderr: '' };
|
|
198
|
+
},
|
|
199
|
+
confirm: () => Promise.resolve(true),
|
|
200
|
+
exit: (code: number): never => {
|
|
201
|
+
exitCode = code;
|
|
202
|
+
throw new Error(`process.exit(${String(code)})`);
|
|
203
|
+
},
|
|
204
|
+
log: (message: string) => {
|
|
205
|
+
logs.push(message);
|
|
206
|
+
},
|
|
207
|
+
errorLog: (message: string) => {
|
|
208
|
+
errorLogs.push(message);
|
|
209
|
+
},
|
|
210
|
+
...overrides,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runCommand(args: string[], deps: CliDependencies): Promise<void> {
|
|
215
|
+
const program = new Command();
|
|
216
|
+
program.exitOverride();
|
|
217
|
+
registerCommands(program, deps);
|
|
218
|
+
try {
|
|
219
|
+
await program.parseAsync(['node', 'latchkey', ...args]);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// Swallow exit errors since we capture the exit code
|
|
222
|
+
if (!(error instanceof Error) || !error.message.startsWith('process.exit(')) {
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
tempDir = mkdtempSync(join(tmpdir(), 'latchkey-cli-test-'));
|
|
230
|
+
capturedArgs = [];
|
|
231
|
+
logs = [];
|
|
232
|
+
errorLogs = [];
|
|
233
|
+
exitCode = null;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
afterEach(() => {
|
|
237
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('services command', () => {
|
|
241
|
+
it('should list all services as space-separated names', async () => {
|
|
242
|
+
const deps = createMockDependencies();
|
|
243
|
+
await runCommand(['services'], deps);
|
|
244
|
+
|
|
245
|
+
expect(logs).toHaveLength(1);
|
|
246
|
+
const services = (logs[0] ?? '').split(' ');
|
|
247
|
+
expect(services).toContain('slack');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('status command', () => {
|
|
252
|
+
it('should return missing when no credentials are stored', async () => {
|
|
253
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
254
|
+
writeSecureFile(storePath, '{}');
|
|
255
|
+
|
|
256
|
+
const deps = createMockDependencies({
|
|
257
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await runCommand(['status', 'slack'], deps);
|
|
261
|
+
|
|
262
|
+
expect(logs).toContain('missing');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should return valid when credentials are valid', async () => {
|
|
266
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
267
|
+
writeSecureFile(
|
|
268
|
+
storePath,
|
|
269
|
+
JSON.stringify({
|
|
270
|
+
slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const deps = createMockDependencies({
|
|
275
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await runCommand(['status', 'slack'], deps);
|
|
279
|
+
|
|
280
|
+
expect(logs).toContain('valid');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should return error for unknown service', async () => {
|
|
284
|
+
const deps = createMockDependencies();
|
|
285
|
+
|
|
286
|
+
await runCommand(['status', 'unknown-service'], deps);
|
|
287
|
+
|
|
288
|
+
expect(exitCode).toBe(1);
|
|
289
|
+
expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should return status for all services when no service name provided', async () => {
|
|
293
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
294
|
+
writeSecureFile(storePath, '{}');
|
|
295
|
+
|
|
296
|
+
const deps = createMockDependencies({
|
|
297
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await runCommand(['status'], deps);
|
|
301
|
+
|
|
302
|
+
expect(logs).toHaveLength(1);
|
|
303
|
+
expect(logs[0]).toBe('slack: missing');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should return status for all services with mixed statuses', async () => {
|
|
307
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
308
|
+
writeSecureFile(
|
|
309
|
+
storePath,
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
|
|
312
|
+
})
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const deps = createMockDependencies({
|
|
316
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await runCommand(['status'], deps);
|
|
320
|
+
|
|
321
|
+
expect(logs).toHaveLength(1);
|
|
322
|
+
expect(logs[0]).toBe('slack: valid');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('clear command', () => {
|
|
327
|
+
it('should delete credentials for a service', async () => {
|
|
328
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
329
|
+
writeSecureFile(
|
|
330
|
+
storePath,
|
|
331
|
+
JSON.stringify({
|
|
332
|
+
slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const deps = createMockDependencies({
|
|
337
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await runCommand(['clear', 'slack'], deps);
|
|
341
|
+
|
|
342
|
+
expect(logs.some((log) => log.includes('have been cleared'))).toBe(true);
|
|
343
|
+
const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as StoredCredentials;
|
|
344
|
+
expect(storedData.slack).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should report no credentials found when service has no stored credentials', async () => {
|
|
348
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
349
|
+
writeSecureFile(storePath, '{}');
|
|
350
|
+
|
|
351
|
+
const deps = createMockDependencies({
|
|
352
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await runCommand(['clear', 'slack'], deps);
|
|
356
|
+
|
|
357
|
+
expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should return error for unknown service', async () => {
|
|
361
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
362
|
+
|
|
363
|
+
const deps = createMockDependencies({
|
|
364
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await runCommand(['clear', 'unknown-service'], deps);
|
|
368
|
+
|
|
369
|
+
expect(exitCode).toBe(1);
|
|
370
|
+
expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should use default config paths', async () => {
|
|
374
|
+
const deps = createMockDependencies();
|
|
375
|
+
|
|
376
|
+
await runCommand(['clear', 'slack'], deps);
|
|
377
|
+
|
|
378
|
+
// With default paths, should report no credentials found (not error about missing env var)
|
|
379
|
+
expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should preserve other services when clearing one', async () => {
|
|
383
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
384
|
+
writeSecureFile(
|
|
385
|
+
storePath,
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
|
|
388
|
+
discord: { objectType: 'authorizationBare', token: 'discord-token' },
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const deps = createMockDependencies({
|
|
393
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await runCommand(['clear', 'slack'], deps);
|
|
397
|
+
|
|
398
|
+
const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as StoredCredentials;
|
|
399
|
+
expect(storedData.slack).toBeUndefined();
|
|
400
|
+
expect(storedData.discord).toBeDefined();
|
|
401
|
+
expect(storedData.discord?.token).toBe('discord-token');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should delete both store and browser state with -y flag', async () => {
|
|
405
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
406
|
+
const browserStatePath = join(tempDir, 'browser_state.json');
|
|
407
|
+
writeSecureFile(
|
|
408
|
+
storePath,
|
|
409
|
+
JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } })
|
|
410
|
+
);
|
|
411
|
+
writeSecureFile(browserStatePath, '{}');
|
|
412
|
+
|
|
413
|
+
const deps = createMockDependencies({
|
|
414
|
+
config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await runCommand(['clear', '-y'], deps);
|
|
418
|
+
|
|
419
|
+
expect(existsSync(storePath)).toBe(false);
|
|
420
|
+
expect(existsSync(browserStatePath)).toBe(false);
|
|
421
|
+
expect(logs.some((log) => log.includes('Deleted credentials store'))).toBe(true);
|
|
422
|
+
expect(logs.some((log) => log.includes('Deleted browser state'))).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should report no files to delete when none exist', async () => {
|
|
426
|
+
const storePath = join(tempDir, 'nonexistent_store.json');
|
|
427
|
+
const browserStatePath = join(tempDir, 'nonexistent_browser_state.json');
|
|
428
|
+
|
|
429
|
+
const deps = createMockDependencies({
|
|
430
|
+
config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await runCommand(['clear', '-y'], deps);
|
|
434
|
+
|
|
435
|
+
expect(logs.some((log) => log.includes('No files to delete'))).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe('curl command', () => {
|
|
440
|
+
it('should pass arguments to subprocess', async () => {
|
|
441
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
442
|
+
writeSecureFile(
|
|
443
|
+
storePath,
|
|
444
|
+
JSON.stringify({
|
|
445
|
+
slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const deps = createMockDependencies({
|
|
450
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await runCommand(['curl', 'https://slack.com/api/test'], deps);
|
|
454
|
+
|
|
455
|
+
expect(capturedArgs).toEqual([
|
|
456
|
+
'-H',
|
|
457
|
+
'Authorization: Bearer stored-token',
|
|
458
|
+
'-H',
|
|
459
|
+
'Cookie: d=stored-cookie',
|
|
460
|
+
'https://slack.com/api/test',
|
|
461
|
+
]);
|
|
462
|
+
expect(exitCode).toBe(0);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should pass multiple arguments correctly', async () => {
|
|
466
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
467
|
+
writeSecureFile(
|
|
468
|
+
storePath,
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const deps = createMockDependencies({
|
|
475
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await runCommand(
|
|
479
|
+
[
|
|
480
|
+
'curl',
|
|
481
|
+
'--',
|
|
482
|
+
'-X',
|
|
483
|
+
'POST',
|
|
484
|
+
'-H',
|
|
485
|
+
'Content-Type: application/json',
|
|
486
|
+
'https://slack.com/api/test',
|
|
487
|
+
],
|
|
488
|
+
deps
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
expect(capturedArgs).toContain('-X');
|
|
492
|
+
expect(capturedArgs).toContain('POST');
|
|
493
|
+
expect(capturedArgs).toContain('-H');
|
|
494
|
+
expect(capturedArgs).toContain('Content-Type: application/json');
|
|
495
|
+
expect(capturedArgs).toContain('https://slack.com/api/test');
|
|
496
|
+
expect(capturedArgs).toContain('Authorization: Bearer stored-token');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('should return subprocess exit code', async () => {
|
|
500
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
501
|
+
writeSecureFile(
|
|
502
|
+
storePath,
|
|
503
|
+
JSON.stringify({
|
|
504
|
+
slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const deps = createMockDependencies({
|
|
509
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
510
|
+
runCurl: (): CurlResult => ({ returncode: 42, stdout: '', stderr: '' }),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await runCommand(['curl', 'https://slack.com/api/test'], deps);
|
|
514
|
+
|
|
515
|
+
expect(exitCode).toBe(42);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should return error when no URL found', async () => {
|
|
519
|
+
const deps = createMockDependencies();
|
|
520
|
+
|
|
521
|
+
await runCommand(['curl', '--', '-X', 'POST'], deps);
|
|
522
|
+
|
|
523
|
+
expect(exitCode).toBe(1);
|
|
524
|
+
expect(errorLogs.some((log) => log.includes('Could not extract URL'))).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('should return error for unknown service', async () => {
|
|
528
|
+
const deps = createMockDependencies();
|
|
529
|
+
|
|
530
|
+
await runCommand(['curl', 'https://unknown-api.example.com'], deps);
|
|
531
|
+
|
|
532
|
+
expect(exitCode).toBe(1);
|
|
533
|
+
expect(errorLogs.some((log) => log.includes('No service matches URL'))).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should inject credentials with verbose flag', async () => {
|
|
537
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
538
|
+
writeSecureFile(
|
|
539
|
+
storePath,
|
|
540
|
+
JSON.stringify({
|
|
541
|
+
slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
|
|
542
|
+
})
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const deps = createMockDependencies({
|
|
546
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await runCommand(['curl', '--', '-v', 'https://slack.com/api/conversations.list'], deps);
|
|
550
|
+
|
|
551
|
+
expect(capturedArgs).toContain('-v');
|
|
552
|
+
expect(capturedArgs).toContain('Authorization: Bearer stored-token');
|
|
553
|
+
expect(capturedArgs).toContain('https://slack.com/api/conversations.list');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('should read credentials from store and not call login', async () => {
|
|
557
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
558
|
+
writeSecureFile(
|
|
559
|
+
storePath,
|
|
560
|
+
JSON.stringify({
|
|
561
|
+
slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
|
|
562
|
+
})
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const mockLogin = vi.fn();
|
|
566
|
+
const mockSlackService: Service = {
|
|
567
|
+
name: 'slack',
|
|
568
|
+
baseApiUrls: ['https://slack.com/api/'],
|
|
569
|
+
loginUrl: 'https://slack.com/signin',
|
|
570
|
+
credentialCheckCurlArguments: [],
|
|
571
|
+
checkApiCredentials: vi.fn(),
|
|
572
|
+
getSession: vi.fn().mockReturnValue({ login: mockLogin }),
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const deps = createMockDependencies({
|
|
576
|
+
registry: new Registry([mockSlackService]),
|
|
577
|
+
config: createMockConfig({ credentialStorePath: storePath }),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await runCommand(['curl', 'https://slack.com/api/test'], deps);
|
|
581
|
+
|
|
582
|
+
expect(mockLogin).not.toHaveBeenCalled();
|
|
583
|
+
expect(capturedArgs).toContain('Authorization: Bearer stored-token');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('should call login when no credentials in store', async () => {
|
|
587
|
+
const storePath = join(tempDir, 'credentials.json');
|
|
588
|
+
const browserStatePath = join(tempDir, 'browser_state.json');
|
|
589
|
+
writeSecureFile(storePath, '{}');
|
|
590
|
+
|
|
591
|
+
const mockLogin = vi
|
|
592
|
+
.fn()
|
|
593
|
+
.mockResolvedValue(new SlackApiCredentials('new-token', 'new-cookie'));
|
|
594
|
+
const mockSlackService: Service = {
|
|
595
|
+
name: 'slack',
|
|
596
|
+
baseApiUrls: ['https://slack.com/api/'],
|
|
597
|
+
loginUrl: 'https://slack.com/signin',
|
|
598
|
+
credentialCheckCurlArguments: [],
|
|
599
|
+
checkApiCredentials: vi.fn(),
|
|
600
|
+
getSession: vi.fn().mockReturnValue({ login: mockLogin }),
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const deps = createMockDependencies({
|
|
604
|
+
registry: new Registry([mockSlackService]),
|
|
605
|
+
config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
await runCommand(['curl', 'https://slack.com/api/test'], deps);
|
|
609
|
+
|
|
610
|
+
expect(mockLogin).toHaveBeenCalledWith(expect.any(EncryptedStorage), browserStatePath);
|
|
611
|
+
expect(capturedArgs).toContain('Authorization: Bearer new-token');
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Integration tests that run the actual CLI binary
|
|
617
|
+
describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
|
|
618
|
+
let tempDir: string;
|
|
619
|
+
let testEnv: TestEnv;
|
|
620
|
+
|
|
621
|
+
beforeEach(() => {
|
|
622
|
+
tempDir = mkdtempSync(join(tmpdir(), 'latchkey-cli-test-'));
|
|
623
|
+
testEnv = {
|
|
624
|
+
LATCHKEY_STORE: join(tempDir, 'credentials.json'),
|
|
625
|
+
LATCHKEY_BROWSER_STATE: join(tempDir, 'browser_state.json'),
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
afterEach(() => {
|
|
630
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('curl command', () => {
|
|
634
|
+
it('should return error when curl has no arguments', () => {
|
|
635
|
+
const result = runCli(['curl'], testEnv);
|
|
636
|
+
expect(result.exitCode).toBe(1);
|
|
637
|
+
expect(result.stderr).toContain('Could not extract URL');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('should return error when no URL found in curl arguments', () => {
|
|
641
|
+
const result = runCli(['curl', '--', '-X', 'POST'], testEnv);
|
|
642
|
+
expect(result.exitCode).toBe(1);
|
|
643
|
+
expect(result.stderr).toContain('Could not extract URL');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('should return error for unknown service', () => {
|
|
647
|
+
const result = runCli(['curl', 'https://unknown-api.example.com'], testEnv);
|
|
648
|
+
expect(result.exitCode).toBe(1);
|
|
649
|
+
expect(result.stderr).toContain('No service matches URL');
|
|
650
|
+
expect(result.stderr).toContain('https://unknown-api.example.com');
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe('status command', () => {
|
|
655
|
+
it('should return missing when no credentials are stored', () => {
|
|
656
|
+
writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
|
|
657
|
+
|
|
658
|
+
const result = runCli(['status', 'slack'], testEnv);
|
|
659
|
+
expect(result.exitCode).toBe(0);
|
|
660
|
+
expect(result.stdout.trim()).toBe('missing');
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('should return error for unknown service', () => {
|
|
664
|
+
const result = runCli(['status', 'unknown-service'], testEnv);
|
|
665
|
+
expect(result.exitCode).toBe(1);
|
|
666
|
+
expect(result.stderr).toContain('Unknown service');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('should return status for all services when no service name provided', () => {
|
|
670
|
+
writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
|
|
671
|
+
|
|
672
|
+
const result = runCli(['status'], testEnv);
|
|
673
|
+
expect(result.exitCode).toBe(0);
|
|
674
|
+
|
|
675
|
+
const lines = result.stdout.trim().split('\n');
|
|
676
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
677
|
+
expect(lines.some((line) => line.includes('slack: missing'))).toBe(true);
|
|
678
|
+
expect(lines.some((line) => line.includes('discord: missing'))).toBe(true);
|
|
679
|
+
expect(lines.some((line) => line.includes('github: missing'))).toBe(true);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('should return status for all services with mixed statuses', () => {
|
|
683
|
+
writeSecureFile(
|
|
684
|
+
testEnv.LATCHKEY_STORE,
|
|
685
|
+
JSON.stringify({
|
|
686
|
+
slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
|
|
687
|
+
})
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
const result = runCli(['status'], testEnv);
|
|
691
|
+
expect(result.exitCode).toBe(0);
|
|
692
|
+
|
|
693
|
+
const lines = result.stdout.trim().split('\n');
|
|
694
|
+
expect(lines.some((line) => line.includes('slack: invalid'))).toBe(true);
|
|
695
|
+
expect(lines.some((line) => line.includes('discord: missing'))).toBe(true);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe('clear command', () => {
|
|
700
|
+
it('should delete credentials for a service', () => {
|
|
701
|
+
writeSecureFile(
|
|
702
|
+
testEnv.LATCHKEY_STORE,
|
|
703
|
+
JSON.stringify({
|
|
704
|
+
slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
|
|
705
|
+
})
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const result = runCli(['clear', 'slack'], testEnv);
|
|
709
|
+
expect(result.exitCode).toBe(0);
|
|
710
|
+
expect(result.stdout).toContain('API credentials for slack have been cleared');
|
|
711
|
+
|
|
712
|
+
const storedData = JSON.parse(
|
|
713
|
+
readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}'
|
|
714
|
+
) as StoredCredentials;
|
|
715
|
+
expect(storedData.slack).toBeUndefined();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('should report no credentials found when service has no stored credentials', () => {
|
|
719
|
+
writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
|
|
720
|
+
|
|
721
|
+
const result = runCli(['clear', 'slack'], testEnv);
|
|
722
|
+
expect(result.exitCode).toBe(0);
|
|
723
|
+
expect(result.stdout).toContain('No API credentials found for slack');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('should return error for unknown service', () => {
|
|
727
|
+
const result = runCli(['clear', 'unknown-service'], testEnv);
|
|
728
|
+
expect(result.exitCode).toBe(1);
|
|
729
|
+
expect(result.stderr).toContain('Unknown service: unknown-service');
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('should preserve other services when clearing one', () => {
|
|
733
|
+
writeSecureFile(
|
|
734
|
+
testEnv.LATCHKEY_STORE,
|
|
735
|
+
JSON.stringify({
|
|
736
|
+
slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
|
|
737
|
+
discord: { objectType: 'authorizationBare', token: 'discord-token' },
|
|
738
|
+
})
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
const result = runCli(['clear', 'slack'], testEnv);
|
|
742
|
+
expect(result.exitCode).toBe(0);
|
|
743
|
+
|
|
744
|
+
const storedData = JSON.parse(
|
|
745
|
+
readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}'
|
|
746
|
+
) as StoredCredentials;
|
|
747
|
+
expect(storedData.slack).toBeUndefined();
|
|
748
|
+
expect(storedData.discord).toBeDefined();
|
|
749
|
+
expect(storedData.discord?.token).toBe('discord-token');
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('should delete both store and browser state with -y flag', () => {
|
|
753
|
+
writeSecureFile(
|
|
754
|
+
testEnv.LATCHKEY_STORE,
|
|
755
|
+
JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } })
|
|
756
|
+
);
|
|
757
|
+
writeSecureFile(testEnv.LATCHKEY_BROWSER_STATE, '{}');
|
|
758
|
+
|
|
759
|
+
const result = runCli(['clear', '-y'], testEnv);
|
|
760
|
+
expect(result.exitCode).toBe(0);
|
|
761
|
+
expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
|
|
762
|
+
expect(existsSync(testEnv.LATCHKEY_BROWSER_STATE)).toBe(false);
|
|
763
|
+
expect(result.stdout).toContain(`Deleted credentials store: ${testEnv.LATCHKEY_STORE}`);
|
|
764
|
+
expect(result.stdout).toContain(`Deleted browser state: ${testEnv.LATCHKEY_BROWSER_STATE}`);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('should delete only existing files with -y flag', () => {
|
|
768
|
+
writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
|
|
769
|
+
// browser_state does not exist
|
|
770
|
+
|
|
771
|
+
const result = runCli(['clear', '-y'], testEnv);
|
|
772
|
+
expect(result.exitCode).toBe(0);
|
|
773
|
+
expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
|
|
774
|
+
expect(result.stdout).toContain(`Deleted credentials store: ${testEnv.LATCHKEY_STORE}`);
|
|
775
|
+
expect(result.stdout).not.toContain('browser state');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should report no files to delete when none exist', () => {
|
|
779
|
+
const result = runCli(['clear', '-y'], testEnv);
|
|
780
|
+
expect(result.exitCode).toBe(0);
|
|
781
|
+
expect(result.stdout).toContain('No files to delete');
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe('services command', () => {
|
|
786
|
+
it('should list all services as space-separated names', () => {
|
|
787
|
+
const result = runCli(['services'], testEnv);
|
|
788
|
+
expect(result.exitCode).toBe(0);
|
|
789
|
+
|
|
790
|
+
const services = result.stdout.trim().split(' ');
|
|
791
|
+
expect(services).toContain('slack');
|
|
792
|
+
expect(services).toContain('discord');
|
|
793
|
+
expect(services).toContain('github');
|
|
794
|
+
expect(services).toContain('dropbox');
|
|
795
|
+
expect(services).toContain('linear');
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
});
|