@hogsend/cli 0.19.0 → 0.21.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/dist/bin.js +1154 -338
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-deploy/references/env-and-secrets.md +8 -0
- package/src/__tests__/connect-command.test.ts +104 -0
- package/src/__tests__/connect-flow.test.ts +559 -0
- package/src/__tests__/dev.test.ts +1 -0
- package/src/__tests__/domain-command.test.ts +1 -0
- package/src/__tests__/loopback.test.ts +159 -0
- package/src/__tests__/oauth.test.ts +230 -0
- package/src/commands/connect.ts +178 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/studio.ts +1 -16
- package/src/lib/browser.ts +17 -0
- package/src/lib/connect-flow.ts +641 -0
- package/src/lib/http.ts +6 -0
- package/src/lib/loopback.ts +223 -0
- package/src/lib/oauth.ts +265 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"tsup": "^8.5.1",
|
|
35
35
|
"tsx": "^4.22.4",
|
|
36
36
|
"vitest": "^4.1.7",
|
|
37
|
-
"@hogsend/studio": "^0.
|
|
37
|
+
"@hogsend/studio": "^0.21.0",
|
|
38
38
|
"@repo/typescript-config": "0.0.0"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"@clack/prompts": "^1.5.0",
|
|
45
45
|
"better-auth": "^1.6.11",
|
|
46
46
|
"picocolors": "^1.1.1",
|
|
47
|
-
"@hogsend/db": "^0.
|
|
48
|
-
"@hogsend/engine": "^0.
|
|
47
|
+
"@hogsend/db": "^0.21.0",
|
|
48
|
+
"@hogsend/engine": "^0.21.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"prebuild": "node scripts/bundle-studio.mjs",
|
|
@@ -107,6 +107,14 @@ Notes:
|
|
|
107
107
|
timezone resolution) need `POSTHOG_PERSONAL_API_KEY` — a personal API key
|
|
108
108
|
scoped `person:read`, created in PostHog → Settings → Personal API keys.
|
|
109
109
|
`hogsend doctor` warns when capture is configured without it.
|
|
110
|
+
- **Scaffold-time PostHog setup mints `POSTHOG_WEBHOOK_SECRET`.** If the app
|
|
111
|
+
was scaffolded with the PostHog prompt answered (or `--posthog-key`),
|
|
112
|
+
`.env` already carries active `POSTHOG_API_KEY`/`POSTHOG_HOST` values,
|
|
113
|
+
`ENABLE_POSTHOG_DESTINATION=true`, and a randomly minted
|
|
114
|
+
`POSTHOG_WEBHOOK_SECRET`. Copy the first three to BOTH Railway services and
|
|
115
|
+
the webhook secret to the api. Then `hogsend connect posthog` against the
|
|
116
|
+
DEPLOYED instance finishes the loop — it wires person reads and the
|
|
117
|
+
PostHog→Hogsend event loop (the webhook back into `/v1/webhooks/posthog`).
|
|
110
118
|
- **Webhook secrets are per-source.** Only set the secret for a webhook source
|
|
111
119
|
you've actually registered (see the consumer's `src/webhook-sources`).
|
|
112
120
|
- **`ADMIN_API_KEY` gates `/v1/admin/*`.** Set it in prod if you want to drive
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { connectCommand } from "../commands/connect.js";
|
|
3
|
+
import type { CommandContext } from "../commands/types.js";
|
|
4
|
+
import type { ResolvedConfig } from "../lib/config.js";
|
|
5
|
+
import type { AdminClient } from "../lib/http.js";
|
|
6
|
+
import type { Output } from "../lib/output.js";
|
|
7
|
+
|
|
8
|
+
// Thin argv/usage mapping only — the flow itself is covered by
|
|
9
|
+
// connect-flow.test.ts (stub-CommandContext harness, domain-command style).
|
|
10
|
+
|
|
11
|
+
/** Sentinel thrown by the stubbed `out.fail` instead of process.exit(1). */
|
|
12
|
+
class FailSignal extends Error {
|
|
13
|
+
constructor(readonly failMessage: string) {
|
|
14
|
+
super(failMessage);
|
|
15
|
+
this.name = "FailSignal";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeCtx(argv: string[]): {
|
|
20
|
+
ctx: CommandContext;
|
|
21
|
+
logs: string[];
|
|
22
|
+
httpCalls: string[];
|
|
23
|
+
} {
|
|
24
|
+
const logs: string[] = [];
|
|
25
|
+
const httpCalls: string[] = [];
|
|
26
|
+
|
|
27
|
+
const out: Output = {
|
|
28
|
+
interactive: false,
|
|
29
|
+
isJson: false,
|
|
30
|
+
intro: () => {},
|
|
31
|
+
step: async <T>(_label: string, fn: () => Promise<T>) => fn(),
|
|
32
|
+
note: (body: string) => {
|
|
33
|
+
logs.push(body);
|
|
34
|
+
},
|
|
35
|
+
table: () => {},
|
|
36
|
+
kv: () => {},
|
|
37
|
+
log: (msg: string) => {
|
|
38
|
+
logs.push(msg);
|
|
39
|
+
},
|
|
40
|
+
json: () => {},
|
|
41
|
+
outro: () => {},
|
|
42
|
+
fail: (message: string): never => {
|
|
43
|
+
throw new FailSignal(message);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const cfg = {
|
|
48
|
+
baseUrl: "http://localhost:3002",
|
|
49
|
+
adminKey: "hsk_test",
|
|
50
|
+
dataKey: undefined,
|
|
51
|
+
sources: { baseUrl: "default", adminKey: "flag", dataKey: "default" },
|
|
52
|
+
} as unknown as ResolvedConfig;
|
|
53
|
+
|
|
54
|
+
const reject = (verb: string) => () => {
|
|
55
|
+
httpCalls.push(verb);
|
|
56
|
+
return Promise.reject(new Error(`unexpected ${verb}`));
|
|
57
|
+
};
|
|
58
|
+
const http = {
|
|
59
|
+
cfg,
|
|
60
|
+
get: reject("GET"),
|
|
61
|
+
post: reject("POST"),
|
|
62
|
+
put: reject("PUT"),
|
|
63
|
+
patch: reject("PATCH"),
|
|
64
|
+
del: reject("DELETE"),
|
|
65
|
+
} as unknown as AdminClient;
|
|
66
|
+
|
|
67
|
+
const ctx: CommandContext = {
|
|
68
|
+
argv,
|
|
69
|
+
cfg,
|
|
70
|
+
http,
|
|
71
|
+
dataHttp: {} as CommandContext["dataHttp"],
|
|
72
|
+
out,
|
|
73
|
+
json: false,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return { ctx, logs, httpCalls };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("hogsend connect — argv mapping", () => {
|
|
80
|
+
it("fails when the provider positional is missing", async () => {
|
|
81
|
+
const { ctx } = makeCtx([]);
|
|
82
|
+
await expect(connectCommand.run(ctx)).rejects.toThrow(/missing provider/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("fails on an unknown provider", async () => {
|
|
86
|
+
const { ctx } = makeCtx(["stripe"]);
|
|
87
|
+
await expect(connectCommand.run(ctx)).rejects.toThrow(
|
|
88
|
+
/unknown provider "stripe"/,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects --provision-only with --no-provision", async () => {
|
|
93
|
+
const { ctx } = makeCtx(["posthog", "--provision-only", "--no-provision"]);
|
|
94
|
+
await expect(connectCommand.run(ctx)).rejects.toThrow(/mutually exclusive/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("--help prints usage and makes no HTTP calls", async () => {
|
|
98
|
+
const { ctx, logs, httpCalls } = makeCtx(["posthog", "--help"]);
|
|
99
|
+
await connectCommand.run(ctx);
|
|
100
|
+
expect(logs.join("\n")).toContain("hogsend connect <provider>");
|
|
101
|
+
expect(logs.join("\n")).toContain("--provision-only");
|
|
102
|
+
expect(httpCalls).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ConnectError,
|
|
4
|
+
type ConnectFlowDeps,
|
|
5
|
+
type ConnectFlowOptions,
|
|
6
|
+
type ConnectInfoResponse,
|
|
7
|
+
runConnectPosthog,
|
|
8
|
+
} from "../lib/connect-flow.js";
|
|
9
|
+
import type { AdminClient, HttpError } from "../lib/http.js";
|
|
10
|
+
import { LoopbackError, type LoopbackServer } from "../lib/loopback.js";
|
|
11
|
+
import type { DiscoveryResult, TokenResponse } from "../lib/oauth.js";
|
|
12
|
+
import { POSTHOG_CLIENT_ID, POSTHOG_SCOPES } from "../lib/oauth.js";
|
|
13
|
+
import type { Output } from "../lib/output.js";
|
|
14
|
+
|
|
15
|
+
// Everything is dependency-injected — no vi.mock needed (mirrors the
|
|
16
|
+
// lib-heavy test style of hatchet-token / dns-apply / admin-recovery).
|
|
17
|
+
|
|
18
|
+
const BASE_URL = "https://api.example.com";
|
|
19
|
+
const NOW = new Date("2026-06-12T18:00:00Z");
|
|
20
|
+
|
|
21
|
+
const DISCOVERY_OK: DiscoveryResult = {
|
|
22
|
+
status: "ok",
|
|
23
|
+
metadata: {
|
|
24
|
+
issuer: "https://eu.posthog.com",
|
|
25
|
+
authorization_endpoint: "https://eu.posthog.com/oauth/authorize/",
|
|
26
|
+
token_endpoint: "https://eu.posthog.com/oauth/token/",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TOKENS: TokenResponse = {
|
|
31
|
+
access_token: "pha_fixture_access_secret",
|
|
32
|
+
refresh_token: "phr_fixture_refresh_secret",
|
|
33
|
+
token_type: "Bearer",
|
|
34
|
+
expires_in: 36_000,
|
|
35
|
+
scope: POSTHOG_SCOPES,
|
|
36
|
+
scoped_teams: [123],
|
|
37
|
+
scoped_organizations: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const PROVISION_OK = {
|
|
41
|
+
provisioned: true,
|
|
42
|
+
created: true,
|
|
43
|
+
action: "created",
|
|
44
|
+
hogFunctionId: "hf-123",
|
|
45
|
+
webhookUrl: `${BASE_URL}/v1/webhooks/posthog`,
|
|
46
|
+
dashboardUrl:
|
|
47
|
+
"https://eu.posthog.com/project/1/pipeline/destinations/hog-hf-123/configuration",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function connectInfo(
|
|
51
|
+
over: Partial<ConnectInfoResponse> = {},
|
|
52
|
+
): ConnectInfoResponse {
|
|
53
|
+
return {
|
|
54
|
+
providerId: "posthog",
|
|
55
|
+
analyticsConfigured: true,
|
|
56
|
+
privateHost: "https://eu.posthog.com",
|
|
57
|
+
hostExplicit: true,
|
|
58
|
+
projectIdHint: null,
|
|
59
|
+
personalKeyConfigured: false,
|
|
60
|
+
webhookSecretConfigured: true,
|
|
61
|
+
apiPublicUrl: BASE_URL,
|
|
62
|
+
...over,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeHttpError(status: number, body: unknown): HttpError {
|
|
67
|
+
const err = new Error(`request failed with status ${status}`) as HttpError;
|
|
68
|
+
err.name = "HttpError";
|
|
69
|
+
err.status = status;
|
|
70
|
+
err.body = body;
|
|
71
|
+
return err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Harness {
|
|
75
|
+
deps: ConnectFlowDeps;
|
|
76
|
+
/** Every string handed to ANY out.* sink (labels, logs, notes, kv, ...). */
|
|
77
|
+
sink: string[];
|
|
78
|
+
calls: {
|
|
79
|
+
get: string[];
|
|
80
|
+
put: Array<{ path: string; body: unknown }>;
|
|
81
|
+
post: Array<{ path: string; body: unknown }>;
|
|
82
|
+
discover: string[];
|
|
83
|
+
exchange: Array<{
|
|
84
|
+
tokenEndpoint: string;
|
|
85
|
+
clientId: string;
|
|
86
|
+
code: string;
|
|
87
|
+
codeVerifier: string;
|
|
88
|
+
redirectUri: string;
|
|
89
|
+
}>;
|
|
90
|
+
browserUrls: string[];
|
|
91
|
+
loopbackStates: string[];
|
|
92
|
+
};
|
|
93
|
+
server: { closed: boolean };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeHarness(opts: {
|
|
97
|
+
info?: ConnectInfoResponse;
|
|
98
|
+
discovery?: DiscoveryResult;
|
|
99
|
+
tokens?: TokenResponse | Error;
|
|
100
|
+
waitResult?: { code: string } | LoopbackError;
|
|
101
|
+
startLoopbackError?: LoopbackError;
|
|
102
|
+
putError?: Error;
|
|
103
|
+
postResult?: unknown | Error;
|
|
104
|
+
interactive?: boolean;
|
|
105
|
+
confirmAnswer?: boolean;
|
|
106
|
+
/** Injected region resolver for the keyless (privateHost null) path. */
|
|
107
|
+
selectRegion?: () => Promise<string>;
|
|
108
|
+
}): Harness {
|
|
109
|
+
const sink: string[] = [];
|
|
110
|
+
const calls: Harness["calls"] = {
|
|
111
|
+
get: [],
|
|
112
|
+
put: [],
|
|
113
|
+
post: [],
|
|
114
|
+
discover: [],
|
|
115
|
+
exchange: [],
|
|
116
|
+
browserUrls: [],
|
|
117
|
+
loopbackStates: [],
|
|
118
|
+
};
|
|
119
|
+
const server = { closed: false };
|
|
120
|
+
|
|
121
|
+
const out: Output = {
|
|
122
|
+
interactive: opts.interactive ?? false,
|
|
123
|
+
isJson: false,
|
|
124
|
+
intro: (title) => {
|
|
125
|
+
sink.push(title);
|
|
126
|
+
},
|
|
127
|
+
step: async (label, fn) => {
|
|
128
|
+
sink.push(label);
|
|
129
|
+
return fn();
|
|
130
|
+
},
|
|
131
|
+
note: (body, title) => {
|
|
132
|
+
sink.push(title ? `${title}\n${body}` : body);
|
|
133
|
+
},
|
|
134
|
+
table: () => {},
|
|
135
|
+
kv: (obj, title) => {
|
|
136
|
+
sink.push(`${title ?? ""}${JSON.stringify(obj)}`);
|
|
137
|
+
},
|
|
138
|
+
log: (msg) => {
|
|
139
|
+
sink.push(msg);
|
|
140
|
+
},
|
|
141
|
+
json: (payload) => {
|
|
142
|
+
sink.push(JSON.stringify(payload));
|
|
143
|
+
},
|
|
144
|
+
outro: (msg) => {
|
|
145
|
+
sink.push(msg);
|
|
146
|
+
},
|
|
147
|
+
fail: (message): never => {
|
|
148
|
+
throw new Error(`fail: ${message}`);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const http = {
|
|
153
|
+
cfg: { baseUrl: BASE_URL } as AdminClient["cfg"],
|
|
154
|
+
get: async (path: string) => {
|
|
155
|
+
calls.get.push(path);
|
|
156
|
+
return (opts.info ?? connectInfo()) as never;
|
|
157
|
+
},
|
|
158
|
+
put: async (path: string, body: unknown) => {
|
|
159
|
+
calls.put.push({ path, body });
|
|
160
|
+
if (opts.putError) throw opts.putError;
|
|
161
|
+
return {} as never;
|
|
162
|
+
},
|
|
163
|
+
post: async (path: string, body: unknown) => {
|
|
164
|
+
calls.post.push({ path, body });
|
|
165
|
+
const result = opts.postResult ?? PROVISION_OK;
|
|
166
|
+
if (result instanceof Error) throw result;
|
|
167
|
+
return result as never;
|
|
168
|
+
},
|
|
169
|
+
patch: async () => {
|
|
170
|
+
throw new Error("unexpected PATCH");
|
|
171
|
+
},
|
|
172
|
+
del: async () => {
|
|
173
|
+
throw new Error("unexpected DELETE");
|
|
174
|
+
},
|
|
175
|
+
} as AdminClient;
|
|
176
|
+
|
|
177
|
+
const loopbackServer: LoopbackServer = {
|
|
178
|
+
port: 8423,
|
|
179
|
+
redirectUri: "http://127.0.0.1:8423/callback",
|
|
180
|
+
waitForCallback: async () => {
|
|
181
|
+
const result = opts.waitResult ?? { code: "abc" };
|
|
182
|
+
if (result instanceof LoopbackError) throw result;
|
|
183
|
+
return result;
|
|
184
|
+
},
|
|
185
|
+
close: async () => {
|
|
186
|
+
server.closed = true;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const deps: ConnectFlowDeps = {
|
|
191
|
+
http,
|
|
192
|
+
out,
|
|
193
|
+
interactive: opts.interactive ?? false,
|
|
194
|
+
discover: async ({ privateHost }) => {
|
|
195
|
+
calls.discover.push(privateHost);
|
|
196
|
+
return opts.discovery ?? DISCOVERY_OK;
|
|
197
|
+
},
|
|
198
|
+
startLoopback: async ({ state }) => {
|
|
199
|
+
calls.loopbackStates.push(state);
|
|
200
|
+
if (opts.startLoopbackError) throw opts.startLoopbackError;
|
|
201
|
+
return loopbackServer;
|
|
202
|
+
},
|
|
203
|
+
exchangeCode: async (exchangeOpts) => {
|
|
204
|
+
calls.exchange.push(exchangeOpts);
|
|
205
|
+
const tokens = opts.tokens ?? TOKENS;
|
|
206
|
+
if (tokens instanceof Error) throw tokens;
|
|
207
|
+
return tokens;
|
|
208
|
+
},
|
|
209
|
+
openBrowser: (url) => {
|
|
210
|
+
calls.browserUrls.push(url);
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
confirm: async () => opts.confirmAnswer ?? true,
|
|
214
|
+
...(opts.selectRegion ? { selectRegion: opts.selectRegion } : {}),
|
|
215
|
+
now: () => NOW,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return { deps, sink, calls, server };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const FLOW_DEFAULTS: ConnectFlowOptions = {
|
|
222
|
+
provisionOnly: false,
|
|
223
|
+
noProvision: false,
|
|
224
|
+
noBrowser: false,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const expectConnectError = async (
|
|
228
|
+
promise: Promise<unknown>,
|
|
229
|
+
verdict: string,
|
|
230
|
+
) => {
|
|
231
|
+
await expect(promise).rejects.toSatisfy((err: unknown) => {
|
|
232
|
+
expect(err).toBeInstanceOf(ConnectError);
|
|
233
|
+
expect((err as ConnectError).verdict).toBe(verdict);
|
|
234
|
+
return true;
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
describe("runConnectPosthog — happy path", () => {
|
|
239
|
+
it("stores the canonical credential, provisions, and reports connected", async () => {
|
|
240
|
+
const h = makeHarness({});
|
|
241
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
242
|
+
|
|
243
|
+
expect(result.verdict).toBe("connected");
|
|
244
|
+
expect(result.instance).toBe(BASE_URL);
|
|
245
|
+
expect(result.posthog?.scopedTeams).toEqual([123]);
|
|
246
|
+
|
|
247
|
+
// PUT: path + the §3.2 body, exactly.
|
|
248
|
+
expect(h.calls.put).toHaveLength(1);
|
|
249
|
+
expect(h.calls.put[0]?.path).toBe("/v1/admin/provider-credentials/posthog");
|
|
250
|
+
expect(h.calls.put[0]?.body).toEqual({
|
|
251
|
+
kind: "oauth",
|
|
252
|
+
payload: {
|
|
253
|
+
accessToken: "pha_fixture_access_secret",
|
|
254
|
+
refreshToken: "phr_fixture_refresh_secret",
|
|
255
|
+
// now (18:00Z) + 36000 s = 04:00Z next day.
|
|
256
|
+
expiresAt: "2026-06-13T04:00:00.000Z",
|
|
257
|
+
tokenEndpoint: "https://eu.posthog.com/oauth/token/",
|
|
258
|
+
clientId: POSTHOG_CLIENT_ID,
|
|
259
|
+
// The granted scopes are the front-loaded set the token response
|
|
260
|
+
// carries (TOKENS.scope === POSTHOG_SCOPES), split on whitespace.
|
|
261
|
+
scopes: POSTHOG_SCOPES.split(" "),
|
|
262
|
+
scopedTeams: [123],
|
|
263
|
+
scopedOrganizations: [],
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Provision POST called with {}.
|
|
268
|
+
expect(h.calls.post).toEqual([
|
|
269
|
+
{ path: "/v1/admin/analytics/provision-loop", body: {} },
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
// The browser URL carries the generated state + PKCE + identity params.
|
|
273
|
+
expect(h.calls.browserUrls).toHaveLength(1);
|
|
274
|
+
const url = new URL(h.calls.browserUrls[0] ?? "");
|
|
275
|
+
expect(url.searchParams.get("state")).toBe(h.calls.loopbackStates[0]);
|
|
276
|
+
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
|
277
|
+
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
278
|
+
expect(url.searchParams.get("client_id")).toBe(POSTHOG_CLIENT_ID);
|
|
279
|
+
expect(url.searchParams.get("scope")).toBe(POSTHOG_SCOPES);
|
|
280
|
+
|
|
281
|
+
expect(result.provision).toEqual({
|
|
282
|
+
attempted: true,
|
|
283
|
+
ok: true,
|
|
284
|
+
created: true,
|
|
285
|
+
hogFunctionId: "hf-123",
|
|
286
|
+
webhookUrl: `${BASE_URL}/v1/webhooks/posthog`,
|
|
287
|
+
});
|
|
288
|
+
expect(h.server.closed).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("never leaks tokens, the code, or the verifier to the output sink", async () => {
|
|
292
|
+
const h = makeHarness({});
|
|
293
|
+
await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
294
|
+
|
|
295
|
+
const verifier = h.calls.exchange[0]?.codeVerifier ?? "";
|
|
296
|
+
expect(verifier.length).toBeGreaterThan(0);
|
|
297
|
+
|
|
298
|
+
const all = h.sink.join("\n");
|
|
299
|
+
expect(all).not.toContain("pha_fixture_access_secret");
|
|
300
|
+
expect(all).not.toContain("phr_fixture_refresh_secret");
|
|
301
|
+
expect(all).not.toContain(verifier);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("runConnectPosthog — failure verdicts", () => {
|
|
306
|
+
it("not_configured when privateHost is null", async () => {
|
|
307
|
+
const h = makeHarness({ info: connectInfo({ privateHost: null }) });
|
|
308
|
+
await expectConnectError(
|
|
309
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
310
|
+
"not_configured",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("oauth_unsupported on discovery 404 — no loopback, no exchange", async () => {
|
|
315
|
+
const h = makeHarness({ discovery: { status: "unsupported" } });
|
|
316
|
+
await expectConnectError(
|
|
317
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
318
|
+
"oauth_unsupported",
|
|
319
|
+
);
|
|
320
|
+
expect(h.calls.loopbackStates).toHaveLength(0);
|
|
321
|
+
expect(h.calls.exchange).toHaveLength(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("state_mismatch closes the server and never exchanges/stores", async () => {
|
|
325
|
+
const h = makeHarness({
|
|
326
|
+
waitResult: new LoopbackError("state_mismatch", "state mismatch"),
|
|
327
|
+
});
|
|
328
|
+
await expectConnectError(
|
|
329
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
330
|
+
"state_mismatch",
|
|
331
|
+
);
|
|
332
|
+
expect(h.calls.exchange).toHaveLength(0);
|
|
333
|
+
expect(h.calls.put).toHaveLength(0);
|
|
334
|
+
expect(h.server.closed).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("port_unavailable when all loopback ports are busy", async () => {
|
|
338
|
+
const h = makeHarness({
|
|
339
|
+
startLoopbackError: new LoopbackError("ports_busy", "ports busy"),
|
|
340
|
+
});
|
|
341
|
+
await expectConnectError(
|
|
342
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
343
|
+
"port_unavailable",
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("exchange_failed (missing refresh token) — PUT never called", async () => {
|
|
348
|
+
const h = makeHarness({
|
|
349
|
+
tokens: new Error(
|
|
350
|
+
"token response missing refresh_token — cannot store a long-lived credential",
|
|
351
|
+
),
|
|
352
|
+
});
|
|
353
|
+
await expectConnectError(
|
|
354
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
355
|
+
"exchange_failed",
|
|
356
|
+
);
|
|
357
|
+
expect(h.calls.put).toHaveLength(0);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("runConnectPosthog — provisioning outcomes", () => {
|
|
362
|
+
it("proceeds to provision even when the webhook secret is unconfigured (server mints it)", async () => {
|
|
363
|
+
// webhookSecretConfigured:false no longer short-circuits — the server mints
|
|
364
|
+
// + persists the secret during provisioning, so the flow stores the
|
|
365
|
+
// credential AND POSTs provision-loop, landing on `connected`.
|
|
366
|
+
const h = makeHarness({
|
|
367
|
+
info: connectInfo({ webhookSecretConfigured: false }),
|
|
368
|
+
});
|
|
369
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
370
|
+
|
|
371
|
+
expect(result.verdict).toBe("connected");
|
|
372
|
+
expect(h.calls.put).toHaveLength(1);
|
|
373
|
+
expect(h.calls.post).toEqual([
|
|
374
|
+
{ path: "/v1/admin/analytics/provision-loop", body: {} },
|
|
375
|
+
]);
|
|
376
|
+
expect(result.provision).toMatchObject({ attempted: true, ok: true });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("soft-skips when API_PUBLIC_URL is loopback (PostHog can't reach it)", async () => {
|
|
380
|
+
const h = makeHarness({
|
|
381
|
+
info: connectInfo({ apiPublicUrl: "http://localhost:3002" }),
|
|
382
|
+
});
|
|
383
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
384
|
+
|
|
385
|
+
expect(result.verdict).toBe("connected_no_provision");
|
|
386
|
+
expect(h.calls.put).toHaveLength(1);
|
|
387
|
+
expect(h.calls.post).toHaveLength(0);
|
|
388
|
+
expect(result.provision).toEqual({
|
|
389
|
+
attempted: false,
|
|
390
|
+
skipped: "api_public_url_unreachable",
|
|
391
|
+
});
|
|
392
|
+
const note = h.sink.find((s) => s.includes("loopback"));
|
|
393
|
+
expect(note).toBeDefined();
|
|
394
|
+
expect(note).toContain("--provision-only");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("a failed provision POST resolves connected_no_provision (exit stays 0)", async () => {
|
|
398
|
+
const h = makeHarness({
|
|
399
|
+
postResult: makeHttpError(500, { error: "boom" }),
|
|
400
|
+
});
|
|
401
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
402
|
+
|
|
403
|
+
expect(result.verdict).toBe("connected_no_provision");
|
|
404
|
+
expect(result.provision).toMatchObject({ attempted: true, ok: false });
|
|
405
|
+
expect(result.credential.stored).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("--no-provision stops after storing the credential", async () => {
|
|
409
|
+
const h = makeHarness({});
|
|
410
|
+
const result = await runConnectPosthog(h.deps, {
|
|
411
|
+
...FLOW_DEFAULTS,
|
|
412
|
+
noProvision: true,
|
|
413
|
+
});
|
|
414
|
+
expect(result.verdict).toBe("connected_no_provision");
|
|
415
|
+
expect(result.provision).toEqual({
|
|
416
|
+
attempted: false,
|
|
417
|
+
skipped: "no_provision_flag",
|
|
418
|
+
});
|
|
419
|
+
expect(h.calls.post).toHaveLength(0);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe("runConnectPosthog — --provision-only", () => {
|
|
424
|
+
const PROVISION_ONLY: ConnectFlowOptions = {
|
|
425
|
+
...FLOW_DEFAULTS,
|
|
426
|
+
provisionOnly: true,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
it("skips OAuth entirely and POSTs once", async () => {
|
|
430
|
+
const h = makeHarness({});
|
|
431
|
+
const result = await runConnectPosthog(h.deps, PROVISION_ONLY);
|
|
432
|
+
|
|
433
|
+
expect(result.verdict).toBe("connected");
|
|
434
|
+
expect(result.posthog).toBeNull();
|
|
435
|
+
expect(h.calls.discover).toHaveLength(0);
|
|
436
|
+
expect(h.calls.loopbackStates).toHaveLength(0);
|
|
437
|
+
expect(h.calls.exchange).toHaveLength(0);
|
|
438
|
+
expect(h.calls.put).toHaveLength(0);
|
|
439
|
+
expect(h.calls.post).toHaveLength(1);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("maps a 409 no_posthog_credential to no_credential with a hint", async () => {
|
|
443
|
+
const h = makeHarness({
|
|
444
|
+
postResult: makeHttpError(409, { error: "no_posthog_credential" }),
|
|
445
|
+
});
|
|
446
|
+
await expect(runConnectPosthog(h.deps, PROVISION_ONLY)).rejects.toSatisfy(
|
|
447
|
+
(err: unknown) => {
|
|
448
|
+
expect(err).toBeInstanceOf(ConnectError);
|
|
449
|
+
expect((err as ConnectError).verdict).toBe("no_credential");
|
|
450
|
+
expect((err as ConnectError).hint).toContain("hogsend connect posthog");
|
|
451
|
+
return true;
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("hard-fails api_public_url_unreachable under --provision-only", async () => {
|
|
457
|
+
const h = makeHarness({
|
|
458
|
+
info: connectInfo({ apiPublicUrl: "http://127.0.0.1:3002" }),
|
|
459
|
+
});
|
|
460
|
+
await expectConnectError(
|
|
461
|
+
runConnectPosthog(h.deps, PROVISION_ONLY),
|
|
462
|
+
"api_public_url_unreachable",
|
|
463
|
+
);
|
|
464
|
+
expect(h.calls.post).toHaveLength(0);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("provisions even when the webhook secret is unconfigured (server mints it)", async () => {
|
|
468
|
+
// --provision-only no longer gates on webhookSecretConfigured — the server
|
|
469
|
+
// mints + persists the secret during provisioning, so the POST proceeds.
|
|
470
|
+
const h = makeHarness({
|
|
471
|
+
info: connectInfo({ webhookSecretConfigured: false }),
|
|
472
|
+
});
|
|
473
|
+
const result = await runConnectPosthog(h.deps, PROVISION_ONLY);
|
|
474
|
+
expect(result.verdict).toBe("connected");
|
|
475
|
+
expect(h.calls.post).toHaveLength(1);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("any other non-2xx is provision_failed", async () => {
|
|
479
|
+
const h = makeHarness({
|
|
480
|
+
postResult: makeHttpError(502, {
|
|
481
|
+
error: "missing-scope",
|
|
482
|
+
detail: "denied",
|
|
483
|
+
}),
|
|
484
|
+
});
|
|
485
|
+
await expectConnectError(
|
|
486
|
+
runConnectPosthog(h.deps, PROVISION_ONLY),
|
|
487
|
+
"provision_failed",
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("runConnectPosthog — keyless / region resolution", () => {
|
|
493
|
+
it("resolves the region from --posthog-host on a keyless instance and proceeds", async () => {
|
|
494
|
+
// Server reports no PostHog config (privateHost null) — the CLI no longer
|
|
495
|
+
// hard-fails not_configured; it derives the region from the flag and runs
|
|
496
|
+
// the full OAuth handshake against it.
|
|
497
|
+
const h = makeHarness({ info: connectInfo({ privateHost: null }) });
|
|
498
|
+
const result = await runConnectPosthog(h.deps, {
|
|
499
|
+
...FLOW_DEFAULTS,
|
|
500
|
+
posthogHost: "https://eu.posthog.com/",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(result.verdict).toBe("connected");
|
|
504
|
+
// Trailing slash stripped; discovery + the OAuth flow ran against it.
|
|
505
|
+
expect(h.calls.discover).toEqual(["https://eu.posthog.com"]);
|
|
506
|
+
expect(result.posthog?.privateHost).toBe("https://eu.posthog.com");
|
|
507
|
+
expect(h.calls.put).toHaveLength(1);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("non-interactive keyless without a flag still fails not_configured", async () => {
|
|
511
|
+
const h = makeHarness({
|
|
512
|
+
info: connectInfo({ privateHost: null }),
|
|
513
|
+
interactive: false,
|
|
514
|
+
});
|
|
515
|
+
await expectConnectError(
|
|
516
|
+
runConnectPosthog(h.deps, FLOW_DEFAULTS),
|
|
517
|
+
"not_configured",
|
|
518
|
+
);
|
|
519
|
+
expect(h.calls.discover).toHaveLength(0);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("interactive keyless uses the injected selectRegion prompt", async () => {
|
|
523
|
+
const selectRegion = async () => "https://us.posthog.com";
|
|
524
|
+
const h = makeHarness({
|
|
525
|
+
info: connectInfo({ privateHost: null }),
|
|
526
|
+
interactive: true,
|
|
527
|
+
selectRegion,
|
|
528
|
+
});
|
|
529
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
530
|
+
|
|
531
|
+
expect(result.verdict).toBe("connected");
|
|
532
|
+
expect(h.calls.discover).toEqual(["https://us.posthog.com"]);
|
|
533
|
+
expect(result.posthog?.privateHost).toBe("https://us.posthog.com");
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe("runConnectPosthog — scope downscope advisory", () => {
|
|
538
|
+
it("prints a note when PostHog grants fewer scopes than requested", async () => {
|
|
539
|
+
// Simulate a downscope: PostHog grants everything except two read scopes.
|
|
540
|
+
const granted = POSTHOG_SCOPES.split(" ")
|
|
541
|
+
.filter((s) => s !== "cohort:read" && s !== "query:read")
|
|
542
|
+
.join(" ");
|
|
543
|
+
const h = makeHarness({ tokens: { ...TOKENS, scope: granted } });
|
|
544
|
+
const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
545
|
+
|
|
546
|
+
expect(result.verdict).toBe("connected");
|
|
547
|
+
const note = h.sink.find((s) => s.includes("PostHog granted"));
|
|
548
|
+
expect(note).toBeDefined();
|
|
549
|
+
expect(note).toContain("cohort:read");
|
|
550
|
+
expect(note).toContain("query:read");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("prints no note when PostHog grants the full requested set", async () => {
|
|
554
|
+
// Default TOKENS.scope === POSTHOG_SCOPES — a full grant.
|
|
555
|
+
const h = makeHarness({});
|
|
556
|
+
await runConnectPosthog(h.deps, FLOW_DEFAULTS);
|
|
557
|
+
expect(h.sink.find((s) => s.includes("PostHog granted"))).toBeUndefined();
|
|
558
|
+
});
|
|
559
|
+
});
|