Skip to content

Commit 245197d

Browse files
authored
Merge pull request #217 from InsForge/feat/webscraper-self-hosted-connect
feat(webscraper): connect Apify with an API token on self-hosted backends
2 parents 56f3d3e + dd56f97 commit 245197d

6 files changed

Lines changed: 515 additions & 3 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Command } from 'commander';
3+
4+
const apiMock = vi.hoisted(() => ({
5+
startApifyCliFlow: vi.fn(),
6+
pollApifyConnection: vi.fn(),
7+
fetchApifyConnection: vi.fn(),
8+
}));
9+
const apifyConfigMock = vi.hoisted(() => ({
10+
storeApifyToken: vi.fn(),
11+
}));
12+
vi.mock('../../../lib/api/webscraper.js', () => ({ ...apiMock, ...apifyConfigMock }));
13+
14+
const configMock = vi.hoisted(() => ({
15+
getProjectConfig: vi.fn(() => ({ project_id: 'p1', project_name: 'Test Project' })),
16+
getAccessToken: vi.fn((): string | null => 'tok'),
17+
}));
18+
vi.mock('../../../lib/config.js', () => configMock);
19+
20+
const analyticsMock = vi.hoisted(() => ({
21+
trackGroupCommand: vi.fn(),
22+
shutdownAnalytics: vi.fn(async () => {}),
23+
}));
24+
vi.mock('../../../lib/analytics.js', () => analyticsMock);
25+
26+
const bridgeMock = vi.hoisted(() => ({
27+
runApifyAuthBridge: vi.fn(async () => ({ skillsInstalled: true })),
28+
}));
29+
vi.mock('../../../lib/apify-bridge.js', () => bridgeMock);
30+
31+
vi.mock('../../../lib/prompts.js', () => ({ isInteractive: false }));
32+
33+
// `open` is loaded dynamically inside runConnectFlow (OAuth path); mock so the
34+
// real browser launch doesn't fire during tests.
35+
vi.mock('open', () => ({ default: vi.fn() }));
36+
37+
// Silence interactive UI noise from clack — tests assert on mocks, not stdout.
38+
vi.mock('@clack/prompts', async (orig) => {
39+
const actual = (await orig()) as Record<string, unknown>;
40+
return {
41+
...actual,
42+
intro: vi.fn(),
43+
outro: vi.fn(),
44+
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() },
45+
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })),
46+
};
47+
});
48+
49+
const outputMock = vi.hoisted(() => ({
50+
outputJson: vi.fn(),
51+
outputSuccess: vi.fn(),
52+
}));
53+
vi.mock('../../../lib/output.js', () => outputMock);
54+
55+
// Imports must come AFTER the vi.mock calls because Vitest hoists the mocks
56+
// but ESM module evaluation order still matters.
57+
import { registerApifyConnectCommand } from './connect.js';
58+
import { CLIError } from '../../../lib/errors.js';
59+
60+
interface RunResult {
61+
exitCode?: number;
62+
}
63+
64+
// Override process.exit so handleError doesn't kill the test process; capture
65+
// the first exit code. Mirrors src/commands/posthog/setup.test.ts.
66+
async function runConnect(argv: string[]): Promise<RunResult> {
67+
const program = new Command();
68+
program.option('--json').option('--api-url <url>').option('-y, --yes');
69+
const webscraper = program.command('webscraper');
70+
const apify = webscraper.command('apify');
71+
registerApifyConnectCommand(apify);
72+
73+
const origExit = process.exit;
74+
const result: RunResult = {};
75+
(process.exit as unknown) = (code?: number) => {
76+
if (result.exitCode === undefined) result.exitCode = code;
77+
throw new Error('__exit__');
78+
};
79+
try {
80+
await program
81+
.parseAsync(['node', 'test', 'webscraper', 'apify', 'connect', ...argv])
82+
.catch((err) => {
83+
if (err instanceof Error && err.message === '__exit__') return;
84+
throw err;
85+
});
86+
} finally {
87+
process.exit = origExit;
88+
}
89+
return result;
90+
}
91+
92+
beforeEach(() => {
93+
apiMock.startApifyCliFlow.mockReset();
94+
apiMock.pollApifyConnection.mockReset();
95+
apiMock.fetchApifyConnection.mockReset();
96+
apifyConfigMock.storeApifyToken.mockReset();
97+
bridgeMock.runApifyAuthBridge.mockReset().mockResolvedValue({ skillsInstalled: true });
98+
outputMock.outputJson.mockReset();
99+
outputMock.outputSuccess.mockReset();
100+
analyticsMock.trackGroupCommand.mockReset();
101+
analyticsMock.shutdownAnalytics.mockClear();
102+
configMock.getProjectConfig.mockReturnValue({ project_id: 'p1', project_name: 'Test Project' });
103+
configMock.getAccessToken.mockReturnValue('tok');
104+
});
105+
106+
describe('apify connect', () => {
107+
describe('--token path (self-hosted)', () => {
108+
it('stores the token and skips OAuth entirely', async () => {
109+
apifyConfigMock.storeApifyToken.mockResolvedValue({
110+
configured: true,
111+
maskedKey: 'apify_ap••••••••mnop',
112+
});
113+
114+
await runConnect(['--token', 'apify_api_tok1234567890']);
115+
116+
expect(apifyConfigMock.storeApifyToken).toHaveBeenCalledWith('apify_api_tok1234567890');
117+
expect(apiMock.startApifyCliFlow).not.toHaveBeenCalled();
118+
expect(apiMock.pollApifyConnection).not.toHaveBeenCalled();
119+
expect(apiMock.fetchApifyConnection).not.toHaveBeenCalled();
120+
expect(bridgeMock.runApifyAuthBridge).not.toHaveBeenCalled();
121+
});
122+
123+
it('does not require a login token', async () => {
124+
configMock.getAccessToken.mockReturnValue(null);
125+
apifyConfigMock.storeApifyToken.mockResolvedValue({
126+
configured: true,
127+
maskedKey: 'apify_ap••••••••mnop',
128+
});
129+
130+
const r = await runConnect(['--token', 'apify_api_tok1234567890']);
131+
132+
expect(r.exitCode).toBeUndefined();
133+
expect(apifyConfigMock.storeApifyToken).toHaveBeenCalledOnce();
134+
});
135+
136+
it('prints the masked key, never the raw token, in non-json mode', async () => {
137+
apifyConfigMock.storeApifyToken.mockResolvedValue({
138+
configured: true,
139+
maskedKey: 'apify_ap••••••••mnop',
140+
});
141+
142+
await runConnect(['--token', 'apify_api_tok1234567890']);
143+
144+
expect(outputMock.outputSuccess).toHaveBeenCalledWith(
145+
expect.stringContaining('apify_ap••••••••mnop'),
146+
);
147+
const allCalls = [...outputMock.outputSuccess.mock.calls, ...outputMock.outputJson.mock.calls];
148+
const serialized = JSON.stringify(allCalls);
149+
expect(serialized).not.toContain('apify_api_tok1234567890');
150+
});
151+
152+
it('emits the masked token status in --json mode', async () => {
153+
apifyConfigMock.storeApifyToken.mockResolvedValue({
154+
configured: true,
155+
maskedKey: 'apify_ap••••••••mnop',
156+
});
157+
158+
await runConnect(['--json', '--token', 'apify_api_tok1234567890']);
159+
160+
expect(outputMock.outputJson).toHaveBeenCalledOnce();
161+
const payload = outputMock.outputJson.mock.calls[0][0] as {
162+
success: boolean;
163+
connectionState: string;
164+
token: { configured: boolean; maskedKey: string | null };
165+
};
166+
expect(payload.success).toBe(true);
167+
expect(payload.connectionState).toBe('newly-connected');
168+
expect(payload.token).toEqual({ configured: true, maskedKey: 'apify_ap••••••••mnop' });
169+
});
170+
171+
it('propagates a rejected token as a CLI error and exits non-zero', async () => {
172+
apifyConfigMock.storeApifyToken.mockRejectedValue(
173+
new CLIError('Apify rejected this API token.', 1, 'INVALID_INPUT', 400),
174+
);
175+
176+
const r = await runConnect(['--token', 'bogus']);
177+
178+
expect(r.exitCode).toBe(1);
179+
});
180+
181+
it('rejects an empty --token locally instead of falling through to OAuth', async () => {
182+
const r = await runConnect(['--token', '']);
183+
184+
expect(r.exitCode).toBe(1);
185+
expect(apifyConfigMock.storeApifyToken).not.toHaveBeenCalled();
186+
expect(apiMock.startApifyCliFlow).not.toHaveBeenCalled();
187+
expect(apiMock.pollApifyConnection).not.toHaveBeenCalled();
188+
expect(apiMock.fetchApifyConnection).not.toHaveBeenCalled();
189+
expect(bridgeMock.runApifyAuthBridge).not.toHaveBeenCalled();
190+
});
191+
192+
it('rejects a whitespace-only --token locally instead of falling through to OAuth', async () => {
193+
const r = await runConnect(['--token', ' ']);
194+
195+
expect(r.exitCode).toBe(1);
196+
expect(apifyConfigMock.storeApifyToken).not.toHaveBeenCalled();
197+
expect(apiMock.startApifyCliFlow).not.toHaveBeenCalled();
198+
expect(bridgeMock.runApifyAuthBridge).not.toHaveBeenCalled();
199+
});
200+
});
201+
202+
describe('OAuth path (--token absent) is unchanged', () => {
203+
it('fast path: cli-start says connected → verifies via /connection, skips polling, runs the auth bridge', async () => {
204+
apiMock.startApifyCliFlow.mockResolvedValue({ type: 'connected' });
205+
apiMock.fetchApifyConnection.mockResolvedValue({
206+
kind: 'connected',
207+
connection: { apifyUsername: 'someone', plan: 'free', status: 'active' },
208+
});
209+
210+
await runConnect(['--skip-browser']);
211+
212+
expect(apiMock.startApifyCliFlow).toHaveBeenCalledOnce();
213+
expect(apiMock.fetchApifyConnection).toHaveBeenCalledOnce();
214+
expect(apiMock.pollApifyConnection).not.toHaveBeenCalled();
215+
expect(bridgeMock.runApifyAuthBridge).toHaveBeenCalledOnce();
216+
expect(apifyConfigMock.storeApifyToken).not.toHaveBeenCalled();
217+
});
218+
219+
it('slow path: cli-start returns authorizeUrl → polls until connected', async () => {
220+
apiMock.startApifyCliFlow.mockResolvedValue({
221+
type: 'authorize',
222+
authorizeUrl: 'https://example.com/auth',
223+
});
224+
apiMock.pollApifyConnection.mockResolvedValue({
225+
apifyUsername: 'someone',
226+
plan: 'free',
227+
status: 'active',
228+
});
229+
230+
await runConnect(['--skip-browser']);
231+
232+
expect(apiMock.pollApifyConnection).toHaveBeenCalledOnce();
233+
expect(apiMock.fetchApifyConnection).not.toHaveBeenCalled();
234+
expect(apifyConfigMock.storeApifyToken).not.toHaveBeenCalled();
235+
});
236+
237+
it('still requires a login token, exactly as before', async () => {
238+
configMock.getAccessToken.mockReturnValue(null);
239+
apiMock.startApifyCliFlow.mockResolvedValue({ type: 'connected' });
240+
241+
const r = await runConnect(['--skip-browser']);
242+
243+
expect(r.exitCode).toBeGreaterThan(0);
244+
expect(apiMock.startApifyCliFlow).not.toHaveBeenCalled();
245+
});
246+
247+
it('emits JSON with connectionState and connection, no token field', async () => {
248+
apiMock.startApifyCliFlow.mockResolvedValue({ type: 'connected' });
249+
apiMock.fetchApifyConnection.mockResolvedValue({
250+
kind: 'connected',
251+
connection: { apifyUsername: 'someone', plan: 'free', status: 'active' },
252+
});
253+
254+
await runConnect(['--json', '--skip-browser']);
255+
256+
expect(outputMock.outputJson).toHaveBeenCalledOnce();
257+
const payload = outputMock.outputJson.mock.calls[0][0] as {
258+
success: boolean;
259+
connectionState: string;
260+
connection: unknown;
261+
token?: unknown;
262+
};
263+
expect(payload.success).toBe(true);
264+
expect(payload.connectionState).toBe('already-connected');
265+
expect(payload.connection).toMatchObject({ apifyUsername: 'someone', plan: 'free' });
266+
expect(payload.token).toBeUndefined();
267+
});
268+
});
269+
});

src/commands/webscraper/apify/connect.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
fetchApifyConnection,
1515
pollApifyConnection,
1616
startApifyCliFlow,
17+
storeApifyToken,
1718
type ApifyConnectionResponse,
18-
} from '../../../lib/api/apify.js';
19+
type ApifyTokenStatus,
20+
} from '../../../lib/api/webscraper.js';
1921
import { outputJson, outputSuccess } from '../../../lib/output.js';
2022
import { trackGroupCommand, shutdownAnalytics } from '../../../lib/analytics.js';
2123
import { runApifyAuthBridge } from '../../../lib/apify-bridge.js';
@@ -33,20 +35,24 @@ interface ConnectResult {
3335
plan?: string | null;
3436
status?: string;
3537
};
38+
/** Present only on the self-hosted `--token` path. */
39+
token?: ApifyTokenStatus;
3640
}
3741

3842
export function registerApifyConnectCommand(program: Command): void {
3943
program
4044
.command('connect')
4145
.description('Connect your Apify account to your InsForge project')
4246
.option('--skip-browser', 'Do not auto-open the browser for OAuth; only print the URL')
47+
.option('--token <token>', 'Apify API token (self-hosted; skips the OAuth flow)')
4348
.action(async (opts, cmd) => {
4449
const { json, apiUrl } = getRootOpts(cmd);
4550
try {
4651
const result = await runConnect({
4752
json,
4853
apiUrl,
4954
skipBrowser: Boolean(opts.skipBrowser),
55+
token: opts.token,
5056
});
5157
if (json) {
5258
outputJson({ success: true, ...result });
@@ -69,6 +75,8 @@ interface RunConnectOpts {
6975
json: boolean;
7076
apiUrl?: string;
7177
skipBrowser: boolean;
78+
/** Self-hosted path: an Apify API token to store directly, bypassing OAuth. */
79+
token?: string;
7280
}
7381

7482
// Ensures the InsForge project has an Apify connection (cli-start / OAuth).
@@ -82,6 +90,33 @@ async function runConnect(opts: RunConnectOpts): Promise<ConnectResult> {
8290
throw new ProjectNotLinkedError();
8391
}
8492

93+
// Self-hosted token path: the token is validated and stored server-side via
94+
// the existing admin-authenticated ossFetch, so it needs neither the login
95+
// token below nor any of the OAuth / auth-bridge flow further down. Short-
96+
// circuits before any OAuth work; everything below is unreachable and thus
97+
// unchanged when --token is absent.
98+
//
99+
// Branch on the option being *supplied*, not on its truthiness: `--token ""`
100+
// must not silently fall through to the OAuth flow below (which would print
101+
// a confusing "not logged in" error in CI when an env var expands to empty).
102+
// Reject it locally instead — the backend's zod message for this case
103+
// ("String must contain at least 1 character(s)") is far less clear.
104+
if (opts.token !== undefined) {
105+
if (opts.token.trim().length === 0) {
106+
throw new CLIError('--token requires a non-empty Apify API token.');
107+
}
108+
trackGroupCommand('apify', 'connect', proj);
109+
const status = await storeApifyToken(opts.token);
110+
if (!opts.json) {
111+
outputSuccess(`Apify connected with token ${status.maskedKey ?? '(hidden)'}`);
112+
}
113+
return {
114+
connectionState: 'newly-connected',
115+
connection: { apifyUsername: null, plan: null, status: 'active' },
116+
token: status,
117+
};
118+
}
119+
85120
// 2. Login token
86121
const token = getAccessToken();
87122
if (!token) {

src/lib/api/oss.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,29 @@ describe('ossFetch', () => {
7676
/Upgrade your InsForge project to a version with Model Gateway support/,
7777
);
7878
});
79+
80+
it('shows an upgrade+token message for a route-level 404 under /api/webscraper (not a cloud-only warning)', async () => {
81+
// A 404 here means the backend predates the web scraper feature, not that
82+
// self-hosting is unsupported — self-hosted backends do serve this route
83+
// once they're upgraded. Guards against regressing to the old wording.
84+
vi.spyOn(config, 'getProjectConfig').mockReturnValue({
85+
project_id: 'p1',
86+
project_name: 'demo',
87+
org_id: 'o1',
88+
appkey: 'app',
89+
region: 'us-east',
90+
api_key: 'ik_test',
91+
oss_host: 'https://app.us-east.insforge.app',
92+
} satisfies ProjectConfig);
93+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
94+
new Response(JSON.stringify({ error: 'NOT_FOUND' }), {
95+
status: 404,
96+
headers: { 'Content-Type': 'application/json' },
97+
}),
98+
);
99+
100+
await expect(ossFetch('/api/webscraper/apify/config')).rejects.toThrow(
101+
/Upgrade your InsForge instance.*insforge webscraper apify connect --token/s,
102+
);
103+
});
79104
});

src/lib/api/oss.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export async function ossFetch(
210210
}
211211

212212
if (res.status === 404 && isRouteLevel404 && path.startsWith('/api/webscraper')) {
213-
message = 'The web scraper is not available on this backend.\nThe Apify web scraper is cloud-only. Self-hosted: this feature is not supported. Cloud: contact your InsForge admin to enable it.';
213+
message = 'The web scraper is not available on this backend.\nUpgrade your InsForge instance to a version with web scraper support, then run `insforge webscraper apify connect --token <token>` to connect your Apify account.';
214214
}
215215

216216
// Safe to treat any 404 on /api/advisor/* as a route-level miss: the OSS

0 commit comments

Comments
 (0)