@wenrwa/marketplace-cli 0.5.24 → 0.6.3
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/dist/__tests__/apps.test.js +22 -0
- package/dist/__tests__/auth-api-key-format.test.d.ts +1 -0
- package/dist/__tests__/auth-api-key-format.test.js +41 -0
- package/dist/__tests__/auth-identity.test.d.ts +1 -0
- package/dist/__tests__/auth-identity.test.js +46 -0
- package/dist/__tests__/auth-login-core.test.d.ts +1 -0
- package/dist/__tests__/auth-login-core.test.js +145 -0
- package/dist/__tests__/auth.test.js +246 -57
- package/dist/__tests__/bridge-bundle-installer.test.js +101 -0
- package/dist/__tests__/bridge-context-writer.test.js +81 -1
- package/dist/__tests__/bridge-local-context-writer.test.js +11 -2
- package/dist/__tests__/client.test.js +26 -4
- package/dist/__tests__/config.test.js +36 -4
- package/dist/__tests__/openBrowser.test.d.ts +1 -0
- package/dist/__tests__/openBrowser.test.js +42 -0
- package/dist/__tests__/prompt.test.d.ts +1 -0
- package/dist/__tests__/prompt.test.js +49 -0
- package/dist/auth/identity.d.ts +22 -0
- package/dist/auth/identity.js +20 -0
- package/dist/auth/login.d.ts +80 -0
- package/dist/auth/login.js +61 -0
- package/dist/bridge/apiClient.d.ts +25 -0
- package/dist/bridge/apiClient.js +7 -1
- package/dist/bridge/bundleInstaller.d.ts +14 -0
- package/dist/bridge/bundleInstaller.js +56 -2
- package/dist/bridge/contextWriter.d.ts +11 -1
- package/dist/bridge/contextWriter.js +52 -8
- package/dist/bridge/localContextWriter.d.ts +6 -1
- package/dist/bridge/localContextWriter.js +22 -7
- package/dist/bridge/pairing.js +7 -1
- package/dist/bridge/relayClient.d.ts +9 -0
- package/dist/bridge/relayClient.js +25 -0
- package/dist/bridge/server.d.ts +6 -0
- package/dist/bridge/server.js +8 -0
- package/dist/commands/apps.js +1 -1
- package/dist/commands/auth.d.ts +17 -0
- package/dist/commands/auth.js +202 -27
- package/dist/commands/bridge.js +61 -0
- package/dist/index.js +0 -0
- package/dist/utils/apiKeyFormat.d.ts +9 -0
- package/dist/utils/apiKeyFormat.js +26 -0
- package/dist/utils/client.js +7 -1
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +28 -2
- package/dist/utils/openBrowser.d.ts +16 -0
- package/dist/utils/openBrowser.js +30 -0
- package/dist/utils/prompt.d.ts +9 -0
- package/dist/utils/prompt.js +63 -0
- package/package.json +1 -1
|
@@ -158,3 +158,25 @@ describe('apps export-github', () => {
|
|
|
158
158
|
spy.mockRestore();
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
|
+
describe('apps download', () => {
|
|
162
|
+
it('authenticates with the API key header, never the retired wallet header', async () => {
|
|
163
|
+
mockClient.getApiUrl = jest.fn(() => 'https://api.example/api/v1');
|
|
164
|
+
mockClient.getApiKey = jest.fn(() => 'wm_sk_testkey');
|
|
165
|
+
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
|
166
|
+
ok: true,
|
|
167
|
+
arrayBuffer: async () => new ArrayBuffer(3),
|
|
168
|
+
});
|
|
169
|
+
const fs = require('fs');
|
|
170
|
+
const writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
|
|
171
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
172
|
+
await runCommand('download', 'proj-1', '-o', '/tmp/out.zip');
|
|
173
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
174
|
+
expect(url).toBe('https://api.example/api/v1/projects/proj-1/source');
|
|
175
|
+
const headers = init.headers;
|
|
176
|
+
expect(headers['X-API-Key']).toBe('wm_sk_testkey');
|
|
177
|
+
expect(Object.keys(headers).map((h) => h.toLowerCase())).not.toContain('x-wallet-pubkey');
|
|
178
|
+
fetchSpy.mockRestore();
|
|
179
|
+
writeSpy.mockRestore();
|
|
180
|
+
logSpy.mockRestore();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — wha-08: distinguishing a real API key (`wm_sk_…`, matches
|
|
5
|
+
* ApiKeyService#generateKey in backend/src/services/api-key.service.ts) from
|
|
6
|
+
* the old habit of pasting a bare Solana wallet public key (base58,
|
|
7
|
+
* 32-44 chars) into --api-key.
|
|
8
|
+
*/
|
|
9
|
+
const apiKeyFormat_1 = require("../utils/apiKeyFormat");
|
|
10
|
+
describe('isRealApiKey', () => {
|
|
11
|
+
it('accepts a key with the real wm_sk_ prefix', () => {
|
|
12
|
+
expect((0, apiKeyFormat_1.isRealApiKey)('wm_sk_' + 'a'.repeat(64))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('rejects a bare wallet public key', () => {
|
|
15
|
+
expect((0, apiKeyFormat_1.isRealApiKey)('AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9')).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it('rejects an empty or garbage string', () => {
|
|
18
|
+
expect((0, apiKeyFormat_1.isRealApiKey)('')).toBe(false);
|
|
19
|
+
expect((0, apiKeyFormat_1.isRealApiKey)('not-a-key')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('looksLikeWalletPubkey', () => {
|
|
23
|
+
it('matches a base58 Solana public key', () => {
|
|
24
|
+
expect((0, apiKeyFormat_1.looksLikeWalletPubkey)('AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it('does not match a real API key', () => {
|
|
27
|
+
expect((0, apiKeyFormat_1.looksLikeWalletPubkey)('wm_sk_' + 'a'.repeat(64))).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('does not match short or non-base58 strings', () => {
|
|
30
|
+
expect((0, apiKeyFormat_1.looksLikeWalletPubkey)('short')).toBe(false);
|
|
31
|
+
expect((0, apiKeyFormat_1.looksLikeWalletPubkey)('has-dashes-and-is-long-enough-0000000')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('shortenKey', () => {
|
|
35
|
+
it('shows a short prefix and hides the rest', () => {
|
|
36
|
+
const shortened = (0, apiKeyFormat_1.shortenKey)('wm_sk_' + 'a'.repeat(64));
|
|
37
|
+
expect(shortened.startsWith('wm_sk_')).toBe(true);
|
|
38
|
+
expect(shortened.length).toBeLessThan(20);
|
|
39
|
+
expect(shortened).toContain('…');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — wha-08: resolveIdentity does the ONE authenticated call both `auth
|
|
5
|
+
* login` (device flow success) and `auth login --api-key` (CI) need: verify
|
|
6
|
+
* the key actually authenticates, and if so, try for a human-readable name
|
|
7
|
+
* (falling back to nothing — the caller decides the final display string).
|
|
8
|
+
* GET /agents/me is used because it works for every account (an account
|
|
9
|
+
* with no registered agent still gets `{ success: true, agent: null }`), so
|
|
10
|
+
* it doubles as a cheap "is this key valid" check.
|
|
11
|
+
*/
|
|
12
|
+
const identity_1 = require("../auth/identity");
|
|
13
|
+
function fakeFetch(status, body) {
|
|
14
|
+
return jest.fn().mockResolvedValue({
|
|
15
|
+
ok: status >= 200 && status < 300,
|
|
16
|
+
status,
|
|
17
|
+
json: async () => body,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
describe('resolveIdentity', () => {
|
|
21
|
+
it('sends the key as X-API-Key (never X-Wallet-Pubkey) and returns the agent name when present', async () => {
|
|
22
|
+
const fetchImpl = fakeFetch(200, { success: true, agent: { name: 'TestAgent' } });
|
|
23
|
+
const result = await (0, identity_1.resolveIdentity)({ apiUrl: 'https://api.x', apiKey: 'wm_sk_abc', fetchImpl });
|
|
24
|
+
expect(result).toEqual({ ok: true, name: 'TestAgent' });
|
|
25
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
26
|
+
expect(url).toBe('https://api.x/agents/me');
|
|
27
|
+
expect(init.headers['X-API-Key']).toBe('wm_sk_abc');
|
|
28
|
+
expect(init.headers['X-Wallet-Pubkey']).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it('returns ok with no name when the account has no registered agent', async () => {
|
|
31
|
+
const fetchImpl = fakeFetch(200, { success: true, agent: null });
|
|
32
|
+
const result = await (0, identity_1.resolveIdentity)({ apiUrl: 'https://api.x', apiKey: 'wm_sk_abc', fetchImpl });
|
|
33
|
+
expect(result).toEqual({ ok: true, name: undefined });
|
|
34
|
+
});
|
|
35
|
+
it('returns ok:false with the server message for an invalid/revoked key', async () => {
|
|
36
|
+
const fetchImpl = fakeFetch(401, { success: false, error: { code: 'INVALID_API_KEY', message: 'Invalid, expired, or revoked API key' } });
|
|
37
|
+
const result = await (0, identity_1.resolveIdentity)({ apiUrl: 'https://api.x', apiKey: 'wm_sk_bad', fetchImpl });
|
|
38
|
+
expect(result).toEqual({ ok: false, message: 'Invalid, expired, or revoked API key' });
|
|
39
|
+
});
|
|
40
|
+
it('returns ok:false when the network call throws', async () => {
|
|
41
|
+
const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
42
|
+
const result = await (0, identity_1.resolveIdentity)({ apiUrl: 'https://api.x', apiKey: 'wm_sk_abc', fetchImpl });
|
|
43
|
+
expect(result.ok).toBe(false);
|
|
44
|
+
expect(result.message).toContain('ECONNREFUSED');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — wha-08: `wen-market auth login` device-flow core. Mirrors
|
|
5
|
+
* bridge-pairing-loop.test.ts / bridge-pairing-core.test.ts's shape (pure
|
|
6
|
+
* poll loop + pure orchestrating core, network/clock/cancellation all
|
|
7
|
+
* injected) but is intentionally a SEPARATE module from bridge/pairing.ts —
|
|
8
|
+
* `wen-market pair` (bridge pairing) must keep working byte-for-byte
|
|
9
|
+
* unchanged, and cli_login has an outcome bridge pairing's loop never
|
|
10
|
+
* handles: the human can DENY a CLI login from the approval screen.
|
|
11
|
+
*/
|
|
12
|
+
const login_1 = require("../auth/login");
|
|
13
|
+
describe('runLoginPoll', () => {
|
|
14
|
+
it('polls until approved and returns the freshly minted API key', async () => {
|
|
15
|
+
const poll = jest
|
|
16
|
+
.fn()
|
|
17
|
+
.mockResolvedValueOnce({ success: true, status: 'pending' })
|
|
18
|
+
.mockResolvedValueOnce({ success: true, status: 'pending' })
|
|
19
|
+
.mockResolvedValueOnce({ success: true, status: 'approved', purpose: 'cli_login', apiKey: 'wm_sk_freshkey' });
|
|
20
|
+
const sleep = jest.fn().mockResolvedValue(undefined);
|
|
21
|
+
const result = await (0, login_1.runLoginPoll)({
|
|
22
|
+
poll,
|
|
23
|
+
sleep,
|
|
24
|
+
intervalMs: 2000,
|
|
25
|
+
deadlineMs: 60000,
|
|
26
|
+
now: () => 0,
|
|
27
|
+
isCancelled: () => false,
|
|
28
|
+
});
|
|
29
|
+
expect(result).toEqual({ outcome: 'approved', apiKey: 'wm_sk_freshkey' });
|
|
30
|
+
expect(poll).toHaveBeenCalledTimes(3);
|
|
31
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
32
|
+
expect(sleep).toHaveBeenCalledWith(2000);
|
|
33
|
+
});
|
|
34
|
+
it('stops with a denied outcome as soon as the human declines in the browser', async () => {
|
|
35
|
+
const poll = jest.fn().mockResolvedValue({ success: true, status: 'denied' });
|
|
36
|
+
const result = await (0, login_1.runLoginPoll)({
|
|
37
|
+
poll,
|
|
38
|
+
sleep: jest.fn().mockResolvedValue(undefined),
|
|
39
|
+
intervalMs: 1000,
|
|
40
|
+
deadlineMs: 60000,
|
|
41
|
+
now: () => 0,
|
|
42
|
+
isCancelled: () => false,
|
|
43
|
+
});
|
|
44
|
+
expect(result).toEqual({ outcome: 'denied' });
|
|
45
|
+
expect(poll).toHaveBeenCalledTimes(1);
|
|
46
|
+
});
|
|
47
|
+
it('stops with an expired outcome as soon as the server reports expired', async () => {
|
|
48
|
+
const poll = jest.fn().mockResolvedValue({ success: true, status: 'expired' });
|
|
49
|
+
const result = await (0, login_1.runLoginPoll)({
|
|
50
|
+
poll,
|
|
51
|
+
sleep: jest.fn().mockResolvedValue(undefined),
|
|
52
|
+
intervalMs: 1000,
|
|
53
|
+
deadlineMs: 60000,
|
|
54
|
+
now: () => 0,
|
|
55
|
+
isCancelled: () => false,
|
|
56
|
+
});
|
|
57
|
+
expect(result).toEqual({ outcome: 'expired' });
|
|
58
|
+
expect(poll).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
it('gives up with a timeout outcome once the overall deadline passes, without polling past it', async () => {
|
|
61
|
+
let elapsed = 0;
|
|
62
|
+
const poll = jest.fn().mockResolvedValue({ success: true, status: 'pending' });
|
|
63
|
+
const sleep = jest.fn().mockImplementation(async (ms) => {
|
|
64
|
+
elapsed += ms;
|
|
65
|
+
});
|
|
66
|
+
const result = await (0, login_1.runLoginPoll)({
|
|
67
|
+
poll,
|
|
68
|
+
sleep,
|
|
69
|
+
intervalMs: 2000,
|
|
70
|
+
deadlineMs: 5000,
|
|
71
|
+
now: () => elapsed,
|
|
72
|
+
isCancelled: () => false,
|
|
73
|
+
});
|
|
74
|
+
expect(result).toEqual({ outcome: 'timeout' });
|
|
75
|
+
expect(poll.mock.calls.length).toBeLessThanOrEqual(3);
|
|
76
|
+
});
|
|
77
|
+
it('is Ctrl-C safe — stops promptly and reports cancelled without an extra poll', async () => {
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
const poll = jest.fn().mockImplementation(async () => {
|
|
80
|
+
cancelled = true; // simulate Ctrl-C arriving while the first poll is in flight
|
|
81
|
+
return { success: true, status: 'pending' };
|
|
82
|
+
});
|
|
83
|
+
const sleep = jest.fn().mockResolvedValue(undefined);
|
|
84
|
+
const result = await (0, login_1.runLoginPoll)({
|
|
85
|
+
poll,
|
|
86
|
+
sleep,
|
|
87
|
+
intervalMs: 2000,
|
|
88
|
+
deadlineMs: 60000,
|
|
89
|
+
now: () => 0,
|
|
90
|
+
isCancelled: () => cancelled,
|
|
91
|
+
});
|
|
92
|
+
expect(result).toEqual({ outcome: 'cancelled' });
|
|
93
|
+
expect(poll).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
it('treats an approved response with no apiKey as expired (defensive — should never happen server-side)', async () => {
|
|
97
|
+
const poll = jest.fn().mockResolvedValue({ success: true, status: 'approved', purpose: 'cli_login' });
|
|
98
|
+
const result = await (0, login_1.runLoginPoll)({
|
|
99
|
+
poll,
|
|
100
|
+
sleep: jest.fn(),
|
|
101
|
+
intervalMs: 1000,
|
|
102
|
+
deadlineMs: 60000,
|
|
103
|
+
now: () => 0,
|
|
104
|
+
isCancelled: () => false,
|
|
105
|
+
});
|
|
106
|
+
expect(result).toEqual({ outcome: 'expired' });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('loginCore', () => {
|
|
110
|
+
it('requests a cli_login code with the hostname, fires onCodeReady, and returns the poll outcome', async () => {
|
|
111
|
+
const requestPairingCode = jest.fn().mockResolvedValue({ code: 'HIVE-7K3Q', pollToken: 'pt', expiresAt: '2026-01-01T00:10:00Z' });
|
|
112
|
+
const pollPairingCode = jest.fn().mockResolvedValue({ success: true, status: 'approved', purpose: 'cli_login', apiKey: 'wm_sk_freshkey' });
|
|
113
|
+
const onCodeReady = jest.fn();
|
|
114
|
+
const outcome = await (0, login_1.loginCore)({ apiUrl: 'https://api.x' }, {
|
|
115
|
+
requestPairingCode,
|
|
116
|
+
pollPairingCode,
|
|
117
|
+
hostname: () => 'bunnys-macbook',
|
|
118
|
+
sleep: jest.fn().mockResolvedValue(undefined),
|
|
119
|
+
now: () => 0,
|
|
120
|
+
isCancelled: () => false,
|
|
121
|
+
onCodeReady,
|
|
122
|
+
});
|
|
123
|
+
expect(requestPairingCode).toHaveBeenCalledWith({ apiUrl: 'https://api.x', deviceLabel: 'bunnys-macbook', purpose: 'cli_login' });
|
|
124
|
+
expect(pollPairingCode).toHaveBeenCalledWith({ apiUrl: 'https://api.x', pollToken: 'pt' });
|
|
125
|
+
expect(onCodeReady).toHaveBeenCalledWith({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z' });
|
|
126
|
+
expect(outcome).toEqual({
|
|
127
|
+
code: 'HIVE-7K3Q',
|
|
128
|
+
expiresAt: '2026-01-01T00:10:00Z',
|
|
129
|
+
result: { outcome: 'approved', apiKey: 'wm_sk_freshkey' },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('propagates a denied outcome without touching disk (loginCore never saves — the command shell does)', async () => {
|
|
133
|
+
const requestPairingCode = jest.fn().mockResolvedValue({ code: 'HIVE-7K3Q', pollToken: 'pt', expiresAt: '2026-01-01T00:10:00Z' });
|
|
134
|
+
const pollPairingCode = jest.fn().mockResolvedValue({ success: true, status: 'denied' });
|
|
135
|
+
const outcome = await (0, login_1.loginCore)({ apiUrl: 'https://api.x' }, {
|
|
136
|
+
requestPairingCode,
|
|
137
|
+
pollPairingCode,
|
|
138
|
+
hostname: () => 'bunnys-macbook',
|
|
139
|
+
sleep: jest.fn().mockResolvedValue(undefined),
|
|
140
|
+
now: () => 0,
|
|
141
|
+
isCancelled: () => false,
|
|
142
|
+
});
|
|
143
|
+
expect(outcome.result).toEqual({ outcome: 'denied' });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -3,92 +3,281 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
/**
|
|
7
|
+
* TDD — wha-08: `wen-market auth login` / `auth logout` / `auth whoami`.
|
|
8
|
+
* The device-flow poll loop itself is tested in auth-login-core.test.ts
|
|
9
|
+
* (runLoginPoll/loginCore) — here loginCore is mocked, and these tests cover
|
|
10
|
+
* the command shell: the already-signed-in prompt, reporting each outcome
|
|
11
|
+
* with the agreed message, the --api-key CI path (accept wm_sk_, refuse a
|
|
12
|
+
* wallet pubkey), logout's revoke-then-delete, and Ctrl+C wiring.
|
|
13
|
+
*/
|
|
6
14
|
const chalk_1 = __importDefault(require("chalk"));
|
|
15
|
+
const REAL_KEY = 'wm_sk_' + 'a'.repeat(64);
|
|
16
|
+
const WALLET_PUBKEY = 'AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9';
|
|
7
17
|
const mockLoadConfig = jest.fn();
|
|
8
18
|
const mockSaveConfig = jest.fn();
|
|
19
|
+
const mockClearConfig = jest.fn();
|
|
9
20
|
const mockGetApiUrl = jest.fn().mockReturnValue('http://test:3001/api/marketplace');
|
|
10
21
|
jest.mock('../utils/config', () => ({
|
|
11
22
|
loadConfig: (...args) => mockLoadConfig(...args),
|
|
12
23
|
saveConfig: (...args) => mockSaveConfig(...args),
|
|
24
|
+
clearConfig: (...args) => mockClearConfig(...args),
|
|
13
25
|
getApiUrl: (...args) => mockGetApiUrl(...args),
|
|
14
26
|
}));
|
|
27
|
+
const mockLoginCore = jest.fn();
|
|
28
|
+
jest.mock('../auth/login', () => ({
|
|
29
|
+
loginCore: (...args) => mockLoginCore(...args),
|
|
30
|
+
}));
|
|
31
|
+
const mockResolveIdentity = jest.fn();
|
|
32
|
+
jest.mock('../auth/identity', () => ({
|
|
33
|
+
resolveIdentity: (...args) => mockResolveIdentity(...args),
|
|
34
|
+
}));
|
|
35
|
+
const mockConfirm = jest.fn();
|
|
36
|
+
const mockIsInteractive = jest.fn();
|
|
37
|
+
jest.mock('../utils/prompt', () => ({
|
|
38
|
+
confirm: (...args) => mockConfirm(...args),
|
|
39
|
+
isInteractive: (...args) => mockIsInteractive(...args),
|
|
40
|
+
}));
|
|
41
|
+
const mockOpenBrowser = jest.fn();
|
|
42
|
+
jest.mock('../utils/openBrowser', () => ({
|
|
43
|
+
openBrowser: (...args) => mockOpenBrowser(...args),
|
|
44
|
+
}));
|
|
45
|
+
const helpers_1 = require("./helpers");
|
|
46
|
+
const mockClient = (0, helpers_1.createMockClient)();
|
|
47
|
+
jest.mock('../utils/client', () => ({
|
|
48
|
+
getClient: () => mockClient,
|
|
49
|
+
}));
|
|
15
50
|
const auth_1 = require("../commands/auth");
|
|
16
|
-
let mockFetch;
|
|
17
51
|
beforeAll(() => {
|
|
18
52
|
chalk_1.default.level = 0;
|
|
19
53
|
});
|
|
20
54
|
beforeEach(() => {
|
|
21
55
|
jest.clearAllMocks();
|
|
22
|
-
|
|
23
|
-
|
|
56
|
+
mockGetApiUrl.mockReturnValue('http://test:3001/api/marketplace');
|
|
57
|
+
mockIsInteractive.mockReturnValue(true);
|
|
24
58
|
});
|
|
25
59
|
async function runCommand(...args) {
|
|
26
60
|
await auth_1.authCommand.parseAsync(args, { from: 'user' });
|
|
27
61
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
function captureLogs() {
|
|
63
|
+
const logs = [];
|
|
64
|
+
const spy = jest.spyOn(console, 'log').mockImplementation((...a) => { logs.push(a.join(' ')); });
|
|
65
|
+
const errSpy = jest.spyOn(console, 'error').mockImplementation((...a) => { logs.push(a.join(' ')); });
|
|
66
|
+
return { logs, restore: () => { spy.mockRestore(); errSpy.mockRestore(); } };
|
|
67
|
+
}
|
|
68
|
+
describe('auth login — device flow (no existing config)', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
mockLoadConfig.mockReturnValue(null);
|
|
71
|
+
});
|
|
72
|
+
it('signs in successfully, saves the key, and prints "Signed in as <name>"', async () => {
|
|
73
|
+
mockLoginCore.mockResolvedValue({
|
|
74
|
+
code: 'HIVE-7K3Q',
|
|
75
|
+
expiresAt: '2026-01-01T00:10:00Z',
|
|
76
|
+
result: { outcome: 'approved', apiKey: REAL_KEY },
|
|
77
|
+
});
|
|
78
|
+
mockResolveIdentity.mockResolvedValue({ ok: true, name: 'BunnyDAO' });
|
|
79
|
+
const { logs, restore } = captureLogs();
|
|
80
|
+
await runCommand('login');
|
|
81
|
+
expect(mockSaveConfig).toHaveBeenCalledWith({ apiKey: REAL_KEY, apiUrl: 'http://test:3001/api/marketplace' });
|
|
82
|
+
expect(logs.join('\n')).toContain('Signed in as BunnyDAO');
|
|
83
|
+
restore();
|
|
84
|
+
});
|
|
85
|
+
it('falls back to a shortened key identifier when no display name is available', async () => {
|
|
86
|
+
mockLoginCore.mockResolvedValue({
|
|
87
|
+
code: 'HIVE-7K3Q',
|
|
88
|
+
expiresAt: '2026-01-01T00:10:00Z',
|
|
89
|
+
result: { outcome: 'approved', apiKey: REAL_KEY },
|
|
90
|
+
});
|
|
91
|
+
mockResolveIdentity.mockResolvedValue({ ok: true, name: undefined });
|
|
92
|
+
const { logs, restore } = captureLogs();
|
|
93
|
+
await runCommand('login');
|
|
94
|
+
expect(logs.join('\n')).toMatch(/Signed in as wm_sk_/);
|
|
95
|
+
restore();
|
|
96
|
+
});
|
|
97
|
+
it('prints the code, the approval URL with ?code=, and tries to open the browser, as soon as the code is minted', async () => {
|
|
98
|
+
mockLoginCore.mockImplementation(async (_opts, deps) => {
|
|
99
|
+
deps.onCodeReady?.({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z' });
|
|
100
|
+
return { code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'cancelled' } };
|
|
37
101
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(mockSaveConfig).
|
|
52
|
-
|
|
53
|
-
});
|
|
54
|
-
it('
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
await runCommand('login'
|
|
58
|
-
expect(
|
|
59
|
-
expect(mockSaveConfig).
|
|
60
|
-
|
|
102
|
+
const { logs, restore } = captureLogs();
|
|
103
|
+
await runCommand('login');
|
|
104
|
+
const output = logs.join('\n');
|
|
105
|
+
expect(output).toContain('HIVE-7K3Q');
|
|
106
|
+
expect(output).toContain('/settings/cli-login?code=HIVE-7K3Q');
|
|
107
|
+
expect(mockOpenBrowser).toHaveBeenCalledWith(expect.stringContaining('/settings/cli-login?code=HIVE-7K3Q'));
|
|
108
|
+
restore();
|
|
109
|
+
});
|
|
110
|
+
it('reports a denial with the agreed message and does not save anything', async () => {
|
|
111
|
+
mockLoginCore.mockResolvedValue({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'denied' } });
|
|
112
|
+
const { logs, restore } = captureLogs();
|
|
113
|
+
await runCommand('login').catch(() => { });
|
|
114
|
+
expect(logs.join('\n')).toContain('Login denied in the browser.');
|
|
115
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
116
|
+
restore();
|
|
117
|
+
});
|
|
118
|
+
it('reports an expired code with the agreed message and does not save anything', async () => {
|
|
119
|
+
mockLoginCore.mockResolvedValue({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'expired' } });
|
|
120
|
+
const { logs, restore } = captureLogs();
|
|
121
|
+
await runCommand('login').catch(() => { });
|
|
122
|
+
expect(logs.join('\n')).toContain('Code expired — run `wen-market auth login` again.');
|
|
123
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
124
|
+
restore();
|
|
125
|
+
});
|
|
126
|
+
it('reports a local timeout with the same "code expired" message and does not save anything', async () => {
|
|
127
|
+
mockLoginCore.mockResolvedValue({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'timeout' } });
|
|
128
|
+
const { logs, restore } = captureLogs();
|
|
129
|
+
await runCommand('login').catch(() => { });
|
|
130
|
+
expect(logs.join('\n')).toContain('Code expired — run `wen-market auth login` again.');
|
|
131
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
132
|
+
restore();
|
|
133
|
+
});
|
|
134
|
+
it('reports a clean cancellation and does not save anything', async () => {
|
|
135
|
+
mockLoginCore.mockResolvedValue({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'cancelled' } });
|
|
136
|
+
const { logs, restore } = captureLogs();
|
|
137
|
+
await runCommand('login');
|
|
138
|
+
expect(logs.join('\n')).toContain('Login cancelled — no changes made.');
|
|
139
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
140
|
+
restore();
|
|
141
|
+
});
|
|
142
|
+
it('Ctrl+C cancels cleanly — the isCancelled dep flips true the instant SIGINT fires', async () => {
|
|
143
|
+
let capturedIsCancelled;
|
|
144
|
+
let resolveLoginCore;
|
|
145
|
+
mockLoginCore.mockImplementation((_opts, deps) => {
|
|
146
|
+
capturedIsCancelled = deps.isCancelled;
|
|
147
|
+
return new Promise((resolve) => { resolveLoginCore = resolve; });
|
|
148
|
+
});
|
|
149
|
+
const { restore } = captureLogs();
|
|
150
|
+
const runPromise = runCommand('login');
|
|
151
|
+
// let the action reach the point where it has called loginCore
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
await Promise.resolve();
|
|
154
|
+
expect(capturedIsCancelled).toBeDefined();
|
|
155
|
+
expect(capturedIsCancelled()).toBe(false);
|
|
156
|
+
process.emit('SIGINT');
|
|
157
|
+
expect(capturedIsCancelled()).toBe(true);
|
|
158
|
+
resolveLoginCore({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'cancelled' } });
|
|
159
|
+
await runPromise;
|
|
160
|
+
restore();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('auth login — already signed in', () => {
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
mockLoadConfig.mockReturnValue({ apiKey: REAL_KEY, apiUrl: 'http://test:3001/api/marketplace' });
|
|
166
|
+
});
|
|
167
|
+
it('defaults to No — declining leaves the existing login untouched and never starts the device flow', async () => {
|
|
168
|
+
mockIsInteractive.mockReturnValue(true);
|
|
169
|
+
mockConfirm.mockResolvedValue(false);
|
|
170
|
+
const { restore } = captureLogs();
|
|
171
|
+
await runCommand('login');
|
|
172
|
+
expect(mockConfirm).toHaveBeenCalledWith(expect.stringContaining('Sign in again?'), false);
|
|
173
|
+
expect(mockLoginCore).not.toHaveBeenCalled();
|
|
174
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
175
|
+
restore();
|
|
176
|
+
});
|
|
177
|
+
it('proceeds with a fresh device login when the human confirms yes', async () => {
|
|
178
|
+
mockIsInteractive.mockReturnValue(true);
|
|
179
|
+
mockConfirm.mockResolvedValue(true);
|
|
180
|
+
mockLoginCore.mockResolvedValue({ code: 'HIVE-7K3Q', expiresAt: '2026-01-01T00:10:00Z', result: { outcome: 'denied' } });
|
|
181
|
+
const { restore } = captureLogs();
|
|
182
|
+
await runCommand('login').catch(() => { });
|
|
183
|
+
expect(mockLoginCore).toHaveBeenCalled();
|
|
184
|
+
restore();
|
|
185
|
+
});
|
|
186
|
+
it('refuses with guidance in non-interactive mode (no TTY) rather than hanging on a prompt', async () => {
|
|
187
|
+
mockIsInteractive.mockReturnValue(false);
|
|
188
|
+
const { logs, restore } = captureLogs();
|
|
189
|
+
await runCommand('login').catch(() => { });
|
|
190
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
191
|
+
expect(mockLoginCore).not.toHaveBeenCalled();
|
|
192
|
+
expect(logs.join('\n')).toContain('Already signed in');
|
|
193
|
+
restore();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe('auth login --api-key (CI)', () => {
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
mockLoadConfig.mockReturnValue(null);
|
|
199
|
+
});
|
|
200
|
+
it('accepts a real wm_sk_ key, verifies it, and saves it', async () => {
|
|
201
|
+
mockResolveIdentity.mockResolvedValue({ ok: true, name: 'CI Bot' });
|
|
202
|
+
const { logs, restore } = captureLogs();
|
|
203
|
+
await runCommand('login', '--api-key', REAL_KEY);
|
|
204
|
+
expect(mockResolveIdentity).toHaveBeenCalledWith(expect.objectContaining({ apiKey: REAL_KEY }));
|
|
205
|
+
expect(mockSaveConfig).toHaveBeenCalledWith({ apiKey: REAL_KEY, apiUrl: 'http://test:3001/api/marketplace' });
|
|
206
|
+
expect(logs.join('\n')).toContain('Signed in as CI Bot');
|
|
207
|
+
restore();
|
|
208
|
+
});
|
|
209
|
+
it('refuses a bare wallet public key with the exact guidance message, and does not save it', async () => {
|
|
210
|
+
const { logs, restore } = captureLogs();
|
|
211
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
212
|
+
await runCommand('login', '--api-key', WALLET_PUBKEY);
|
|
213
|
+
expect(logs.join('\n')).toContain("That's a wallet address, not an API key. Run `wen-market auth login` or create a key at https://marketplace.wenrwa.com/settings");
|
|
214
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
215
|
+
expect(mockResolveIdentity).not.toHaveBeenCalled();
|
|
216
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
217
|
+
exitSpy.mockRestore();
|
|
218
|
+
restore();
|
|
219
|
+
});
|
|
220
|
+
it('refuses to save a key that fails server verification', async () => {
|
|
221
|
+
mockResolveIdentity.mockResolvedValue({ ok: false, message: 'Invalid, expired, or revoked API key' });
|
|
222
|
+
const { logs, restore } = captureLogs();
|
|
223
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
224
|
+
await runCommand('login', '--api-key', REAL_KEY);
|
|
225
|
+
expect(mockSaveConfig).not.toHaveBeenCalled();
|
|
226
|
+
expect(logs.join('\n')).toContain('Invalid, expired, or revoked API key');
|
|
227
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
228
|
+
exitSpy.mockRestore();
|
|
229
|
+
restore();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe('auth logout', () => {
|
|
233
|
+
it('revokes the key server-side (matched by prefix) then deletes it locally', async () => {
|
|
234
|
+
mockLoadConfig.mockReturnValue({ apiKey: REAL_KEY, apiUrl: 'http://test:3001/api/marketplace' });
|
|
235
|
+
mockClient.get.mockResolvedValue({ success: true, keys: [{ id: 'key-1', keyPrefix: REAL_KEY.slice(0, 16) }, { id: 'key-2', keyPrefix: 'wm_sk_other00000' }] });
|
|
236
|
+
mockClient.del.mockResolvedValue({ success: true });
|
|
237
|
+
const { logs, restore } = captureLogs();
|
|
238
|
+
await runCommand('logout');
|
|
239
|
+
expect(mockClient.del).toHaveBeenCalledWith('/keys/key-1');
|
|
240
|
+
// logout must happen AFTER the server revoke succeeds
|
|
241
|
+
const delIdx = mockClient.del.mock.invocationCallOrder[0];
|
|
242
|
+
const clearIdx = mockClearConfig.mock.invocationCallOrder[0];
|
|
243
|
+
expect(clearIdx).toBeGreaterThan(delIdx);
|
|
244
|
+
expect(logs.join('\n')).toMatch(/revoked/i);
|
|
245
|
+
restore();
|
|
246
|
+
});
|
|
247
|
+
it('still deletes the key locally, with a clear warning, when the server call fails', async () => {
|
|
248
|
+
mockLoadConfig.mockReturnValue({ apiKey: REAL_KEY, apiUrl: 'http://test:3001/api/marketplace' });
|
|
249
|
+
mockClient.get.mockRejectedValue(new Error('Network error'));
|
|
250
|
+
const { logs, restore } = captureLogs();
|
|
251
|
+
await runCommand('logout');
|
|
252
|
+
expect(mockClearConfig).toHaveBeenCalled();
|
|
253
|
+
expect(logs.join('\n')).toMatch(/could not revoke/i);
|
|
254
|
+
restore();
|
|
255
|
+
});
|
|
256
|
+
it('does nothing when not signed in', async () => {
|
|
257
|
+
mockLoadConfig.mockReturnValue(null);
|
|
258
|
+
const { restore } = captureLogs();
|
|
259
|
+
await runCommand('logout');
|
|
260
|
+
expect(mockClient.del).not.toHaveBeenCalled();
|
|
261
|
+
expect(mockClearConfig).not.toHaveBeenCalled();
|
|
262
|
+
restore();
|
|
61
263
|
});
|
|
62
264
|
});
|
|
63
265
|
describe('auth whoami', () => {
|
|
64
|
-
it('
|
|
65
|
-
mockLoadConfig.mockReturnValue({ apiKey:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
json: () => Promise.resolve({ agent: { name: 'TestAgent', status: 'active', weighted_reputation: 4.5 } }),
|
|
69
|
-
});
|
|
70
|
-
const spy = jest.spyOn(console, 'log').mockImplementation();
|
|
266
|
+
it('shows the stored identity when signed in', async () => {
|
|
267
|
+
mockLoadConfig.mockReturnValue({ apiKey: REAL_KEY, apiUrl: 'http://test:3001/api/marketplace' });
|
|
268
|
+
mockResolveIdentity.mockResolvedValue({ ok: true, name: 'BunnyDAO' });
|
|
269
|
+
const { logs, restore } = captureLogs();
|
|
71
270
|
await runCommand('whoami');
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
expect(output).toContain('TestAgent');
|
|
75
|
-
spy.mockRestore();
|
|
271
|
+
expect(logs.join('\n')).toContain('BunnyDAO');
|
|
272
|
+
restore();
|
|
76
273
|
});
|
|
77
|
-
it('exits when not
|
|
274
|
+
it('exits when not signed in', async () => {
|
|
78
275
|
mockLoadConfig.mockReturnValue(null);
|
|
79
276
|
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
80
|
-
const
|
|
81
|
-
// Commander wraps action errors
|
|
277
|
+
const { restore } = captureLogs();
|
|
82
278
|
await runCommand('whoami').catch(() => { });
|
|
83
279
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
84
280
|
exitSpy.mockRestore();
|
|
85
|
-
|
|
86
|
-
});
|
|
87
|
-
it('handles API errors gracefully', async () => {
|
|
88
|
-
mockLoadConfig.mockReturnValue({ apiKey: 'key', apiUrl: 'http://test' });
|
|
89
|
-
mockFetch.mockRejectedValue(new Error('Connection refused'));
|
|
90
|
-
const spy = jest.spyOn(console, 'log').mockImplementation();
|
|
91
|
-
await runCommand('whoami');
|
|
92
|
-
spy.mockRestore();
|
|
281
|
+
restore();
|
|
93
282
|
});
|
|
94
283
|
});
|