Skip to content

Commit 1bdfa52

Browse files
committed
fix slack app setup env file updating
1 parent 310a481 commit 1bdfa52

File tree

4 files changed

+372
-34
lines changed

4 files changed

+372
-34
lines changed

packages/blink/src/cli/init-templates/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
export const templates = {
55
scout: {
66
".env.local.hbs":
7-
'# Store local environment variables here.\n# They will be used by blink dev for development.\n{{#each envLocal}}\n{{this.[0]}}={{this.[1]}}\n{{/each}}\n{{#unless (hasAnyEnvVar envLocal "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "AI_GATEWAY_API_KEY")}}\n# OPENAI_API_KEY=\n# ANTHROPIC_API_KEY=\n# AI_GATEWAY_API_KEY=\n{{/unless}}\n\n# Slack App credentials (run `blink setup slack-app` to configure)\nSLACK_BOT_TOKEN=xoxb-your-token-here\nSLACK_SIGNING_SECRET=your-signing-secret-here\n\n# Web search (optional - get an API key from https://exa.ai)\n# EXA_API_KEY=\n\n# Coder credentials (optional - for production compute)\n# CODER_URL=\n# CODER_SESSION_TOKEN=\n# CODER_TEMPLATE=\n# CODER_PRESET_NAME=\n',
7+
'# Store local environment variables here.\n# They will be used by blink dev for development.\n{{#each envLocal}}\n{{this.[0]}}={{this.[1]}}\n{{/each}}\n{{#unless (hasAnyEnvVar envLocal "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "AI_GATEWAY_API_KEY")}}\n# OPENAI_API_KEY=\n# ANTHROPIC_API_KEY=\n# AI_GATEWAY_API_KEY=\n{{/unless}}\n\n# Web search (optional - get an API key from https://exa.ai)\n# EXA_API_KEY=\n\n# Coder credentials (optional - for production compute)\n# CODER_URL=\n# CODER_SESSION_TOKEN=\n# CODER_TEMPLATE=\n# CODER_PRESET_NAME=\n',
88
".env.production":
99
"# Store production environment variables here.\n# They will be upserted as secrets on blink deploy.\n# OPENAI_API_KEY=\n# ANTHROPIC_API_KEY=\n# AI_GATEWAY_API_KEY=\n\n# Slack App credentials\n# SLACK_BOT_TOKEN=\n# SLACK_SIGNING_SECRET=\n\n# GitHub App credentials\n# GITHUB_APP_ID=\n# GITHUB_CLIENT_ID=\n# GITHUB_CLIENT_SECRET=\n# GITHUB_WEBHOOK_SECRET=\n# GITHUB_PRIVATE_KEY=\n\n# Web search\n# EXA_API_KEY=\n\n# Coder credentials (recommended for production)\n# CODER_URL=\n# CODER_SESSION_TOKEN=\n# CODER_TEMPLATE=\n# CODER_PRESET_NAME=\n",
1010
".gitignore":

packages/blink/src/cli/init-templates/scout/_noignore.env.local.hbs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@
99
# AI_GATEWAY_API_KEY=
1010
{{/unless}}
1111

12-
# Slack App credentials (run `blink setup slack-app` to configure)
13-
SLACK_BOT_TOKEN=xoxb-your-token-here
14-
SLACK_SIGNING_SECRET=your-signing-secret-here
15-
1612
# Web search (optional - get an API key from https://exa.ai)
1713
# EXA_API_KEY=
1814

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { describe, expect, it } from "bun:test";
2+
import crypto from "node:crypto";
3+
import { readFile, writeFile } from "node:fs/promises";
4+
import { join } from "node:path";
5+
import { BLINK_COMMAND, KEY_CODES, makeTmpDir, render } from "./lib/terminal";
6+
import { updateEnvCredentials, verifySlackSignature } from "./setup-slack-app";
7+
8+
describe("verifySlackSignature", () => {
9+
const signingSecret = "test_signing_secret_12345";
10+
11+
function generateValidSignature(
12+
secret: string,
13+
timestamp: string,
14+
body: string
15+
): string {
16+
const hmac = crypto.createHmac("sha256", secret);
17+
hmac.update(`v0:${timestamp}:${body}`);
18+
return `v0=${hmac.digest("hex")}`;
19+
}
20+
21+
it("should return true for a valid signature", () => {
22+
const timestamp = Math.floor(Date.now() / 1000).toString();
23+
const body = JSON.stringify({ type: "event_callback", event: {} });
24+
const signature = generateValidSignature(signingSecret, timestamp, body);
25+
26+
const result = verifySlackSignature(
27+
signingSecret,
28+
timestamp,
29+
body,
30+
signature
31+
);
32+
expect(result).toBe(true);
33+
});
34+
35+
it("should return false for an invalid signature", () => {
36+
const timestamp = Math.floor(Date.now() / 1000).toString();
37+
const body = JSON.stringify({ type: "event_callback", event: {} });
38+
// Use a signature with the correct length (v0= + 64 hex chars for SHA256)
39+
const invalidSignature =
40+
"v0=0000000000000000000000000000000000000000000000000000000000000000";
41+
42+
const result = verifySlackSignature(
43+
signingSecret,
44+
timestamp,
45+
body,
46+
invalidSignature
47+
);
48+
expect(result).toBe(false);
49+
});
50+
51+
it("should return false for a request older than 5 minutes", () => {
52+
// Timestamp from 10 minutes ago
53+
const timestamp = (Math.floor(Date.now() / 1000) - 600).toString();
54+
const body = JSON.stringify({ type: "event_callback", event: {} });
55+
const signature = generateValidSignature(signingSecret, timestamp, body);
56+
57+
const result = verifySlackSignature(
58+
signingSecret,
59+
timestamp,
60+
body,
61+
signature
62+
);
63+
expect(result).toBe(false);
64+
});
65+
66+
it("should return false when signature does not match body", () => {
67+
const timestamp = Math.floor(Date.now() / 1000).toString();
68+
const originalBody = JSON.stringify({ type: "event_callback", event: {} });
69+
const tamperedBody = JSON.stringify({
70+
type: "event_callback",
71+
event: { tampered: true },
72+
});
73+
const signature = generateValidSignature(
74+
signingSecret,
75+
timestamp,
76+
originalBody
77+
);
78+
79+
const result = verifySlackSignature(
80+
signingSecret,
81+
timestamp,
82+
tamperedBody,
83+
signature
84+
);
85+
expect(result).toBe(false);
86+
});
87+
88+
it("should return false when signing secret is wrong", () => {
89+
const timestamp = Math.floor(Date.now() / 1000).toString();
90+
const body = JSON.stringify({ type: "event_callback", event: {} });
91+
const signature = generateValidSignature("wrong_secret", timestamp, body);
92+
93+
const result = verifySlackSignature(
94+
signingSecret,
95+
timestamp,
96+
body,
97+
signature
98+
);
99+
expect(result).toBe(false);
100+
});
101+
});
102+
103+
describe("updateEnvCredentials", () => {
104+
it("should add credentials to an empty env file", async () => {
105+
await using tempDir = await makeTmpDir();
106+
const envPath = join(tempDir.path, ".env.local");
107+
await writeFile(envPath, "", "utf-8");
108+
109+
await updateEnvCredentials(
110+
envPath,
111+
"xoxb-test-token",
112+
"test-signing-secret"
113+
);
114+
115+
const content = await readFile(envPath, "utf-8");
116+
expect(content).toContain("SLACK_BOT_TOKEN=xoxb-test-token");
117+
expect(content).toContain("SLACK_SIGNING_SECRET=test-signing-secret");
118+
expect(content).toContain("# Slack App credentials");
119+
});
120+
121+
it("should add credentials to an env file with existing variables", async () => {
122+
await using tempDir = await makeTmpDir();
123+
const envPath = join(tempDir.path, ".env.local");
124+
await writeFile(
125+
envPath,
126+
"EXISTING_VAR=value\nANOTHER_VAR=another_value\n",
127+
"utf-8"
128+
);
129+
130+
await updateEnvCredentials(
131+
envPath,
132+
"xoxb-test-token",
133+
"test-signing-secret"
134+
);
135+
136+
const content = await readFile(envPath, "utf-8");
137+
expect(content).toContain("EXISTING_VAR=value");
138+
expect(content).toContain("ANOTHER_VAR=another_value");
139+
expect(content).toContain("SLACK_BOT_TOKEN=xoxb-test-token");
140+
expect(content).toContain("SLACK_SIGNING_SECRET=test-signing-secret");
141+
});
142+
143+
it("should comment out existing Slack credentials", async () => {
144+
await using tempDir = await makeTmpDir();
145+
const envPath = join(tempDir.path, ".env.local");
146+
await writeFile(
147+
envPath,
148+
`EXISTING_VAR=value
149+
SLACK_BOT_TOKEN=old-token
150+
SLACK_SIGNING_SECRET=old-secret
151+
ANOTHER_VAR=another_value
152+
`,
153+
"utf-8"
154+
);
155+
156+
await updateEnvCredentials(envPath, "xoxb-new-token", "new-signing-secret");
157+
158+
const content = await readFile(envPath, "utf-8");
159+
160+
// Old credentials should be commented out
161+
expect(content).toContain("# SLACK_BOT_TOKEN=old-token");
162+
expect(content).toContain("# SLACK_SIGNING_SECRET=old-secret");
163+
164+
// New credentials should be present
165+
expect(content).toContain("SLACK_BOT_TOKEN=xoxb-new-token");
166+
expect(content).toContain("SLACK_SIGNING_SECRET=new-signing-secret");
167+
168+
// Other variables should be preserved
169+
expect(content).toContain("EXISTING_VAR=value");
170+
expect(content).toContain("ANOTHER_VAR=another_value");
171+
});
172+
173+
it("should not double-comment already commented credentials", async () => {
174+
await using tempDir = await makeTmpDir();
175+
const envPath = join(tempDir.path, ".env.local");
176+
await writeFile(
177+
envPath,
178+
`# SLACK_BOT_TOKEN=already_commented
179+
SLACK_SIGNING_SECRET=active_secret
180+
`,
181+
"utf-8"
182+
);
183+
184+
await updateEnvCredentials(envPath, "xoxb-new-token", "new-secret");
185+
186+
const content = await readFile(envPath, "utf-8");
187+
188+
// Already commented should stay as is (not double-commented)
189+
expect(content).toContain("# SLACK_BOT_TOKEN=already_commented");
190+
expect(content).not.toContain("# # SLACK_BOT_TOKEN=already_commented");
191+
192+
// Active credential should be commented out
193+
expect(content).toContain("# SLACK_SIGNING_SECRET=active_secret");
194+
195+
// New credentials should be present
196+
expect(content).toContain("SLACK_BOT_TOKEN=xoxb-new-token");
197+
expect(content).toContain("SLACK_SIGNING_SECRET=new-secret");
198+
});
199+
200+
it("should handle env file that does not exist", async () => {
201+
await using tempDir = await makeTmpDir();
202+
const envPath = join(tempDir.path, ".env.local");
203+
// Don't create the file - it doesn't exist
204+
205+
await updateEnvCredentials(envPath, "xoxb-test-token", "test-secret");
206+
207+
const content = await readFile(envPath, "utf-8");
208+
expect(content).toContain("SLACK_BOT_TOKEN=xoxb-test-token");
209+
expect(content).toContain("SLACK_SIGNING_SECRET=test-secret");
210+
});
211+
212+
it("should only add bot token if signing secret is not provided", async () => {
213+
await using tempDir = await makeTmpDir();
214+
const envPath = join(tempDir.path, ".env.local");
215+
await writeFile(envPath, "", "utf-8");
216+
217+
await updateEnvCredentials(envPath, "xoxb-test-token", undefined);
218+
219+
const content = await readFile(envPath, "utf-8");
220+
expect(content).toContain("SLACK_BOT_TOKEN=xoxb-test-token");
221+
expect(content).not.toContain("SLACK_SIGNING_SECRET=");
222+
});
223+
224+
it("should only add signing secret if bot token is not provided", async () => {
225+
await using tempDir = await makeTmpDir();
226+
const envPath = join(tempDir.path, ".env.local");
227+
await writeFile(envPath, "", "utf-8");
228+
229+
await updateEnvCredentials(envPath, undefined, "test-signing-secret");
230+
231+
const content = await readFile(envPath, "utf-8");
232+
expect(content).not.toContain("SLACK_BOT_TOKEN=");
233+
expect(content).toContain("SLACK_SIGNING_SECRET=test-signing-secret");
234+
});
235+
});
236+
237+
describe("setup slack-app command", () => {
238+
it("should show error when .env.local does not exist", async () => {
239+
await using tempDir = await makeTmpDir();
240+
using term = render(`${BLINK_COMMAND} setup slack-app`, {
241+
cwd: tempDir.path,
242+
});
243+
244+
await term.waitUntil((screen) =>
245+
screen.includes("No .env.local file found")
246+
);
247+
expect(term.getScreen()).toContain("No .env.local file found");
248+
});
249+
250+
it("should prompt for app name when .env.local exists", async () => {
251+
await using tempDir = await makeTmpDir();
252+
const envPath = join(tempDir.path, ".env.local");
253+
await writeFile(envPath, "SOME_VAR=value\n", "utf-8");
254+
255+
using term = render(`${BLINK_COMMAND} setup slack-app`, {
256+
cwd: tempDir.path,
257+
});
258+
259+
await term.waitUntil((screen) =>
260+
screen.includes("What should your Slack app be called?")
261+
);
262+
expect(term.getScreen()).toContain("What should your Slack app be called?");
263+
});
264+
265+
it("should show URL and browser prompt after entering app name", async () => {
266+
await using tempDir = await makeTmpDir();
267+
const envPath = join(tempDir.path, ".env.local");
268+
await writeFile(envPath, "SOME_VAR=value\n", "utf-8");
269+
270+
using term = render(`${BLINK_COMMAND} setup slack-app`, {
271+
cwd: tempDir.path,
272+
});
273+
274+
// Enter app name
275+
await term.waitUntil((screen) =>
276+
screen.includes("What should your Slack app be called?")
277+
);
278+
term.write("my-test-slack-app");
279+
term.write(KEY_CODES.ENTER);
280+
281+
// Should show URL and ask about opening browser
282+
await term.waitUntil((screen) =>
283+
screen.includes("Open this URL in your browser automatically?")
284+
);
285+
expect(term.getScreen()).toContain("api.slack.com");
286+
expect(term.getScreen()).toContain(
287+
"Open this URL in your browser automatically?"
288+
);
289+
});
290+
291+
it("should prompt for App ID after declining to open browser", async () => {
292+
await using tempDir = await makeTmpDir();
293+
const envPath = join(tempDir.path, ".env.local");
294+
await writeFile(envPath, "SOME_VAR=value\n", "utf-8");
295+
296+
using term = render(`${BLINK_COMMAND} setup slack-app`, {
297+
cwd: tempDir.path,
298+
});
299+
300+
// Enter app name
301+
await term.waitUntil((screen) =>
302+
screen.includes("What should your Slack app be called?")
303+
);
304+
term.write("my-test-slack-app");
305+
term.write(KEY_CODES.ENTER);
306+
307+
// Decline to open browser
308+
await term.waitUntil((screen) =>
309+
screen.includes("Open this URL in your browser automatically?")
310+
);
311+
// Move selection to "No" and confirm
312+
term.write(KEY_CODES.LEFT);
313+
term.write(KEY_CODES.ENTER);
314+
315+
// Should prompt for App ID
316+
await term.waitUntil((screen) => screen.includes("paste the App ID"));
317+
expect(term.getScreen()).toContain("App ID");
318+
});
319+
});

0 commit comments

Comments
 (0)