@openpalm/slack-portal 0.12.7

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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Slack rich-UX renderer — PURE Block Kit + registry-authorization tests (§4.3).
3
+ *
4
+ * The live Slack side-effects (chat.postMessage/update, real button clicks via
5
+ * app.action) need a live Slack workspace and are stated in needsLiveVerification.
6
+ * What IS unit-provable here is the security/correctness-load-bearing logic:
7
+ * - Block Kit buttons carry the requestID/sessionId in `value` so the central
8
+ * action handler can route them (no per-message collector on Slack);
9
+ * - the permission registry enforces interaction identity (only the requesting
10
+ * Slack user may decide — §4.3) and maps Approve/Always/Deny → the correct
11
+ * signed `reply` relayed through the OcClient;
12
+ * - the Stop registry is likewise identity-gated.
13
+ *
14
+ * The native-OpenCode event interpretation itself is the SHARED portal stream-render
15
+ * logic (oc-events.test.ts) — not retested here.
16
+ */
17
+ import { describe, test, expect } from "bun:test";
18
+ import {
19
+ SlackPermissionRegistry,
20
+ buildPermissionBlocks,
21
+ buildAnswerBlocks,
22
+ buildToolBlocks,
23
+ ACTION_PERM_ONCE,
24
+ ACTION_PERM_ALWAYS,
25
+ ACTION_PERM_DENY,
26
+ } from "./stream-render.ts";
27
+ import type { OcClient } from './runtime.ts';
28
+
29
+ // A hand-written OcClient stub recording the calls the registry makes.
30
+ function stubClient(): { client: OcClient; replies: Array<{ userId: string; requestID: string; reply: string }>; aborts: Array<{ userId: string; sessionId: string }>; fail?: boolean } {
31
+ const replies: Array<{ userId: string; requestID: string; reply: string }> = [];
32
+ const aborts: Array<{ userId: string; sessionId: string }> = [];
33
+ const holder = { fail: false };
34
+ const client = {
35
+ async replyPermission(userId: string, requestID: string, reply: string) {
36
+ if (holder.fail) throw new Error("expired");
37
+ replies.push({ userId, requestID, reply });
38
+ return true;
39
+ },
40
+ async abort(userId: string, sessionId: string) {
41
+ aborts.push({ userId, sessionId });
42
+ },
43
+ } as unknown as OcClient;
44
+ return { client, replies, aborts, ...holder };
45
+ }
46
+
47
+ describe("buildPermissionBlocks — buttons route by value (§4.3)", () => {
48
+ test("each button carries the requestID in value", () => {
49
+ const blocks = buildPermissionBlocks({ requestID: "per_9", permission: "bash", patterns: ["echo *"] }) as any[];
50
+ const actions = blocks.find((b) => b.type === "actions");
51
+ const ids = actions.elements.map((e: any) => e.action_id);
52
+ expect(ids).toEqual([ACTION_PERM_ONCE, ACTION_PERM_ALWAYS, ACTION_PERM_DENY]);
53
+ for (const el of actions.elements) expect(el.value).toBe("per_9");
54
+ });
55
+
56
+ test("the prompt section names the permission + patterns", () => {
57
+ const blocks = buildPermissionBlocks({ requestID: "per_1", permission: "bash", patterns: ["echo *"] }) as any[];
58
+ expect(blocks[0].text.text).toContain("bash");
59
+ expect(blocks[0].text.text).toContain("echo *");
60
+ });
61
+ });
62
+
63
+ describe("buildAnswerBlocks — Stop button carries the sessionId", () => {
64
+ test("stop button value is the sessionId", () => {
65
+ const blocks = buildAnswerBlocks("hello", "ses_42") as any[];
66
+ const actions = blocks.find((b) => b.type === "actions");
67
+ expect(actions.elements[0].value).toBe("ses_42");
68
+ });
69
+ });
70
+
71
+ describe("buildToolBlocks — context block names the tool + status", () => {
72
+ test("renders tool + status", () => {
73
+ const blocks = buildToolBlocks({ callID: "c1", tool: "bash", status: "running", title: "echo hi" }) as any[];
74
+ expect(blocks[0].type).toBe("context");
75
+ expect(blocks[0].elements[0].text).toContain("bash");
76
+ expect(blocks[0].elements[0].text).toContain("echo hi");
77
+ });
78
+
79
+ test("EMPTY status/tool/title still produce non-empty text (Block Kit rejects empty)", () => {
80
+ // Regression: a tool frame with empty status/title must not yield empty text.
81
+ // The Discord equivalent (empty footer/description) threw shapeshift's
82
+ // "Received one or more errors" and aborted the whole turn.
83
+ const blocks = buildToolBlocks({ callID: "c1", tool: "", status: "", title: "" }) as any[];
84
+ const text = blocks[0].elements[0].text as string;
85
+ expect(text.length).toBeGreaterThan(0);
86
+ expect(text).toContain("tool"); // name fallback
87
+ expect(text).toContain("running"); // status fallback
88
+ });
89
+ });
90
+
91
+ describe("SlackPermissionRegistry — interaction identity + reply relay (§4.3)", () => {
92
+ test("Approve → reply:once relayed through the OcClient", async () => {
93
+ const { client, replies } = stubClient();
94
+ const reg = new SlackPermissionRegistry(client);
95
+ reg.registerPermission("per_1", { userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "1.1" });
96
+
97
+ const out = await reg.handlePermissionClick("per_1", ACTION_PERM_ONCE, "U1");
98
+ expect(out).not.toBeNull();
99
+ expect(replies).toEqual([{ userId: "slack:U1", requestID: "per_1", reply: "once" }]);
100
+ expect(out!.text).toContain("once");
101
+ });
102
+
103
+ test("Always → reply:always; Deny → reply:reject", async () => {
104
+ const { client, replies } = stubClient();
105
+ const reg = new SlackPermissionRegistry(client);
106
+ reg.registerPermission("a", { userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "1" });
107
+ reg.registerPermission("b", { userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "2" });
108
+ await reg.handlePermissionClick("a", ACTION_PERM_ALWAYS, "U1");
109
+ await reg.handlePermissionClick("b", ACTION_PERM_DENY, "U1");
110
+ expect(replies.map((r) => r.reply)).toEqual(["always", "reject"]);
111
+ });
112
+
113
+ test("a DIFFERENT Slack user cannot answer the prompt (interaction identity)", async () => {
114
+ const { client, replies } = stubClient();
115
+ const reg = new SlackPermissionRegistry(client);
116
+ reg.registerPermission("per_1", { userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "1.1" });
117
+
118
+ const out = await reg.handlePermissionClick("per_1", ACTION_PERM_ONCE, "U_ATTACKER");
119
+ expect(out).toBeNull();
120
+ expect(replies).toHaveLength(0);
121
+ });
122
+
123
+ test("an unknown/expired requestID is refused", async () => {
124
+ const { client } = stubClient();
125
+ const reg = new SlackPermissionRegistry(client);
126
+ expect(await reg.handlePermissionClick("ghost", ACTION_PERM_ONCE, "U1")).toBeNull();
127
+ });
128
+
129
+ test("a failed reply still resolves with a user-facing message and clears the entry", async () => {
130
+ const s = stubClient();
131
+ s.fail = true;
132
+ // rebuild client to capture the flipped flag
133
+ const replies: Array<{ userId: string; requestID: string; reply: string }> = [];
134
+ const client = {
135
+ async replyPermission() { throw new Error("expired"); },
136
+ async abort() {},
137
+ } as unknown as OcClient;
138
+ const reg = new SlackPermissionRegistry(client);
139
+ reg.registerPermission("per_1", { userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "1.1" });
140
+ const out = await reg.handlePermissionClick("per_1", ACTION_PERM_ONCE, "U1");
141
+ expect(out).not.toBeNull();
142
+ expect(out!.text).toContain("Could not record");
143
+ // second click finds nothing (entry cleared)
144
+ expect(await reg.handlePermissionClick("per_1", ACTION_PERM_ONCE, "U1")).toBeNull();
145
+ expect(replies).toHaveLength(0);
146
+ });
147
+
148
+ test("Stop is identity-gated and issues the abort for the owner", async () => {
149
+ const { client, aborts } = stubClient();
150
+ const reg = new SlackPermissionRegistry(client);
151
+ reg.registerStop("ses_1", { userId: "slack:U1", requestingUserId: "U1", sessionId: "ses_1" });
152
+
153
+ expect(await reg.handleStopClick("ses_1", "U_OTHER")).toBe(false);
154
+ expect(aborts).toHaveLength(0);
155
+
156
+ expect(await reg.handleStopClick("ses_1", "U1")).toBe(true);
157
+ expect(aborts).toEqual([{ userId: "slack:U1", sessionId: "ses_1" }]);
158
+ });
159
+
160
+ test("clearStop removes the stop control", async () => {
161
+ const { client } = stubClient();
162
+ const reg = new SlackPermissionRegistry(client);
163
+ reg.registerStop("ses_1", { userId: "slack:U1", requestingUserId: "U1", sessionId: "ses_1" });
164
+ reg.clearStop("ses_1");
165
+ expect(await reg.handleStopClick("ses_1", "U1")).toBe(false);
166
+ });
167
+
168
+ test("an unclicked permission entry past its TTL is pruned (no unbounded leak)", async () => {
169
+ const { client } = stubClient();
170
+ const reg = new SlackPermissionRegistry(client);
171
+ // An old, never-clicked prompt (registered > TTL ago).
172
+ reg.registerPermission("stale", {
173
+ userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "1",
174
+ createdAt: Date.now() - 16 * 60_000, // default TTL is 15 min
175
+ });
176
+ expect(reg.pendingPermissionCount()).toBe(1);
177
+ // A fresh register triggers the lazy prune, evicting the stale entry.
178
+ reg.registerPermission("fresh", { userId: "slack:U1", requestingUserId: "U1", permission: "bash", channel: "C1", ts: "2" });
179
+ expect(reg.pendingPermissionCount()).toBe(1); // only "fresh" survives
180
+ expect(await reg.handlePermissionClick("stale", ACTION_PERM_ONCE, "U1")).toBeNull(); // gone
181
+ });
182
+ });