@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,927 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock linear-connections ────────────────────────────────────────────────
|
|
4
|
+
// Each function is declared as a vi.fn() so we can control return values per test.
|
|
5
|
+
const mockListConnections = vi.fn(() => [] as any[]);
|
|
6
|
+
const mockGetConnection = vi.fn((_id: string) => null as any);
|
|
7
|
+
const mockCreateConnection = vi.fn((data: { name: string; apiKey: string }) => ({
|
|
8
|
+
id: "new-conn-id",
|
|
9
|
+
name: data.name,
|
|
10
|
+
apiKey: data.apiKey,
|
|
11
|
+
workspaceName: "",
|
|
12
|
+
workspaceId: "",
|
|
13
|
+
viewerName: "",
|
|
14
|
+
viewerEmail: "",
|
|
15
|
+
connected: false,
|
|
16
|
+
autoTransition: false,
|
|
17
|
+
autoTransitionStateId: "",
|
|
18
|
+
autoTransitionStateName: "",
|
|
19
|
+
archiveTransition: false,
|
|
20
|
+
archiveTransitionStateId: "",
|
|
21
|
+
archiveTransitionStateName: "",
|
|
22
|
+
createdAt: 1000,
|
|
23
|
+
updatedAt: 1000,
|
|
24
|
+
}));
|
|
25
|
+
const mockUpdateConnection = vi.fn((_id: string, _patch: any) => null as any);
|
|
26
|
+
const mockDeleteConnection = vi.fn((_id: string) => false);
|
|
27
|
+
|
|
28
|
+
vi.mock("../linear-connections.js", () => ({
|
|
29
|
+
listConnections: () => mockListConnections(),
|
|
30
|
+
getConnection: (id: string) => mockGetConnection(id),
|
|
31
|
+
createConnection: (data: any) => mockCreateConnection(data),
|
|
32
|
+
updateConnection: (id: string, patch: any) => mockUpdateConnection(id, patch),
|
|
33
|
+
deleteConnection: (id: string) => mockDeleteConnection(id),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// ─── Mock linear-cache ──────────────────────────────────────────────────────
|
|
37
|
+
// The routes use linearCache.invalidate() on update/delete; we mock it as a no-op spy.
|
|
38
|
+
vi.mock("../linear-cache.js", () => ({
|
|
39
|
+
linearCache: {
|
|
40
|
+
getOrFetch: vi.fn(async (_key: string, _ttl: number, fetcher: () => Promise<unknown>) => fetcher()),
|
|
41
|
+
invalidate: vi.fn(),
|
|
42
|
+
clear: vi.fn(),
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// ─── Imports (after mocks are declared) ─────────────────────────────────────
|
|
47
|
+
import { Hono } from "hono";
|
|
48
|
+
import { linearCache } from "../linear-cache.js";
|
|
49
|
+
import { registerLinearConnectionRoutes } from "./linear-connection-routes.js";
|
|
50
|
+
|
|
51
|
+
// ─── Test setup ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
let app: Hono;
|
|
54
|
+
|
|
55
|
+
// Save original global fetch so we can restore it after each test.
|
|
56
|
+
const originalFetch = globalThis.fetch;
|
|
57
|
+
|
|
58
|
+
/** Helper to mock globalThis.fetch without TS errors about missing properties */
|
|
59
|
+
function mockFetch() {
|
|
60
|
+
const fn = vi.fn();
|
|
61
|
+
globalThis.fetch = fn as any;
|
|
62
|
+
return fn;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build a successful Linear GraphQL response (for verification calls). */
|
|
66
|
+
function linearVerifyOk(viewer: Record<string, unknown>, org: Record<string, unknown>) {
|
|
67
|
+
return new Response(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
data: {
|
|
70
|
+
viewer,
|
|
71
|
+
organization: org,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build a Linear GraphQL error response. */
|
|
79
|
+
function linearError(message: string, status = 200) {
|
|
80
|
+
return new Response(
|
|
81
|
+
JSON.stringify({ errors: [{ message }] }),
|
|
82
|
+
{ status, headers: { "Content-Type": "application/json" } },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build a non-ok HTTP response. */
|
|
87
|
+
function linearHttpError(statusText = "Internal Server Error", status = 500) {
|
|
88
|
+
return new Response(JSON.stringify({}), {
|
|
89
|
+
status,
|
|
90
|
+
statusText,
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Create a full connection object for use in mocks. */
|
|
96
|
+
function makeConnection(overrides: Record<string, unknown> = {}) {
|
|
97
|
+
return {
|
|
98
|
+
id: "conn-1",
|
|
99
|
+
name: "My Workspace",
|
|
100
|
+
apiKey: "lin_api_testapikey1234",
|
|
101
|
+
workspaceName: "TestOrg",
|
|
102
|
+
workspaceId: "org-1",
|
|
103
|
+
viewerName: "Test User",
|
|
104
|
+
viewerEmail: "test@example.com",
|
|
105
|
+
connected: true,
|
|
106
|
+
autoTransition: false,
|
|
107
|
+
autoTransitionStateId: "",
|
|
108
|
+
autoTransitionStateName: "",
|
|
109
|
+
archiveTransition: false,
|
|
110
|
+
archiveTransitionStateId: "",
|
|
111
|
+
archiveTransitionStateName: "",
|
|
112
|
+
createdAt: 1000,
|
|
113
|
+
updatedAt: 1000,
|
|
114
|
+
...overrides,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
|
|
121
|
+
// Restore global fetch to prevent leaks between tests
|
|
122
|
+
globalThis.fetch = originalFetch;
|
|
123
|
+
|
|
124
|
+
// Reset mock defaults
|
|
125
|
+
mockListConnections.mockReturnValue([]);
|
|
126
|
+
mockGetConnection.mockReturnValue(null);
|
|
127
|
+
mockDeleteConnection.mockReturnValue(false);
|
|
128
|
+
mockUpdateConnection.mockReturnValue(null);
|
|
129
|
+
|
|
130
|
+
app = new Hono();
|
|
131
|
+
const api = new Hono();
|
|
132
|
+
registerLinearConnectionRoutes(api);
|
|
133
|
+
app.route("/api", api);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
globalThis.fetch = originalFetch;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// =============================================================================
|
|
141
|
+
// GET /api/linear/connections
|
|
142
|
+
// =============================================================================
|
|
143
|
+
|
|
144
|
+
describe("GET /api/linear/connections", () => {
|
|
145
|
+
it("returns an empty array when no connections exist", async () => {
|
|
146
|
+
// Validates that listing connections with an empty store returns { connections: [] }
|
|
147
|
+
mockListConnections.mockReturnValue([]);
|
|
148
|
+
|
|
149
|
+
const res = await app.request("/api/linear/connections");
|
|
150
|
+
expect(res.status).toBe(200);
|
|
151
|
+
const json = await res.json();
|
|
152
|
+
expect(json.connections).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns connections with API keys masked, showing only last 4 chars", async () => {
|
|
156
|
+
// Validates that the maskApiKey helper correctly hides all but the last 4 characters
|
|
157
|
+
const conn = makeConnection({ apiKey: "lin_api_supersecretkey1234" });
|
|
158
|
+
mockListConnections.mockReturnValue([conn]);
|
|
159
|
+
|
|
160
|
+
const res = await app.request("/api/linear/connections");
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
|
|
164
|
+
expect(json.connections).toHaveLength(1);
|
|
165
|
+
expect(json.connections[0].apiKeyLast4).toBe("****1234");
|
|
166
|
+
// The raw apiKey should NOT appear in the response
|
|
167
|
+
expect(json.connections[0].apiKey).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("masks short API keys (4 chars or fewer) as '****'", async () => {
|
|
171
|
+
// Validates the edge case in maskApiKey where key.length <= 4 returns "****"
|
|
172
|
+
const conn = makeConnection({ apiKey: "abcd" });
|
|
173
|
+
mockListConnections.mockReturnValue([conn]);
|
|
174
|
+
|
|
175
|
+
const res = await app.request("/api/linear/connections");
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
const json = await res.json();
|
|
178
|
+
|
|
179
|
+
expect(json.connections[0].apiKeyLast4).toBe("****");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("masks very short API keys (fewer than 4 chars) as '****'", async () => {
|
|
183
|
+
// Validates the edge case in maskApiKey where key.length < 4 returns "****"
|
|
184
|
+
const conn = makeConnection({ apiKey: "ab" });
|
|
185
|
+
mockListConnections.mockReturnValue([conn]);
|
|
186
|
+
|
|
187
|
+
const res = await app.request("/api/linear/connections");
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
const json = await res.json();
|
|
190
|
+
|
|
191
|
+
expect(json.connections[0].apiKeyLast4).toBe("****");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("returns all connection fields (except raw apiKey) for multiple connections", async () => {
|
|
195
|
+
// Validates that all mapped fields are correctly returned for each connection
|
|
196
|
+
const conns = [
|
|
197
|
+
makeConnection({
|
|
198
|
+
id: "conn-1",
|
|
199
|
+
name: "Workspace A",
|
|
200
|
+
apiKey: "lin_api_aaaabbbb",
|
|
201
|
+
workspaceName: "OrgA",
|
|
202
|
+
workspaceId: "org-a",
|
|
203
|
+
viewerName: "Alice",
|
|
204
|
+
viewerEmail: "alice@example.com",
|
|
205
|
+
connected: true,
|
|
206
|
+
autoTransition: true,
|
|
207
|
+
autoTransitionStateId: "state-1",
|
|
208
|
+
autoTransitionStateName: "In Progress",
|
|
209
|
+
archiveTransition: true,
|
|
210
|
+
archiveTransitionStateId: "state-2",
|
|
211
|
+
archiveTransitionStateName: "Done",
|
|
212
|
+
}),
|
|
213
|
+
makeConnection({
|
|
214
|
+
id: "conn-2",
|
|
215
|
+
name: "Workspace B",
|
|
216
|
+
apiKey: "lin_api_ccccdddd",
|
|
217
|
+
connected: false,
|
|
218
|
+
}),
|
|
219
|
+
];
|
|
220
|
+
mockListConnections.mockReturnValue(conns);
|
|
221
|
+
|
|
222
|
+
const res = await app.request("/api/linear/connections");
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
const json = await res.json();
|
|
225
|
+
|
|
226
|
+
expect(json.connections).toHaveLength(2);
|
|
227
|
+
|
|
228
|
+
// First connection: verify all fields
|
|
229
|
+
const c1 = json.connections[0];
|
|
230
|
+
expect(c1.id).toBe("conn-1");
|
|
231
|
+
expect(c1.name).toBe("Workspace A");
|
|
232
|
+
expect(c1.apiKeyLast4).toBe("****bbbb");
|
|
233
|
+
expect(c1.workspaceName).toBe("OrgA");
|
|
234
|
+
expect(c1.workspaceId).toBe("org-a");
|
|
235
|
+
expect(c1.viewerName).toBe("Alice");
|
|
236
|
+
expect(c1.viewerEmail).toBe("alice@example.com");
|
|
237
|
+
expect(c1.connected).toBe(true);
|
|
238
|
+
expect(c1.autoTransition).toBe(true);
|
|
239
|
+
expect(c1.autoTransitionStateId).toBe("state-1");
|
|
240
|
+
expect(c1.autoTransitionStateName).toBe("In Progress");
|
|
241
|
+
expect(c1.archiveTransition).toBe(true);
|
|
242
|
+
expect(c1.archiveTransitionStateId).toBe("state-2");
|
|
243
|
+
expect(c1.archiveTransitionStateName).toBe("Done");
|
|
244
|
+
|
|
245
|
+
// Second connection: spot check
|
|
246
|
+
const c2 = json.connections[1];
|
|
247
|
+
expect(c2.id).toBe("conn-2");
|
|
248
|
+
expect(c2.connected).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// POST /api/linear/connections
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
describe("POST /api/linear/connections", () => {
|
|
257
|
+
it("returns 400 when name is missing", async () => {
|
|
258
|
+
// Validates that the route rejects requests without a name field
|
|
259
|
+
const res = await app.request("/api/linear/connections", {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "Content-Type": "application/json" },
|
|
262
|
+
body: JSON.stringify({ apiKey: "lin_api_somekey1234" }),
|
|
263
|
+
});
|
|
264
|
+
expect(res.status).toBe(400);
|
|
265
|
+
const json = await res.json();
|
|
266
|
+
expect(json.error).toBe("name is required");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("returns 400 when name is empty string", async () => {
|
|
270
|
+
// Validates that whitespace-only names are treated as empty
|
|
271
|
+
const res = await app.request("/api/linear/connections", {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({ name: " ", apiKey: "lin_api_somekey1234" }),
|
|
275
|
+
});
|
|
276
|
+
expect(res.status).toBe(400);
|
|
277
|
+
const json = await res.json();
|
|
278
|
+
expect(json.error).toBe("name is required");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("returns 400 when apiKey is missing", async () => {
|
|
282
|
+
// Validates that the route rejects requests without an apiKey field
|
|
283
|
+
const res = await app.request("/api/linear/connections", {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "Content-Type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ name: "My Connection" }),
|
|
287
|
+
});
|
|
288
|
+
expect(res.status).toBe(400);
|
|
289
|
+
const json = await res.json();
|
|
290
|
+
expect(json.error).toBe("apiKey is required");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("returns 400 when apiKey is empty string", async () => {
|
|
294
|
+
// Validates that whitespace-only API keys are treated as empty
|
|
295
|
+
const res = await app.request("/api/linear/connections", {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { "Content-Type": "application/json" },
|
|
298
|
+
body: JSON.stringify({ name: "My Connection", apiKey: " " }),
|
|
299
|
+
});
|
|
300
|
+
expect(res.status).toBe(400);
|
|
301
|
+
const json = await res.json();
|
|
302
|
+
expect(json.error).toBe("apiKey is required");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("creates a connection and verifies the API key successfully (201)", async () => {
|
|
306
|
+
// Validates the happy path: API key is verified, connection is created with workspace info
|
|
307
|
+
const fetchMock = mockFetch();
|
|
308
|
+
fetchMock.mockResolvedValue(
|
|
309
|
+
linearVerifyOk(
|
|
310
|
+
{ id: "user-1", name: "Test User", email: "test@example.com" },
|
|
311
|
+
{ id: "org-1", name: "TestOrg" },
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const createdConn = makeConnection({
|
|
316
|
+
id: "new-conn-id",
|
|
317
|
+
name: "New Workspace",
|
|
318
|
+
apiKey: "lin_api_newkey1234",
|
|
319
|
+
connected: false,
|
|
320
|
+
});
|
|
321
|
+
mockCreateConnection.mockReturnValue(createdConn);
|
|
322
|
+
|
|
323
|
+
// After updateConnection is called with verified info, getConnection returns the updated version
|
|
324
|
+
const updatedConn = makeConnection({
|
|
325
|
+
id: "new-conn-id",
|
|
326
|
+
name: "New Workspace",
|
|
327
|
+
apiKey: "lin_api_newkey1234",
|
|
328
|
+
connected: true,
|
|
329
|
+
workspaceName: "TestOrg",
|
|
330
|
+
workspaceId: "org-1",
|
|
331
|
+
viewerName: "Test User",
|
|
332
|
+
viewerEmail: "test@example.com",
|
|
333
|
+
});
|
|
334
|
+
mockGetConnection.mockReturnValue(updatedConn);
|
|
335
|
+
|
|
336
|
+
const res = await app.request("/api/linear/connections", {
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: { "Content-Type": "application/json" },
|
|
339
|
+
body: JSON.stringify({ name: "New Workspace", apiKey: "lin_api_newkey1234" }),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(res.status).toBe(201);
|
|
343
|
+
const json = await res.json();
|
|
344
|
+
expect(json.verified).toBe(true);
|
|
345
|
+
expect(json.error).toBeUndefined();
|
|
346
|
+
expect(json.connection.id).toBe("new-conn-id");
|
|
347
|
+
expect(json.connection.name).toBe("New Workspace");
|
|
348
|
+
expect(json.connection.apiKeyLast4).toBe("****1234");
|
|
349
|
+
expect(json.connection.workspaceName).toBe("TestOrg");
|
|
350
|
+
expect(json.connection.connected).toBe(true);
|
|
351
|
+
|
|
352
|
+
// Verify that updateConnection was called with workspace info
|
|
353
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith("new-conn-id", {
|
|
354
|
+
connected: true,
|
|
355
|
+
workspaceName: "TestOrg",
|
|
356
|
+
workspaceId: "org-1",
|
|
357
|
+
viewerName: "Test User",
|
|
358
|
+
viewerEmail: "test@example.com",
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("does not create a connection when verification fails (422)", async () => {
|
|
363
|
+
// Validates that no connection is persisted when the API key verification fails
|
|
364
|
+
const fetchMock = mockFetch();
|
|
365
|
+
fetchMock.mockResolvedValue(linearError("Authentication failed"));
|
|
366
|
+
|
|
367
|
+
const res = await app.request("/api/linear/connections", {
|
|
368
|
+
method: "POST",
|
|
369
|
+
headers: { "Content-Type": "application/json" },
|
|
370
|
+
body: JSON.stringify({ name: "Bad Key Workspace", apiKey: "lin_api_badkey1234" }),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(res.status).toBe(422);
|
|
374
|
+
const json = await res.json();
|
|
375
|
+
expect(json.verified).toBe(false);
|
|
376
|
+
expect(json.error).toBe("Authentication failed");
|
|
377
|
+
expect(json.connection).toBeNull();
|
|
378
|
+
|
|
379
|
+
// createConnection should NOT have been called because verification failed
|
|
380
|
+
expect(mockCreateConnection).not.toHaveBeenCalled();
|
|
381
|
+
expect(mockUpdateConnection).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("does not create a connection on non-ok HTTP response (422)", async () => {
|
|
385
|
+
// Validates that a non-200 HTTP response from Linear results in no connection being created
|
|
386
|
+
const fetchMock = mockFetch();
|
|
387
|
+
fetchMock.mockResolvedValue(linearHttpError("Unauthorized", 401));
|
|
388
|
+
|
|
389
|
+
const res = await app.request("/api/linear/connections", {
|
|
390
|
+
method: "POST",
|
|
391
|
+
headers: { "Content-Type": "application/json" },
|
|
392
|
+
body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(res.status).toBe(422);
|
|
396
|
+
const json = await res.json();
|
|
397
|
+
expect(json.verified).toBe(false);
|
|
398
|
+
expect(json.error).toBe("Unauthorized");
|
|
399
|
+
expect(json.connection).toBeNull();
|
|
400
|
+
expect(mockCreateConnection).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("does not create a connection on network error (422)", async () => {
|
|
404
|
+
// Validates that a network error (fetch throws) results in no connection being created
|
|
405
|
+
const fetchMock = mockFetch();
|
|
406
|
+
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
407
|
+
|
|
408
|
+
const res = await app.request("/api/linear/connections", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(res.status).toBe(422);
|
|
415
|
+
const json = await res.json();
|
|
416
|
+
expect(json.verified).toBe(false);
|
|
417
|
+
expect(json.error).toBe("ECONNREFUSED");
|
|
418
|
+
expect(json.connection).toBeNull();
|
|
419
|
+
expect(mockCreateConnection).not.toHaveBeenCalled();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("handles malformed JSON body gracefully", async () => {
|
|
423
|
+
// Validates that the route handles non-JSON body without crashing
|
|
424
|
+
const res = await app.request("/api/linear/connections", {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: { "Content-Type": "application/json" },
|
|
427
|
+
body: "not-valid-json",
|
|
428
|
+
});
|
|
429
|
+
// Body parse fails silently (returns {}), so name is missing
|
|
430
|
+
expect(res.status).toBe(400);
|
|
431
|
+
const json = await res.json();
|
|
432
|
+
expect(json.error).toBe("name is required");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("does not create a connection on non-Error throw during verification", async () => {
|
|
436
|
+
// Validates the catch block handles non-Error thrown values without creating a connection
|
|
437
|
+
const fetchMock = mockFetch();
|
|
438
|
+
fetchMock.mockRejectedValue("string error");
|
|
439
|
+
|
|
440
|
+
const res = await app.request("/api/linear/connections", {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { "Content-Type": "application/json" },
|
|
443
|
+
body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(res.status).toBe(422);
|
|
447
|
+
const json = await res.json();
|
|
448
|
+
expect(json.verified).toBe(false);
|
|
449
|
+
expect(json.connection).toBeNull();
|
|
450
|
+
expect(json.error).toBe("Verification failed");
|
|
451
|
+
expect(mockCreateConnection).not.toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("handles verification response with null viewer/organization fields", async () => {
|
|
455
|
+
// Validates that null viewer and organization fields default to empty strings
|
|
456
|
+
const fetchMock = mockFetch();
|
|
457
|
+
fetchMock.mockResolvedValue(
|
|
458
|
+
new Response(
|
|
459
|
+
JSON.stringify({
|
|
460
|
+
data: {
|
|
461
|
+
viewer: null,
|
|
462
|
+
organization: null,
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const createdConn = makeConnection({ id: "new-conn-id", connected: false });
|
|
470
|
+
mockCreateConnection.mockReturnValue(createdConn);
|
|
471
|
+
|
|
472
|
+
const updatedConn = makeConnection({ id: "new-conn-id", connected: true });
|
|
473
|
+
mockGetConnection.mockReturnValue(updatedConn);
|
|
474
|
+
|
|
475
|
+
const res = await app.request("/api/linear/connections", {
|
|
476
|
+
method: "POST",
|
|
477
|
+
headers: { "Content-Type": "application/json" },
|
|
478
|
+
body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
expect(res.status).toBe(201);
|
|
482
|
+
const json = await res.json();
|
|
483
|
+
expect(json.verified).toBe(true);
|
|
484
|
+
|
|
485
|
+
// Should update with empty strings for null fields
|
|
486
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith("new-conn-id", {
|
|
487
|
+
connected: true,
|
|
488
|
+
workspaceName: "",
|
|
489
|
+
workspaceId: "",
|
|
490
|
+
viewerName: "",
|
|
491
|
+
viewerEmail: "",
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("handles verification response where json parsing fails (returns {})", async () => {
|
|
496
|
+
// Validates that if response.json() fails (e.g., invalid JSON body from Linear),
|
|
497
|
+
// the route handles it gracefully by treating it as a verification failure
|
|
498
|
+
const fetchMock = mockFetch();
|
|
499
|
+
fetchMock.mockResolvedValue(
|
|
500
|
+
new Response("not json at all", {
|
|
501
|
+
status: 200,
|
|
502
|
+
headers: { "Content-Type": "application/json" },
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const createdConn = makeConnection({ id: "new-conn-id", connected: false });
|
|
507
|
+
mockCreateConnection.mockReturnValue(createdConn);
|
|
508
|
+
|
|
509
|
+
const updatedConn = makeConnection({ id: "new-conn-id", connected: true });
|
|
510
|
+
mockGetConnection.mockReturnValue(updatedConn);
|
|
511
|
+
|
|
512
|
+
const res = await app.request("/api/linear/connections", {
|
|
513
|
+
method: "POST",
|
|
514
|
+
headers: { "Content-Type": "application/json" },
|
|
515
|
+
body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
expect(res.status).toBe(201);
|
|
519
|
+
const json = await res.json();
|
|
520
|
+
// response.ok is true, but json parses to {} so no errors array
|
|
521
|
+
// This means verification succeeds with empty strings for fields
|
|
522
|
+
expect(json.verified).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// =============================================================================
|
|
527
|
+
// PUT /api/linear/connections/:id
|
|
528
|
+
// =============================================================================
|
|
529
|
+
|
|
530
|
+
describe("PUT /api/linear/connections/:id", () => {
|
|
531
|
+
it("returns 404 when connection is not found", async () => {
|
|
532
|
+
// Validates that updating a nonexistent connection returns 404
|
|
533
|
+
mockGetConnection.mockReturnValue(null);
|
|
534
|
+
|
|
535
|
+
const res = await app.request("/api/linear/connections/nonexistent-id", {
|
|
536
|
+
method: "PUT",
|
|
537
|
+
headers: { "Content-Type": "application/json" },
|
|
538
|
+
body: JSON.stringify({ name: "Updated" }),
|
|
539
|
+
});
|
|
540
|
+
expect(res.status).toBe(404);
|
|
541
|
+
const json = await res.json();
|
|
542
|
+
expect(json.error).toBe("Connection not found");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("updates connection name successfully", async () => {
|
|
546
|
+
// Validates that updating just the name field works and returns the updated connection
|
|
547
|
+
const existing = makeConnection({ id: "conn-1" });
|
|
548
|
+
mockGetConnection.mockReturnValue(existing);
|
|
549
|
+
|
|
550
|
+
const updated = makeConnection({ id: "conn-1", name: "Updated Name" });
|
|
551
|
+
mockUpdateConnection.mockReturnValue(updated);
|
|
552
|
+
|
|
553
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
554
|
+
method: "PUT",
|
|
555
|
+
headers: { "Content-Type": "application/json" },
|
|
556
|
+
body: JSON.stringify({ name: "Updated Name" }),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
expect(res.status).toBe(200);
|
|
560
|
+
const json = await res.json();
|
|
561
|
+
expect(json.connection.name).toBe("Updated Name");
|
|
562
|
+
expect(json.connection.apiKeyLast4).toBe("****1234");
|
|
563
|
+
|
|
564
|
+
// Cache should be invalidated for this connection
|
|
565
|
+
expect(vi.mocked(linearCache.invalidate)).toHaveBeenCalledWith("conn-1:");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("updates apiKey and sets connected to false", async () => {
|
|
569
|
+
// Validates that changing the API key marks the connection as needing re-verification
|
|
570
|
+
const existing = makeConnection({ id: "conn-1", connected: true });
|
|
571
|
+
mockGetConnection.mockReturnValue(existing);
|
|
572
|
+
|
|
573
|
+
const updated = makeConnection({
|
|
574
|
+
id: "conn-1",
|
|
575
|
+
apiKey: "lin_api_newkey5678",
|
|
576
|
+
connected: false,
|
|
577
|
+
});
|
|
578
|
+
mockUpdateConnection.mockReturnValue(updated);
|
|
579
|
+
|
|
580
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
581
|
+
method: "PUT",
|
|
582
|
+
headers: { "Content-Type": "application/json" },
|
|
583
|
+
body: JSON.stringify({ apiKey: "lin_api_newkey5678" }),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(res.status).toBe(200);
|
|
587
|
+
const json = await res.json();
|
|
588
|
+
expect(json.connection.connected).toBe(false);
|
|
589
|
+
expect(json.connection.apiKeyLast4).toBe("****5678");
|
|
590
|
+
|
|
591
|
+
// Verify the patch includes connected: false when apiKey changes
|
|
592
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith(
|
|
593
|
+
"conn-1",
|
|
594
|
+
expect.objectContaining({
|
|
595
|
+
apiKey: "lin_api_newkey5678",
|
|
596
|
+
connected: false,
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("ignores empty apiKey (whitespace only)", async () => {
|
|
602
|
+
// Validates that an empty/whitespace apiKey is not included in the patch
|
|
603
|
+
const existing = makeConnection({ id: "conn-1" });
|
|
604
|
+
mockGetConnection.mockReturnValue(existing);
|
|
605
|
+
|
|
606
|
+
const updated = makeConnection({ id: "conn-1", name: "Same" });
|
|
607
|
+
mockUpdateConnection.mockReturnValue(updated);
|
|
608
|
+
|
|
609
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
610
|
+
method: "PUT",
|
|
611
|
+
headers: { "Content-Type": "application/json" },
|
|
612
|
+
body: JSON.stringify({ name: "Same", apiKey: " " }),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
expect(res.status).toBe(200);
|
|
616
|
+
// The patch should contain name but NOT apiKey since it was whitespace
|
|
617
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith(
|
|
618
|
+
"conn-1",
|
|
619
|
+
expect.not.objectContaining({ apiKey: expect.anything() }),
|
|
620
|
+
);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("updates autoTransition and archiveTransition boolean fields", async () => {
|
|
624
|
+
// Validates that boolean fields like autoTransition and archiveTransition are accepted
|
|
625
|
+
const existing = makeConnection({ id: "conn-1" });
|
|
626
|
+
mockGetConnection.mockReturnValue(existing);
|
|
627
|
+
|
|
628
|
+
const updated = makeConnection({
|
|
629
|
+
id: "conn-1",
|
|
630
|
+
autoTransition: true,
|
|
631
|
+
autoTransitionStateId: "state-1",
|
|
632
|
+
autoTransitionStateName: "In Progress",
|
|
633
|
+
archiveTransition: true,
|
|
634
|
+
archiveTransitionStateId: "state-2",
|
|
635
|
+
archiveTransitionStateName: "Done",
|
|
636
|
+
});
|
|
637
|
+
mockUpdateConnection.mockReturnValue(updated);
|
|
638
|
+
|
|
639
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
640
|
+
method: "PUT",
|
|
641
|
+
headers: { "Content-Type": "application/json" },
|
|
642
|
+
body: JSON.stringify({
|
|
643
|
+
autoTransition: true,
|
|
644
|
+
autoTransitionStateId: "state-1",
|
|
645
|
+
autoTransitionStateName: "In Progress",
|
|
646
|
+
archiveTransition: true,
|
|
647
|
+
archiveTransitionStateId: "state-2",
|
|
648
|
+
archiveTransitionStateName: "Done",
|
|
649
|
+
}),
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
expect(res.status).toBe(200);
|
|
653
|
+
const json = await res.json();
|
|
654
|
+
expect(json.connection.autoTransition).toBe(true);
|
|
655
|
+
expect(json.connection.autoTransitionStateName).toBe("In Progress");
|
|
656
|
+
expect(json.connection.archiveTransition).toBe(true);
|
|
657
|
+
expect(json.connection.archiveTransitionStateName).toBe("Done");
|
|
658
|
+
|
|
659
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith(
|
|
660
|
+
"conn-1",
|
|
661
|
+
expect.objectContaining({
|
|
662
|
+
autoTransition: true,
|
|
663
|
+
autoTransitionStateId: "state-1",
|
|
664
|
+
autoTransitionStateName: "In Progress",
|
|
665
|
+
archiveTransition: true,
|
|
666
|
+
archiveTransitionStateId: "state-2",
|
|
667
|
+
archiveTransitionStateName: "Done",
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("returns 500 when updateConnection returns null (update failed)", async () => {
|
|
673
|
+
// Validates the error path when updateConnection returns null (unexpected failure)
|
|
674
|
+
const existing = makeConnection({ id: "conn-1" });
|
|
675
|
+
mockGetConnection.mockReturnValue(existing);
|
|
676
|
+
mockUpdateConnection.mockReturnValue(null);
|
|
677
|
+
|
|
678
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
679
|
+
method: "PUT",
|
|
680
|
+
headers: { "Content-Type": "application/json" },
|
|
681
|
+
body: JSON.stringify({ name: "Updated" }),
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
expect(res.status).toBe(500);
|
|
685
|
+
const json = await res.json();
|
|
686
|
+
expect(json.error).toBe("Update failed");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("handles malformed JSON body gracefully", async () => {
|
|
690
|
+
// Validates that the route handles non-JSON body without crashing
|
|
691
|
+
const existing = makeConnection({ id: "conn-1" });
|
|
692
|
+
mockGetConnection.mockReturnValue(existing);
|
|
693
|
+
|
|
694
|
+
// Empty patch should still work (no fields to update)
|
|
695
|
+
const updated = makeConnection({ id: "conn-1" });
|
|
696
|
+
mockUpdateConnection.mockReturnValue(updated);
|
|
697
|
+
|
|
698
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
699
|
+
method: "PUT",
|
|
700
|
+
headers: { "Content-Type": "application/json" },
|
|
701
|
+
body: "not-valid-json",
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// Body parse fails silently to {}, so an empty patch is applied
|
|
705
|
+
expect(res.status).toBe(200);
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// =============================================================================
|
|
710
|
+
// DELETE /api/linear/connections/:id
|
|
711
|
+
// =============================================================================
|
|
712
|
+
|
|
713
|
+
describe("DELETE /api/linear/connections/:id", () => {
|
|
714
|
+
it("returns 404 when connection is not found", async () => {
|
|
715
|
+
// Validates that deleting a nonexistent connection returns 404
|
|
716
|
+
mockDeleteConnection.mockReturnValue(false);
|
|
717
|
+
|
|
718
|
+
const res = await app.request("/api/linear/connections/nonexistent-id", {
|
|
719
|
+
method: "DELETE",
|
|
720
|
+
});
|
|
721
|
+
expect(res.status).toBe(404);
|
|
722
|
+
const json = await res.json();
|
|
723
|
+
expect(json.error).toBe("Connection not found");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("deletes a connection successfully and invalidates cache", async () => {
|
|
727
|
+
// Validates the happy path: connection is deleted and cache is invalidated
|
|
728
|
+
mockDeleteConnection.mockReturnValue(true);
|
|
729
|
+
|
|
730
|
+
const res = await app.request("/api/linear/connections/conn-1", {
|
|
731
|
+
method: "DELETE",
|
|
732
|
+
});
|
|
733
|
+
expect(res.status).toBe(200);
|
|
734
|
+
const json = await res.json();
|
|
735
|
+
expect(json.ok).toBe(true);
|
|
736
|
+
|
|
737
|
+
// deleteConnection should have been called with the connection ID
|
|
738
|
+
expect(mockDeleteConnection).toHaveBeenCalledWith("conn-1");
|
|
739
|
+
|
|
740
|
+
// Cache should be invalidated for this connection's prefix
|
|
741
|
+
expect(vi.mocked(linearCache.invalidate)).toHaveBeenCalledWith("conn-1:");
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// =============================================================================
|
|
746
|
+
// POST /api/linear/connections/:id/verify
|
|
747
|
+
// =============================================================================
|
|
748
|
+
|
|
749
|
+
describe("POST /api/linear/connections/:id/verify", () => {
|
|
750
|
+
it("returns 404 when connection is not found", async () => {
|
|
751
|
+
// Validates that verifying a nonexistent connection returns 404
|
|
752
|
+
mockGetConnection.mockReturnValue(null);
|
|
753
|
+
|
|
754
|
+
const res = await app.request("/api/linear/connections/nonexistent-id/verify", {
|
|
755
|
+
method: "POST",
|
|
756
|
+
});
|
|
757
|
+
expect(res.status).toBe(404);
|
|
758
|
+
const json = await res.json();
|
|
759
|
+
expect(json.error).toBe("Connection not found");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("re-verifies a connection successfully", async () => {
|
|
763
|
+
// Validates the happy path: the connection's stored API key is used for verification
|
|
764
|
+
// and the connection is updated with workspace info
|
|
765
|
+
const conn = makeConnection({
|
|
766
|
+
id: "conn-1",
|
|
767
|
+
apiKey: "lin_api_existingkey1234",
|
|
768
|
+
connected: false,
|
|
769
|
+
workspaceName: "",
|
|
770
|
+
viewerName: "",
|
|
771
|
+
});
|
|
772
|
+
mockGetConnection
|
|
773
|
+
.mockReturnValueOnce(conn) // first call: find existing connection
|
|
774
|
+
.mockReturnValueOnce(
|
|
775
|
+
makeConnection({
|
|
776
|
+
id: "conn-1",
|
|
777
|
+
apiKey: "lin_api_existingkey1234",
|
|
778
|
+
connected: true,
|
|
779
|
+
workspaceName: "Verified Org",
|
|
780
|
+
workspaceId: "org-v",
|
|
781
|
+
viewerName: "Verified User",
|
|
782
|
+
viewerEmail: "verified@example.com",
|
|
783
|
+
}),
|
|
784
|
+
); // second call: return updated connection after updateConnection
|
|
785
|
+
|
|
786
|
+
const fetchMock = mockFetch();
|
|
787
|
+
fetchMock.mockResolvedValue(
|
|
788
|
+
linearVerifyOk(
|
|
789
|
+
{ id: "user-v", name: "Verified User", email: "verified@example.com" },
|
|
790
|
+
{ id: "org-v", name: "Verified Org" },
|
|
791
|
+
),
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const res = await app.request("/api/linear/connections/conn-1/verify", {
|
|
795
|
+
method: "POST",
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
expect(res.status).toBe(200);
|
|
799
|
+
const json = await res.json();
|
|
800
|
+
expect(json.verified).toBe(true);
|
|
801
|
+
expect(json.error).toBeUndefined();
|
|
802
|
+
expect(json.connection.id).toBe("conn-1");
|
|
803
|
+
expect(json.connection.connected).toBe(true);
|
|
804
|
+
expect(json.connection.workspaceName).toBe("Verified Org");
|
|
805
|
+
expect(json.connection.viewerName).toBe("Verified User");
|
|
806
|
+
expect(json.connection.viewerEmail).toBe("verified@example.com");
|
|
807
|
+
expect(json.connection.apiKeyLast4).toBe("****1234");
|
|
808
|
+
|
|
809
|
+
// updateConnection should be called with connected: true and workspace info
|
|
810
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith("conn-1", {
|
|
811
|
+
connected: true,
|
|
812
|
+
workspaceName: "Verified Org",
|
|
813
|
+
workspaceId: "org-v",
|
|
814
|
+
viewerName: "Verified User",
|
|
815
|
+
viewerEmail: "verified@example.com",
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("marks connection as disconnected when verification fails", async () => {
|
|
820
|
+
// Validates that a failed verification sets connected to false but preserves
|
|
821
|
+
// existing workspace info
|
|
822
|
+
const existingConn = makeConnection({
|
|
823
|
+
id: "conn-1",
|
|
824
|
+
apiKey: "lin_api_badkey1234",
|
|
825
|
+
connected: true,
|
|
826
|
+
workspaceName: "OldOrg",
|
|
827
|
+
workspaceId: "org-old",
|
|
828
|
+
viewerName: "OldUser",
|
|
829
|
+
viewerEmail: "old@example.com",
|
|
830
|
+
});
|
|
831
|
+
mockGetConnection
|
|
832
|
+
.mockReturnValueOnce(existingConn) // first call
|
|
833
|
+
.mockReturnValueOnce(
|
|
834
|
+
makeConnection({
|
|
835
|
+
...existingConn,
|
|
836
|
+
connected: false,
|
|
837
|
+
}),
|
|
838
|
+
); // second call after updateConnection
|
|
839
|
+
|
|
840
|
+
const fetchMock = mockFetch();
|
|
841
|
+
fetchMock.mockResolvedValue(linearError("Invalid token"));
|
|
842
|
+
|
|
843
|
+
const res = await app.request("/api/linear/connections/conn-1/verify", {
|
|
844
|
+
method: "POST",
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
expect(res.status).toBe(200);
|
|
848
|
+
const json = await res.json();
|
|
849
|
+
expect(json.verified).toBe(false);
|
|
850
|
+
expect(json.error).toBe("Invalid token");
|
|
851
|
+
expect(json.connection.connected).toBe(false);
|
|
852
|
+
|
|
853
|
+
// When verification fails, updateConnection should preserve existing workspace info
|
|
854
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith("conn-1", {
|
|
855
|
+
connected: false,
|
|
856
|
+
workspaceName: "OldOrg",
|
|
857
|
+
workspaceId: "org-old",
|
|
858
|
+
viewerName: "OldUser",
|
|
859
|
+
viewerEmail: "old@example.com",
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("handles network error during verification", async () => {
|
|
864
|
+
// Validates that a network error during verification (fetch throws)
|
|
865
|
+
// results in the connection being marked as disconnected
|
|
866
|
+
const existingConn = makeConnection({
|
|
867
|
+
id: "conn-1",
|
|
868
|
+
apiKey: "lin_api_key12345678",
|
|
869
|
+
connected: true,
|
|
870
|
+
workspaceName: "PrevOrg",
|
|
871
|
+
workspaceId: "org-prev",
|
|
872
|
+
viewerName: "PrevUser",
|
|
873
|
+
viewerEmail: "prev@example.com",
|
|
874
|
+
});
|
|
875
|
+
mockGetConnection
|
|
876
|
+
.mockReturnValueOnce(existingConn)
|
|
877
|
+
.mockReturnValueOnce(makeConnection({ ...existingConn, connected: false }));
|
|
878
|
+
|
|
879
|
+
const fetchMock = mockFetch();
|
|
880
|
+
fetchMock.mockRejectedValue(new Error("DNS resolution failed"));
|
|
881
|
+
|
|
882
|
+
const res = await app.request("/api/linear/connections/conn-1/verify", {
|
|
883
|
+
method: "POST",
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
expect(res.status).toBe(200);
|
|
887
|
+
const json = await res.json();
|
|
888
|
+
expect(json.verified).toBe(false);
|
|
889
|
+
expect(json.error).toBe("DNS resolution failed");
|
|
890
|
+
|
|
891
|
+
// Should mark as disconnected but keep existing workspace info
|
|
892
|
+
expect(mockUpdateConnection).toHaveBeenCalledWith("conn-1", {
|
|
893
|
+
connected: false,
|
|
894
|
+
workspaceName: "PrevOrg",
|
|
895
|
+
workspaceId: "org-prev",
|
|
896
|
+
viewerName: "PrevUser",
|
|
897
|
+
viewerEmail: "prev@example.com",
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it("handles HTTP error response during verification", async () => {
|
|
902
|
+
// Validates that a non-ok HTTP status from Linear results in failed verification
|
|
903
|
+
const existingConn = makeConnection({
|
|
904
|
+
id: "conn-1",
|
|
905
|
+
apiKey: "lin_api_key12345678",
|
|
906
|
+
workspaceName: "Existing",
|
|
907
|
+
workspaceId: "org-e",
|
|
908
|
+
viewerName: "ExUser",
|
|
909
|
+
viewerEmail: "ex@example.com",
|
|
910
|
+
});
|
|
911
|
+
mockGetConnection
|
|
912
|
+
.mockReturnValueOnce(existingConn)
|
|
913
|
+
.mockReturnValueOnce(makeConnection({ ...existingConn, connected: false }));
|
|
914
|
+
|
|
915
|
+
const fetchMock = mockFetch();
|
|
916
|
+
fetchMock.mockResolvedValue(linearHttpError("Service Unavailable", 503));
|
|
917
|
+
|
|
918
|
+
const res = await app.request("/api/linear/connections/conn-1/verify", {
|
|
919
|
+
method: "POST",
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
expect(res.status).toBe(200);
|
|
923
|
+
const json = await res.json();
|
|
924
|
+
expect(json.verified).toBe(false);
|
|
925
|
+
expect(json.error).toBe("Service Unavailable");
|
|
926
|
+
});
|
|
927
|
+
});
|