@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// Tests for the Linear Agent SDK webhook and OAuth routes.
|
|
2
|
+
// Covers webhook signature verification, event dispatch, OAuth callback,
|
|
3
|
+
// authorization URL generation, status endpoint, and disconnect flow.
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
|
|
8
|
+
// Mock linear-agent module
|
|
9
|
+
vi.mock("../linear-agent.js", () => ({
|
|
10
|
+
verifyWebhookSignature: vi.fn(),
|
|
11
|
+
isLinearOAuthConfigured: vi.fn(),
|
|
12
|
+
getOAuthAuthorizeUrl: vi.fn(),
|
|
13
|
+
exchangeCodeForTokens: vi.fn(),
|
|
14
|
+
validateOAuthState: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock agent-store
|
|
18
|
+
vi.mock("../agent-store.js", () => ({
|
|
19
|
+
listAgents: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock linear-staging module
|
|
23
|
+
vi.mock("../linear-staging.js", () => ({
|
|
24
|
+
createSlot: vi.fn(),
|
|
25
|
+
getSlot: vi.fn(),
|
|
26
|
+
deleteSlot: vi.fn(),
|
|
27
|
+
updateSlotTokens: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock settings-manager
|
|
31
|
+
vi.mock("../settings-manager.js", () => ({
|
|
32
|
+
getSettings: vi.fn().mockReturnValue({
|
|
33
|
+
publicUrl: "https://companion.example.com",
|
|
34
|
+
linearOAuthClientId: "client-id",
|
|
35
|
+
linearOAuthClientSecret: "client-secret",
|
|
36
|
+
linearOAuthWebhookSecret: "webhook-secret",
|
|
37
|
+
linearOAuthAccessToken: "access-token",
|
|
38
|
+
}),
|
|
39
|
+
updateSettings: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import * as linearAgent from "../linear-agent.js";
|
|
43
|
+
import * as settingsManager from "../settings-manager.js";
|
|
44
|
+
import * as agentStore from "../agent-store.js";
|
|
45
|
+
import * as staging from "../linear-staging.js";
|
|
46
|
+
import {
|
|
47
|
+
registerLinearAgentWebhookRoute,
|
|
48
|
+
registerLinearAgentProtectedRoutes,
|
|
49
|
+
} from "./linear-agent-routes.js";
|
|
50
|
+
|
|
51
|
+
// ─── Test helpers ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function createMockBridge() {
|
|
54
|
+
return {
|
|
55
|
+
handleEvent: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
} as unknown as import("../linear-agent-bridge.js").LinearAgentBridge;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createApp() {
|
|
60
|
+
const app = new Hono();
|
|
61
|
+
const bridge = createMockBridge();
|
|
62
|
+
registerLinearAgentWebhookRoute(app, bridge);
|
|
63
|
+
registerLinearAgentProtectedRoutes(app);
|
|
64
|
+
return { app, bridge };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const testAgent = {
|
|
68
|
+
id: "agent-1",
|
|
69
|
+
name: "Linear Bot",
|
|
70
|
+
enabled: true,
|
|
71
|
+
triggers: {
|
|
72
|
+
linear: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
oauthClientId: "test-client-id",
|
|
75
|
+
webhookSecret: "test-webhook-secret",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const validPayload = {
|
|
81
|
+
type: "AgentSessionEvent",
|
|
82
|
+
action: "created",
|
|
83
|
+
oauthClientId: "test-client-id",
|
|
84
|
+
agentSession: {
|
|
85
|
+
id: "session-123",
|
|
86
|
+
status: "pending",
|
|
87
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
88
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
89
|
+
},
|
|
90
|
+
promptContext: "Fix the bug",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ─── Webhook endpoint tests ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("POST /linear/agent-webhook", () => {
|
|
96
|
+
let app: Hono;
|
|
97
|
+
let bridge: ReturnType<typeof createMockBridge>;
|
|
98
|
+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
99
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
100
|
+
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
vi.clearAllMocks();
|
|
104
|
+
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
105
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
106
|
+
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
107
|
+
({ app, bridge } = createApp());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
vi.restoreAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns 401 when webhook signature is invalid", async () => {
|
|
115
|
+
// Agent must be found first (per-agent lookup), then signature check fails
|
|
116
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
117
|
+
vi.mocked(linearAgent.verifyWebhookSignature).mockReturnValue(false);
|
|
118
|
+
|
|
119
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify(validPayload),
|
|
122
|
+
headers: { "Content-Type": "application/json", "linear-signature": "bad-sig" },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(res.status).toBe(401);
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
expect(body.error).toBe("Invalid signature");
|
|
128
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
129
|
+
expect.stringContaining("Invalid webhook signature"),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns 400 for invalid JSON body", async () => {
|
|
134
|
+
// JSON parsing now happens before signature verification
|
|
135
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
body: "not-json{{",
|
|
138
|
+
headers: { "Content-Type": "text/plain", "linear-signature": "valid-sig" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(res.status).toBe(400);
|
|
142
|
+
const body = await res.json();
|
|
143
|
+
expect(body.error).toBe("Invalid JSON");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("dispatches AgentSessionEvent to bridge and returns 200", async () => {
|
|
147
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
148
|
+
vi.mocked(linearAgent.verifyWebhookSignature).mockReturnValue(true);
|
|
149
|
+
|
|
150
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
body: JSON.stringify(validPayload),
|
|
153
|
+
headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(res.status).toBe(200);
|
|
157
|
+
const body = await res.json();
|
|
158
|
+
expect(body.ok).toBe(true);
|
|
159
|
+
|
|
160
|
+
// Wait a tick for the async dispatch
|
|
161
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
162
|
+
expect(bridge.handleEvent).toHaveBeenCalledWith(validPayload);
|
|
163
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
164
|
+
expect.stringContaining("Accepted AgentSessionEvent"),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("ignores non-AgentSessionEvent types", async () => {
|
|
169
|
+
// Type check happens before agent lookup, so no agent mock needed
|
|
170
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
171
|
+
method: "POST",
|
|
172
|
+
body: JSON.stringify({ type: "Issue", action: "created", data: {} }),
|
|
173
|
+
headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
const body = await res.json();
|
|
178
|
+
expect(body.ignored).toBe(true);
|
|
179
|
+
expect(bridge.handleEvent).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("accepts x-linear-signature header as fallback", async () => {
|
|
183
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
184
|
+
vi.mocked(linearAgent.verifyWebhookSignature).mockReturnValue(true);
|
|
185
|
+
|
|
186
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: JSON.stringify(validPayload),
|
|
189
|
+
headers: { "Content-Type": "application/json", "x-linear-signature": "valid-sig" },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
// verifyWebhookSignature now takes (webhookSecret, rawBody, signature)
|
|
194
|
+
expect(linearAgent.verifyWebhookSignature).toHaveBeenCalledWith(
|
|
195
|
+
"test-webhook-secret",
|
|
196
|
+
expect.any(String),
|
|
197
|
+
"valid-sig",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns 404 when no agent matches the oauthClientId", async () => {
|
|
202
|
+
// No agents configured — should return 404
|
|
203
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([]);
|
|
204
|
+
|
|
205
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
body: JSON.stringify(validPayload),
|
|
208
|
+
headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(res.status).toBe(404);
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
expect(body.error).toContain("No agent configured");
|
|
214
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
215
|
+
expect.stringContaining("No agent found for oauthClientId"),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("sanitizes user-controlled fields before logging webhook diagnostics", async () => {
|
|
220
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([]);
|
|
221
|
+
|
|
222
|
+
const maliciousPayload = {
|
|
223
|
+
...validPayload,
|
|
224
|
+
action: "created\nforged",
|
|
225
|
+
oauthClientId: "evil\n[linear-agent-routes] Accepted AgentSessionEvent",
|
|
226
|
+
agentSession: {
|
|
227
|
+
...validPayload.agentSession,
|
|
228
|
+
id: "session-123\tforged",
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const res = await app.request("/linear/agent-webhook", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: JSON.stringify(maliciousPayload),
|
|
235
|
+
headers: { "Content-Type": "application/json", "linear-signature": "valid-sig" },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(res.status).toBe(404);
|
|
239
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
240
|
+
"[linear-agent-routes] No agent found for oauthClientId: evil_[linear-agent-routes] Accepted AgentSessionEvent action=created_forged sessionId=session-123_forged",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("console spy cleanup", () => {
|
|
246
|
+
it("restores console spies before later describe blocks run", () => {
|
|
247
|
+
// Regression test: webhook tests install console spies, but later describes
|
|
248
|
+
// should still see the original console implementations.
|
|
249
|
+
expect(vi.isMockFunction(console.log)).toBe(false);
|
|
250
|
+
expect(vi.isMockFunction(console.warn)).toBe(false);
|
|
251
|
+
expect(vi.isMockFunction(console.error)).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ─── OAuth callback tests ───────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("GET /linear/oauth/callback", () => {
|
|
258
|
+
let app: Hono;
|
|
259
|
+
|
|
260
|
+
beforeEach(() => {
|
|
261
|
+
vi.clearAllMocks();
|
|
262
|
+
({ app } = createApp());
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("redirects with error when error parameter is present", async () => {
|
|
266
|
+
const res = await app.request("/linear/oauth/callback?error=access_denied");
|
|
267
|
+
|
|
268
|
+
expect(res.status).toBe(302);
|
|
269
|
+
const location = res.headers.get("location");
|
|
270
|
+
expect(location).toContain("oauth_error=access_denied");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("redirects with error when no code parameter", async () => {
|
|
274
|
+
const res = await app.request("/linear/oauth/callback");
|
|
275
|
+
|
|
276
|
+
expect(res.status).toBe(302);
|
|
277
|
+
const location = res.headers.get("location");
|
|
278
|
+
expect(location).toContain("oauth_error=no_code");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("redirects with error when state is missing (CSRF protection)", async () => {
|
|
282
|
+
vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: false });
|
|
283
|
+
const res = await app.request("/linear/oauth/callback?code=auth-code-123");
|
|
284
|
+
|
|
285
|
+
expect(res.status).toBe(302);
|
|
286
|
+
const location = res.headers.get("location");
|
|
287
|
+
expect(location).toContain("oauth_error=invalid_state");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("redirects with error when state is invalid (CSRF protection)", async () => {
|
|
291
|
+
vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: false });
|
|
292
|
+
|
|
293
|
+
const res = await app.request("/linear/oauth/callback?code=auth-code-123&state=bad-state");
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe(302);
|
|
296
|
+
const location = res.headers.get("location");
|
|
297
|
+
expect(location).toContain("oauth_error=invalid_state");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("exchanges code for tokens and redirects on success", async () => {
|
|
301
|
+
vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: true });
|
|
302
|
+
vi.mocked(linearAgent.exchangeCodeForTokens).mockResolvedValue({
|
|
303
|
+
accessToken: "new-access",
|
|
304
|
+
refreshToken: "new-refresh",
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const res = await app.request("/linear/oauth/callback?code=auth-code-123&state=valid-state");
|
|
308
|
+
|
|
309
|
+
expect(res.status).toBe(302);
|
|
310
|
+
const location = res.headers.get("location");
|
|
311
|
+
expect(location).toContain("oauth_success=true");
|
|
312
|
+
|
|
313
|
+
// exchangeCodeForTokens now receives credentials object as first arg
|
|
314
|
+
expect(linearAgent.exchangeCodeForTokens).toHaveBeenCalledWith(
|
|
315
|
+
{ clientId: "client-id", clientSecret: "client-secret" },
|
|
316
|
+
"auth-code-123",
|
|
317
|
+
expect.stringContaining("/api/linear/oauth/callback"),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Should persist tokens to global staging
|
|
321
|
+
expect(settingsManager.updateSettings).toHaveBeenCalledWith({
|
|
322
|
+
linearOAuthAccessToken: "new-access",
|
|
323
|
+
linearOAuthRefreshToken: "new-refresh",
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("redirects with error when token exchange fails", async () => {
|
|
328
|
+
vi.mocked(linearAgent.validateOAuthState).mockReturnValue({ valid: true });
|
|
329
|
+
vi.mocked(linearAgent.exchangeCodeForTokens).mockResolvedValue(null);
|
|
330
|
+
|
|
331
|
+
const res = await app.request("/linear/oauth/callback?code=bad-code&state=valid-state");
|
|
332
|
+
|
|
333
|
+
expect(res.status).toBe(302);
|
|
334
|
+
const location = res.headers.get("location");
|
|
335
|
+
expect(location).toContain("oauth_error=token_exchange_failed");
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ─── OAuth authorize URL endpoint ───────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
describe("GET /linear/oauth/authorize-url", () => {
|
|
342
|
+
let app: Hono;
|
|
343
|
+
|
|
344
|
+
beforeEach(() => {
|
|
345
|
+
vi.clearAllMocks();
|
|
346
|
+
({ app } = createApp());
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("returns authorization URL when configured", async () => {
|
|
350
|
+
vi.mocked(linearAgent.getOAuthAuthorizeUrl).mockReturnValue({
|
|
351
|
+
url: "https://linear.app/oauth/authorize?client_id=test&state=abc123",
|
|
352
|
+
state: "abc123",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const res = await app.request("/linear/oauth/authorize-url");
|
|
356
|
+
|
|
357
|
+
expect(res.status).toBe(200);
|
|
358
|
+
const body = await res.json();
|
|
359
|
+
expect(body.url).toContain("linear.app/oauth/authorize");
|
|
360
|
+
|
|
361
|
+
// getOAuthAuthorizeUrl receives clientId, redirectUri, and an options object
|
|
362
|
+
expect(linearAgent.getOAuthAuthorizeUrl).toHaveBeenCalledWith(
|
|
363
|
+
"client-id",
|
|
364
|
+
expect.stringContaining("/api/linear/oauth/callback"),
|
|
365
|
+
{ returnTo: undefined, stagingId: undefined },
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("returns 400 when OAuth client ID is not configured", async () => {
|
|
370
|
+
vi.mocked(linearAgent.getOAuthAuthorizeUrl).mockReturnValue(null);
|
|
371
|
+
|
|
372
|
+
const res = await app.request("/linear/oauth/authorize-url");
|
|
373
|
+
|
|
374
|
+
expect(res.status).toBe(400);
|
|
375
|
+
const body = await res.json();
|
|
376
|
+
expect(body.error).toContain("not configured");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ─── OAuth status endpoint ──────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
describe("GET /linear/oauth/status", () => {
|
|
383
|
+
let app: Hono;
|
|
384
|
+
|
|
385
|
+
beforeEach(() => {
|
|
386
|
+
vi.clearAllMocks();
|
|
387
|
+
({ app } = createApp());
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns OAuth configuration status", async () => {
|
|
391
|
+
vi.mocked(linearAgent.isLinearOAuthConfigured).mockReturnValue(true);
|
|
392
|
+
|
|
393
|
+
const res = await app.request("/linear/oauth/status");
|
|
394
|
+
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
const body = await res.json();
|
|
397
|
+
expect(body.configured).toBe(true);
|
|
398
|
+
expect(body.hasClientId).toBe(true);
|
|
399
|
+
expect(body.hasClientSecret).toBe(true);
|
|
400
|
+
expect(body.hasWebhookSecret).toBe(true);
|
|
401
|
+
expect(body.hasAccessToken).toBe(true);
|
|
402
|
+
|
|
403
|
+
// isLinearOAuthConfigured now receives credentials object
|
|
404
|
+
expect(linearAgent.isLinearOAuthConfigured).toHaveBeenCalledWith({
|
|
405
|
+
clientId: "client-id",
|
|
406
|
+
clientSecret: "client-secret",
|
|
407
|
+
accessToken: "access-token",
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ─── OAuth disconnect endpoint ──────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe("POST /linear/oauth/disconnect", () => {
|
|
415
|
+
let app: Hono;
|
|
416
|
+
|
|
417
|
+
beforeEach(() => {
|
|
418
|
+
vi.clearAllMocks();
|
|
419
|
+
({ app } = createApp());
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("clears OAuth tokens and returns success", async () => {
|
|
423
|
+
const res = await app.request("/linear/oauth/disconnect", { method: "POST" });
|
|
424
|
+
|
|
425
|
+
expect(res.status).toBe(200);
|
|
426
|
+
const body = await res.json();
|
|
427
|
+
expect(body.ok).toBe(true);
|
|
428
|
+
|
|
429
|
+
expect(settingsManager.updateSettings).toHaveBeenCalledWith({
|
|
430
|
+
linearOAuthAccessToken: "",
|
|
431
|
+
linearOAuthRefreshToken: "",
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ─── Staging slot CRUD tests ────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
describe("POST /linear/oauth/staging", () => {
|
|
439
|
+
let app: Hono;
|
|
440
|
+
|
|
441
|
+
beforeEach(() => {
|
|
442
|
+
vi.clearAllMocks();
|
|
443
|
+
({ app } = createApp());
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("creates a staging slot and returns the stagingId", async () => {
|
|
447
|
+
// createSlot should return a hex ID when given valid credentials
|
|
448
|
+
vi.mocked(staging.createSlot).mockReturnValue("abcd1234abcd1234abcd1234abcd1234");
|
|
449
|
+
|
|
450
|
+
const res = await app.request("/linear/oauth/staging", {
|
|
451
|
+
method: "POST",
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
clientId: "my-client-id",
|
|
454
|
+
clientSecret: "my-client-secret",
|
|
455
|
+
webhookSecret: "my-webhook-secret",
|
|
456
|
+
}),
|
|
457
|
+
headers: { "Content-Type": "application/json" },
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(res.status).toBe(200);
|
|
461
|
+
const body = await res.json();
|
|
462
|
+
expect(body.stagingId).toBe("abcd1234abcd1234abcd1234abcd1234");
|
|
463
|
+
|
|
464
|
+
// Verify createSlot was called with the provided credentials
|
|
465
|
+
expect(staging.createSlot).toHaveBeenCalledWith({
|
|
466
|
+
clientId: "my-client-id",
|
|
467
|
+
clientSecret: "my-client-secret",
|
|
468
|
+
webhookSecret: "my-webhook-secret",
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("returns 400 when required fields are missing", async () => {
|
|
473
|
+
// Missing webhookSecret — should be rejected before createSlot is called
|
|
474
|
+
const res = await app.request("/linear/oauth/staging", {
|
|
475
|
+
method: "POST",
|
|
476
|
+
body: JSON.stringify({
|
|
477
|
+
clientId: "my-client-id",
|
|
478
|
+
clientSecret: "my-client-secret",
|
|
479
|
+
// webhookSecret intentionally omitted
|
|
480
|
+
}),
|
|
481
|
+
headers: { "Content-Type": "application/json" },
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
expect(res.status).toBe(400);
|
|
485
|
+
const body = await res.json();
|
|
486
|
+
expect(body.error).toContain("required");
|
|
487
|
+
expect(staging.createSlot).not.toHaveBeenCalled();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("returns 400 when all fields are empty strings", async () => {
|
|
491
|
+
// All fields present but empty — should be rejected after trimming
|
|
492
|
+
const res = await app.request("/linear/oauth/staging", {
|
|
493
|
+
method: "POST",
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
clientId: " ",
|
|
496
|
+
clientSecret: "",
|
|
497
|
+
webhookSecret: "",
|
|
498
|
+
}),
|
|
499
|
+
headers: { "Content-Type": "application/json" },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(res.status).toBe(400);
|
|
503
|
+
const body = await res.json();
|
|
504
|
+
expect(body.error).toContain("required");
|
|
505
|
+
expect(staging.createSlot).not.toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("GET /linear/oauth/staging/:id/status", () => {
|
|
510
|
+
let app: Hono;
|
|
511
|
+
|
|
512
|
+
beforeEach(() => {
|
|
513
|
+
vi.clearAllMocks();
|
|
514
|
+
({ app } = createApp());
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("returns full status for an existing staging slot", async () => {
|
|
518
|
+
// Simulate a slot that has completed OAuth (has accessToken)
|
|
519
|
+
vi.mocked(staging.getSlot).mockReturnValue({
|
|
520
|
+
id: "abcd1234abcd1234abcd1234abcd1234",
|
|
521
|
+
clientId: "my-client-id",
|
|
522
|
+
clientSecret: "my-client-secret",
|
|
523
|
+
webhookSecret: "my-webhook-secret",
|
|
524
|
+
accessToken: "token-abc",
|
|
525
|
+
refreshToken: "refresh-abc",
|
|
526
|
+
createdAt: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const res = await app.request("/linear/oauth/staging/abcd1234abcd1234abcd1234abcd1234/status");
|
|
530
|
+
|
|
531
|
+
expect(res.status).toBe(200);
|
|
532
|
+
const body = await res.json();
|
|
533
|
+
expect(body.exists).toBe(true);
|
|
534
|
+
expect(body.hasAccessToken).toBe(true);
|
|
535
|
+
expect(body.hasClientId).toBe(true);
|
|
536
|
+
expect(body.hasClientSecret).toBe(true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("returns exists:false for a non-existent or expired slot", async () => {
|
|
540
|
+
// getSlot returns null when the slot doesn't exist or has expired
|
|
541
|
+
vi.mocked(staging.getSlot).mockReturnValue(null);
|
|
542
|
+
|
|
543
|
+
const res = await app.request("/linear/oauth/staging/deadbeefdeadbeefdeadbeefdeadbeef/status");
|
|
544
|
+
|
|
545
|
+
expect(res.status).toBe(200);
|
|
546
|
+
const body = await res.json();
|
|
547
|
+
expect(body.exists).toBe(false);
|
|
548
|
+
expect(body.hasAccessToken).toBe(false);
|
|
549
|
+
expect(body.hasClientId).toBe(false);
|
|
550
|
+
expect(body.hasClientSecret).toBe(false);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe("DELETE /linear/oauth/staging/:id", () => {
|
|
555
|
+
let app: Hono;
|
|
556
|
+
|
|
557
|
+
beforeEach(() => {
|
|
558
|
+
vi.clearAllMocks();
|
|
559
|
+
({ app } = createApp());
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("deletes a staging slot and returns ok", async () => {
|
|
563
|
+
vi.mocked(staging.deleteSlot).mockReturnValue(true);
|
|
564
|
+
|
|
565
|
+
const res = await app.request("/linear/oauth/staging/abcd1234abcd1234abcd1234abcd1234", {
|
|
566
|
+
method: "DELETE",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(res.status).toBe(200);
|
|
570
|
+
const body = await res.json();
|
|
571
|
+
expect(body.ok).toBe(true);
|
|
572
|
+
|
|
573
|
+
expect(staging.deleteSlot).toHaveBeenCalledWith("abcd1234abcd1234abcd1234abcd1234");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ─── OAuth callback with expired staging slot ───────────────────────────────
|
|
578
|
+
|
|
579
|
+
describe("GET /linear/oauth/callback — expired staging slot", () => {
|
|
580
|
+
let app: Hono;
|
|
581
|
+
|
|
582
|
+
beforeEach(() => {
|
|
583
|
+
vi.clearAllMocks();
|
|
584
|
+
({ app } = createApp());
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("redirects with staging_slot_expired error when staging slot has expired", async () => {
|
|
588
|
+
// The state nonce is valid and contains a stagingId, but the slot has been
|
|
589
|
+
// deleted or expired (getSlot returns null). The callback should NOT fall
|
|
590
|
+
// back to global credentials — it should return an explicit error.
|
|
591
|
+
vi.mocked(linearAgent.validateOAuthState).mockReturnValue({
|
|
592
|
+
valid: true,
|
|
593
|
+
stagingId: "abcd1234abcd1234abcd1234abcd1234",
|
|
594
|
+
returnTo: "/#/agents",
|
|
595
|
+
});
|
|
596
|
+
vi.mocked(staging.getSlot).mockReturnValue(null);
|
|
597
|
+
|
|
598
|
+
const res = await app.request(
|
|
599
|
+
"/linear/oauth/callback?code=auth-code-123&state=valid-state-with-staging",
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
expect(res.status).toBe(302);
|
|
603
|
+
const location = res.headers.get("location");
|
|
604
|
+
// Should redirect to the returnTo path with the staging_slot_expired error
|
|
605
|
+
expect(location).toContain("/#/agents");
|
|
606
|
+
expect(location).toContain("oauth_error=staging_slot_expired");
|
|
607
|
+
|
|
608
|
+
// Token exchange should never be attempted when the staging slot is expired
|
|
609
|
+
expect(linearAgent.exchangeCodeForTokens).not.toHaveBeenCalled();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("uses staging slot credentials when slot exists", async () => {
|
|
613
|
+
// When a stagingId is in the state and the slot is still alive, the callback
|
|
614
|
+
// should use the staging slot's credentials for token exchange and persist
|
|
615
|
+
// the tokens back to the slot via updateSlotTokens.
|
|
616
|
+
vi.mocked(linearAgent.validateOAuthState).mockReturnValue({
|
|
617
|
+
valid: true,
|
|
618
|
+
stagingId: "abcd1234abcd1234abcd1234abcd1234",
|
|
619
|
+
returnTo: "/#/agents",
|
|
620
|
+
});
|
|
621
|
+
vi.mocked(staging.getSlot).mockReturnValue({
|
|
622
|
+
id: "abcd1234abcd1234abcd1234abcd1234",
|
|
623
|
+
clientId: "staging-client-id",
|
|
624
|
+
clientSecret: "staging-client-secret",
|
|
625
|
+
webhookSecret: "staging-webhook-secret",
|
|
626
|
+
accessToken: "",
|
|
627
|
+
refreshToken: "",
|
|
628
|
+
createdAt: Date.now(),
|
|
629
|
+
});
|
|
630
|
+
vi.mocked(linearAgent.exchangeCodeForTokens).mockResolvedValue({
|
|
631
|
+
accessToken: "new-staging-access",
|
|
632
|
+
refreshToken: "new-staging-refresh",
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const res = await app.request(
|
|
636
|
+
"/linear/oauth/callback?code=auth-code-456&state=valid-state-with-staging",
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
expect(res.status).toBe(302);
|
|
640
|
+
const location = res.headers.get("location");
|
|
641
|
+
expect(location).toContain("/#/agents");
|
|
642
|
+
expect(location).toContain("oauth_success=true");
|
|
643
|
+
|
|
644
|
+
// Should use the staging slot's credentials, not global settings
|
|
645
|
+
expect(linearAgent.exchangeCodeForTokens).toHaveBeenCalledWith(
|
|
646
|
+
{ clientId: "staging-client-id", clientSecret: "staging-client-secret" },
|
|
647
|
+
"auth-code-456",
|
|
648
|
+
expect.stringContaining("/api/linear/oauth/callback"),
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
// Tokens should be persisted to the staging slot, not global settings
|
|
652
|
+
expect(staging.updateSlotTokens).toHaveBeenCalledWith(
|
|
653
|
+
"abcd1234abcd1234abcd1234abcd1234",
|
|
654
|
+
{ accessToken: "new-staging-access", refreshToken: "new-staging-refresh" },
|
|
655
|
+
);
|
|
656
|
+
// Global settings should NOT be updated
|
|
657
|
+
expect(settingsManager.updateSettings).not.toHaveBeenCalled();
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// ─── OAuth authorize URL with staging slot ──────────────────────────────────
|
|
662
|
+
|
|
663
|
+
describe("GET /linear/oauth/authorize-url — with stagingId", () => {
|
|
664
|
+
let app: Hono;
|
|
665
|
+
|
|
666
|
+
beforeEach(() => {
|
|
667
|
+
vi.clearAllMocks();
|
|
668
|
+
({ app } = createApp());
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("uses the staging slot's clientId when stagingId is provided", async () => {
|
|
672
|
+
// When the authorize-url request includes a stagingId, the route should look
|
|
673
|
+
// up the staging slot and use its clientId instead of the global setting.
|
|
674
|
+
vi.mocked(staging.getSlot).mockReturnValue({
|
|
675
|
+
id: "abcd1234abcd1234abcd1234abcd1234",
|
|
676
|
+
clientId: "staging-oauth-client-id",
|
|
677
|
+
clientSecret: "staging-oauth-client-secret",
|
|
678
|
+
webhookSecret: "staging-webhook-secret",
|
|
679
|
+
accessToken: "",
|
|
680
|
+
refreshToken: "",
|
|
681
|
+
createdAt: Date.now(),
|
|
682
|
+
});
|
|
683
|
+
vi.mocked(linearAgent.getOAuthAuthorizeUrl).mockReturnValue({
|
|
684
|
+
url: "https://linear.app/oauth/authorize?client_id=staging-oauth-client-id&state=xyz",
|
|
685
|
+
state: "xyz",
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const res = await app.request(
|
|
689
|
+
"/linear/oauth/authorize-url?stagingId=abcd1234abcd1234abcd1234abcd1234",
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
expect(res.status).toBe(200);
|
|
693
|
+
const body = await res.json();
|
|
694
|
+
expect(body.url).toContain("linear.app/oauth/authorize");
|
|
695
|
+
|
|
696
|
+
// getOAuthAuthorizeUrl should receive the staging slot's clientId
|
|
697
|
+
expect(linearAgent.getOAuthAuthorizeUrl).toHaveBeenCalledWith(
|
|
698
|
+
"staging-oauth-client-id",
|
|
699
|
+
expect.stringContaining("/api/linear/oauth/callback"),
|
|
700
|
+
{ returnTo: undefined, stagingId: "abcd1234abcd1234abcd1234abcd1234" },
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("returns 404 when staging slot doesn't exist (expired or deleted)", async () => {
|
|
705
|
+
// If stagingId is provided but the slot is expired/missing, the endpoint
|
|
706
|
+
// should return 404 immediately rather than generating a URL that will
|
|
707
|
+
// fail at callback time with staging_slot_expired.
|
|
708
|
+
vi.mocked(staging.getSlot).mockReturnValue(null);
|
|
709
|
+
|
|
710
|
+
const res = await app.request(
|
|
711
|
+
"/linear/oauth/authorize-url?stagingId=deadbeefdeadbeefdeadbeefdeadbeef",
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
expect(res.status).toBe(404);
|
|
715
|
+
const body = await res.json();
|
|
716
|
+
expect(body.error).toContain("Staging slot expired");
|
|
717
|
+
|
|
718
|
+
// Should not have attempted to generate an authorize URL
|
|
719
|
+
expect(linearAgent.getOAuthAuthorizeUrl).not.toHaveBeenCalled();
|
|
720
|
+
});
|
|
721
|
+
});
|