@opsee/mcp-server 0.9.33 → 0.9.35
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/README.md +1 -1
- package/gen/api/v1/doc_space_pb.d.ts +11 -0
- package/gen/api/v1/doc_space_pb.js +1 -1
- package/package.json +2 -1
- package/src/tools/docs.ts +28 -27
- package/src/tools/initiatives.ts +2 -1
- package/src/tools/milestones.ts +5 -3
- package/src/utils/format/blocknote.ts +21 -0
- package/src/utils/format/index.ts +1 -1
- package/src/__tests__/blocknote-fixtures.test.ts +0 -58
- package/src/__tests__/blocknote-markdown.test.ts +0 -92
- package/src/__tests__/comments.test.ts +0 -193
- package/src/__tests__/initiatives.test.ts +0 -188
- package/src/__tests__/kv-store.test.ts +0 -53
- package/src/__tests__/task-dependencies.test.ts +0 -173
- package/src/__tests__/tools.test.ts +0 -913
- package/src/__tests__/work-logs.test.ts +0 -140
- package/src/auth/__tests__/manager.test.ts +0 -67
- package/src/auth/__tests__/oauth-provider.test.ts +0 -272
- package/src/utils/__tests__/format.test.ts +0 -53
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
-
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
-
import { createServer } from "../server.js";
|
|
5
|
-
import type { ApiClients } from "../client/api.js";
|
|
6
|
-
|
|
7
|
-
const mockTaskWithLogs = {
|
|
8
|
-
id: 10,
|
|
9
|
-
identifier: "TP-1",
|
|
10
|
-
title: "Fix the bug",
|
|
11
|
-
estimatedHours: 5,
|
|
12
|
-
workLogs: [
|
|
13
|
-
{ id: 1, userId: 5, user: { fullName: "Alice" }, hours: 2, description: "implemented foo" },
|
|
14
|
-
{ id: 2, userId: 6, hours: 1.5 },
|
|
15
|
-
],
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
function createMockClients(overrides: Partial<ApiClients["tasks"]> = {}): ApiClients {
|
|
19
|
-
return {
|
|
20
|
-
tasks: {
|
|
21
|
-
getTask: async () => ({ task: mockTaskWithLogs }),
|
|
22
|
-
addWorkLog: async (req: { taskId: number; hours: number }) => ({
|
|
23
|
-
workLog: { id: 42, taskId: req.taskId, userId: 5, hours: req.hours },
|
|
24
|
-
}),
|
|
25
|
-
deleteWorkLog: async () => ({}),
|
|
26
|
-
...overrides,
|
|
27
|
-
},
|
|
28
|
-
} as unknown as ApiClients;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function connect(clients: ApiClients) {
|
|
32
|
-
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
33
|
-
const server = createServer(() => clients);
|
|
34
|
-
await server.connect(st);
|
|
35
|
-
const client = new Client({ name: "work-logs-test", version: "1.0.0" });
|
|
36
|
-
await client.connect(ct);
|
|
37
|
-
return { client, cleanup: async () => { await client.close(); await server.close(); } };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("Work log tools", () => {
|
|
41
|
-
let client: Client;
|
|
42
|
-
let cleanup: () => Promise<void>;
|
|
43
|
-
|
|
44
|
-
beforeAll(async () => {
|
|
45
|
-
const conn = await connect(createMockClients());
|
|
46
|
-
client = conn.client;
|
|
47
|
-
cleanup = conn.cleanup;
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterAll(async () => {
|
|
51
|
-
await cleanup();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("opsee_list_work_logs formats entries, users, and the running total", async () => {
|
|
55
|
-
const result = await client.callTool({
|
|
56
|
-
name: "opsee_list_work_logs",
|
|
57
|
-
arguments: { taskId: 10 },
|
|
58
|
-
});
|
|
59
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
60
|
-
expect(text).toContain("Work logs for TP-1 (total 3.5h)");
|
|
61
|
-
expect(text).toContain("Alice @");
|
|
62
|
-
expect(text).toContain("2h — implemented foo (ID: 1)");
|
|
63
|
-
expect(text).toContain("User #6 @");
|
|
64
|
-
expect(text).toContain("1.5h (ID: 2)");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("opsee_list_work_logs reports task not found", async () => {
|
|
68
|
-
const { client: c, cleanup: cl } = await connect(createMockClients({ getTask: async () => ({}) } as any));
|
|
69
|
-
const result = await c.callTool({
|
|
70
|
-
name: "opsee_list_work_logs",
|
|
71
|
-
arguments: { taskId: 999 },
|
|
72
|
-
});
|
|
73
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
74
|
-
expect(text).toContain("Task not found.");
|
|
75
|
-
await cl();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("opsee_log_work logs hours against a task", async () => {
|
|
79
|
-
const result = await client.callTool({
|
|
80
|
-
name: "opsee_log_work",
|
|
81
|
-
arguments: { taskId: 10, hours: 2.5, description: "implemented foo" },
|
|
82
|
-
});
|
|
83
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
84
|
-
expect(text).toContain("Logged 2.5h on task 10 (work_log ID 42).");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("opsee_log_work reports failure when the backend returns no work log", async () => {
|
|
88
|
-
const { client: c, cleanup: cl } = await connect(createMockClients({ addWorkLog: async () => ({}) } as any));
|
|
89
|
-
const result = await c.callTool({
|
|
90
|
-
name: "opsee_log_work",
|
|
91
|
-
arguments: { taskId: 10, hours: 1 },
|
|
92
|
-
});
|
|
93
|
-
expect(result.isError).toBe(true);
|
|
94
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
95
|
-
expect(text).toContain("Failed to log work");
|
|
96
|
-
await cl();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("opsee_delete_work_log succeeds", async () => {
|
|
100
|
-
const result = await client.callTool({
|
|
101
|
-
name: "opsee_delete_work_log",
|
|
102
|
-
arguments: { taskId: 10, workLogId: 1 },
|
|
103
|
-
});
|
|
104
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
105
|
-
expect(text).toContain("Work log deleted.");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("opsee_get_task_actual_hours reports over-budget delta", async () => {
|
|
109
|
-
const result = await client.callTool({
|
|
110
|
-
name: "opsee_get_task_actual_hours",
|
|
111
|
-
arguments: { taskId: 10 },
|
|
112
|
-
});
|
|
113
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
114
|
-
// total logged is 3.5h against an estimate of 5h, so this is under budget.
|
|
115
|
-
expect(text).toContain("TP-1: 3.5h logged (estimated 5h, under by 1.5 h).");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("opsee_get_task_actual_hours reports over-budget when logged hours exceed the estimate", async () => {
|
|
119
|
-
const overBudgetTask = { ...mockTaskWithLogs, estimatedHours: 2 };
|
|
120
|
-
const { client: c, cleanup: cl } = await connect(createMockClients({ getTask: async () => ({ task: overBudgetTask }) } as any));
|
|
121
|
-
const result = await c.callTool({
|
|
122
|
-
name: "opsee_get_task_actual_hours",
|
|
123
|
-
arguments: { taskId: 10 },
|
|
124
|
-
});
|
|
125
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
126
|
-
expect(text).toContain("TP-1: 3.5h logged (estimated 2h, over by 1.5 h).");
|
|
127
|
-
await cl();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("opsee_get_task_actual_hours reports task not found", async () => {
|
|
131
|
-
const { client: c, cleanup: cl } = await connect(createMockClients({ getTask: async () => ({}) } as any));
|
|
132
|
-
const result = await c.callTool({
|
|
133
|
-
name: "opsee_get_task_actual_hours",
|
|
134
|
-
arguments: { taskId: 999 },
|
|
135
|
-
});
|
|
136
|
-
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
137
|
-
expect(text).toContain("Task not found.");
|
|
138
|
-
await cl();
|
|
139
|
-
});
|
|
140
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import type { Credentials } from "../credentials.js";
|
|
3
|
-
|
|
4
|
-
// Control what the manager reads from disk.
|
|
5
|
-
const { mockRead } = vi.hoisted(() => ({ mockRead: vi.fn() }));
|
|
6
|
-
vi.mock("../credentials.js", () => ({
|
|
7
|
-
readCredentials: mockRead,
|
|
8
|
-
clearCredentials: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
import { AuthManager } from "../manager.js";
|
|
12
|
-
|
|
13
|
-
const CANONICAL = "https://grpc.api.opsee.ai";
|
|
14
|
-
const LEGACY_STALE = "https://api.opsee.dev";
|
|
15
|
-
|
|
16
|
-
function creds(serverUrl: string): Credentials {
|
|
17
|
-
return {
|
|
18
|
-
token: "jwt.token.here",
|
|
19
|
-
serverUrl,
|
|
20
|
-
userId: "u",
|
|
21
|
-
companyId: "c",
|
|
22
|
-
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Build a manager whose credentials are populated from `stored` (null = none).
|
|
27
|
-
function managerWith(stored: Credentials | null): AuthManager {
|
|
28
|
-
mockRead.mockReturnValue(stored);
|
|
29
|
-
const m = new AuthManager();
|
|
30
|
-
m.getToken(); // populates the private credentials field, as in real startup
|
|
31
|
-
return m;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("AuthManager.getServerUrl", () => {
|
|
35
|
-
const OLD_ENV = process.env.OPSEE_API_URL;
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
mockRead.mockReset();
|
|
38
|
-
delete process.env.OPSEE_API_URL;
|
|
39
|
-
});
|
|
40
|
-
afterEach(() => {
|
|
41
|
-
if (OLD_ENV === undefined) delete process.env.OPSEE_API_URL;
|
|
42
|
-
else process.env.OPSEE_API_URL = OLD_ENV;
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("returns the canonical default when nothing is stored", () => {
|
|
46
|
-
expect(managerWith(null).getServerUrl()).toBe(CANONICAL);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("returns a stored, non-stale serverUrl unchanged", () => {
|
|
50
|
-
expect(managerWith(creds("https://custom.example.com")).getServerUrl()).toBe(
|
|
51
|
-
"https://custom.example.com",
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("treats the legacy stale host as unset and falls back to the canonical default", () => {
|
|
56
|
-
// The whole point of the migration: existing users with the dead host in
|
|
57
|
-
// ~/.opsee/credentials.json must NOT be pinned to it, without re-login.
|
|
58
|
-
expect(managerWith(creds(LEGACY_STALE)).getServerUrl()).toBe(CANONICAL);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("OPSEE_API_URL env var overrides both stored and default", () => {
|
|
62
|
-
process.env.OPSEE_API_URL = "https://env.example.com";
|
|
63
|
-
expect(managerWith(creds("https://custom.example.com")).getServerUrl()).toBe(
|
|
64
|
-
"https://env.example.com",
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import type { Response } from "express";
|
|
3
|
-
import { InMemoryKVStore } from "../kv-store.js";
|
|
4
|
-
import { OpseeClientStore, OpseeOAuthProvider } from "../oauth-provider.js";
|
|
5
|
-
import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
6
|
-
|
|
7
|
-
const SERVER_URL = "https://mcp.example.com";
|
|
8
|
-
const REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
|
|
9
|
-
|
|
10
|
-
function makeClient(): Omit<
|
|
11
|
-
OAuthClientInformationFull,
|
|
12
|
-
"client_id" | "client_id_issued_at"
|
|
13
|
-
> {
|
|
14
|
-
return {
|
|
15
|
-
redirect_uris: [REDIRECT_URI],
|
|
16
|
-
token_endpoint_auth_method: "none",
|
|
17
|
-
grant_types: ["authorization_code"],
|
|
18
|
-
response_types: ["code"],
|
|
19
|
-
} as unknown as Omit<
|
|
20
|
-
OAuthClientInformationFull,
|
|
21
|
-
"client_id" | "client_id_issued_at"
|
|
22
|
-
>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("InMemoryKVStore", () => {
|
|
26
|
-
test("stores and retrieves values", async () => {
|
|
27
|
-
const store = new InMemoryKVStore();
|
|
28
|
-
await store.set("k", "v");
|
|
29
|
-
expect(await store.get("k")).toBe("v");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("returns null for missing keys", async () => {
|
|
33
|
-
const store = new InMemoryKVStore();
|
|
34
|
-
expect(await store.get("nope")).toBeNull();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("deletes values", async () => {
|
|
38
|
-
const store = new InMemoryKVStore();
|
|
39
|
-
await store.set("k", "v");
|
|
40
|
-
await store.del("k");
|
|
41
|
-
expect(await store.get("k")).toBeNull();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("expires values after the TTL elapses", async () => {
|
|
45
|
-
vi.useFakeTimers();
|
|
46
|
-
const store = new InMemoryKVStore();
|
|
47
|
-
await store.set("k", "v", 10); // 10 second TTL
|
|
48
|
-
expect(await store.get("k")).toBe("v");
|
|
49
|
-
vi.advanceTimersByTime(11_000);
|
|
50
|
-
expect(await store.get("k")).toBeNull();
|
|
51
|
-
vi.useRealTimers();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("OpseeClientStore", () => {
|
|
56
|
-
test("registerClient persists redirect_uris and getClient round-trips them", async () => {
|
|
57
|
-
const store = new InMemoryKVStore();
|
|
58
|
-
const clientStore = new OpseeClientStore(store);
|
|
59
|
-
|
|
60
|
-
const registered = await clientStore.registerClient(makeClient());
|
|
61
|
-
expect(registered.client_id).toBeTruthy();
|
|
62
|
-
|
|
63
|
-
const fetched = await clientStore.getClient(registered.client_id);
|
|
64
|
-
expect(fetched).toBeDefined();
|
|
65
|
-
// The actual fix: the registered redirect_uri survives the round-trip, so
|
|
66
|
-
// the SDK's `redirect_uris.includes(redirect_uri)` check passes.
|
|
67
|
-
expect(fetched?.redirect_uris).toContain(REDIRECT_URI);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("public clients (auth method 'none') are not issued a client_secret", async () => {
|
|
71
|
-
const store = new InMemoryKVStore();
|
|
72
|
-
const clientStore = new OpseeClientStore(store);
|
|
73
|
-
|
|
74
|
-
// makeClient() registers with token_endpoint_auth_method "none". Issuing a
|
|
75
|
-
// secret would make the SDK's token endpoint require it and reject the
|
|
76
|
-
// PKCE-only token request with invalid_client.
|
|
77
|
-
const registered = await clientStore.registerClient(makeClient());
|
|
78
|
-
expect(registered.client_secret).toBeUndefined();
|
|
79
|
-
|
|
80
|
-
const fetched = await clientStore.getClient(registered.client_id);
|
|
81
|
-
expect(fetched?.client_secret).toBeUndefined();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("confidential clients still receive a client_secret", async () => {
|
|
85
|
-
const store = new InMemoryKVStore();
|
|
86
|
-
const clientStore = new OpseeClientStore(store);
|
|
87
|
-
|
|
88
|
-
const confidential = {
|
|
89
|
-
...makeClient(),
|
|
90
|
-
token_endpoint_auth_method: "client_secret_post",
|
|
91
|
-
} as unknown as Omit<
|
|
92
|
-
OAuthClientInformationFull,
|
|
93
|
-
"client_id" | "client_id_issued_at"
|
|
94
|
-
>;
|
|
95
|
-
|
|
96
|
-
const registered = await clientStore.registerClient(confidential);
|
|
97
|
-
expect(registered.client_secret).toBeTruthy();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("registrations persist without a TTL", async () => {
|
|
101
|
-
vi.useFakeTimers();
|
|
102
|
-
try {
|
|
103
|
-
const store = new InMemoryKVStore();
|
|
104
|
-
const clientStore = new OpseeClientStore(store);
|
|
105
|
-
|
|
106
|
-
const registered = await clientStore.registerClient(makeClient());
|
|
107
|
-
// Well past any old 30-day expiry — the registration must still resolve,
|
|
108
|
-
// otherwise long-idle clients hit invalid_client at /authorize.
|
|
109
|
-
vi.advanceTimersByTime(60 * 24 * 60 * 60 * 1000);
|
|
110
|
-
expect(await clientStore.getClient(registered.client_id)).toBeDefined();
|
|
111
|
-
} finally {
|
|
112
|
-
vi.useRealTimers();
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("getClient returns undefined for an unknown client (no empty-uri fallback)", async () => {
|
|
117
|
-
const store = new InMemoryKVStore();
|
|
118
|
-
const clientStore = new OpseeClientStore(store);
|
|
119
|
-
expect(await clientStore.getClient("never-registered")).toBeUndefined();
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe("OpseeOAuthProvider flow", () => {
|
|
124
|
-
let store: InMemoryKVStore;
|
|
125
|
-
let provider: OpseeOAuthProvider;
|
|
126
|
-
let client: OAuthClientInformationFull;
|
|
127
|
-
|
|
128
|
-
beforeEach(async () => {
|
|
129
|
-
store = new InMemoryKVStore();
|
|
130
|
-
provider = new OpseeOAuthProvider(SERVER_URL, store, "https://app.example.com");
|
|
131
|
-
client = await (provider.clientsStore as OpseeClientStore).registerClient(
|
|
132
|
-
makeClient(),
|
|
133
|
-
);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// Captures the pendingId the provider embedded in the login redirect URL.
|
|
137
|
-
async function authorizeAndGetPendingId(): Promise<string> {
|
|
138
|
-
let location = "";
|
|
139
|
-
const res = {
|
|
140
|
-
redirect: (url: string) => {
|
|
141
|
-
location = url;
|
|
142
|
-
},
|
|
143
|
-
} as unknown as Response;
|
|
144
|
-
|
|
145
|
-
await provider.authorize(
|
|
146
|
-
client,
|
|
147
|
-
{
|
|
148
|
-
redirectUri: REDIRECT_URI,
|
|
149
|
-
codeChallenge: "challenge-123",
|
|
150
|
-
state: "state-xyz",
|
|
151
|
-
},
|
|
152
|
-
res,
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
expect(location).toContain("https://app.example.com/auth/mcp");
|
|
156
|
-
const callback = new URL(
|
|
157
|
-
decodeURIComponent(location.split("callback=")[1]),
|
|
158
|
-
);
|
|
159
|
-
const pendingId = callback.searchParams.get("pending");
|
|
160
|
-
expect(pendingId).toBeTruthy();
|
|
161
|
-
return pendingId as string;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
test("authorize → callback → exchange issues the backend token", async () => {
|
|
165
|
-
const pendingId = await authorizeAndGetPendingId();
|
|
166
|
-
|
|
167
|
-
const result = await provider.handleCallback(
|
|
168
|
-
pendingId,
|
|
169
|
-
"backend.jwt.token",
|
|
170
|
-
"user-1",
|
|
171
|
-
"company-1",
|
|
172
|
-
"",
|
|
173
|
-
);
|
|
174
|
-
expect("redirectUri" in result).toBe(true);
|
|
175
|
-
const redirectUri = (result as { redirectUri: string }).redirectUri;
|
|
176
|
-
|
|
177
|
-
const url = new URL(redirectUri);
|
|
178
|
-
expect(url.searchParams.get("state")).toBe("state-xyz");
|
|
179
|
-
const code = url.searchParams.get("code");
|
|
180
|
-
expect(code).toBeTruthy();
|
|
181
|
-
|
|
182
|
-
// PKCE challenge is recoverable for the issued code.
|
|
183
|
-
expect(await provider.challengeForAuthorizationCode(client, code as string)).toBe(
|
|
184
|
-
"challenge-123",
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
const tokens = await provider.exchangeAuthorizationCode(client, code as string);
|
|
188
|
-
expect(tokens.access_token).toBe("backend.jwt.token");
|
|
189
|
-
expect(tokens.token_type).toBe("bearer");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("authorization codes are single-use", async () => {
|
|
193
|
-
const pendingId = await authorizeAndGetPendingId();
|
|
194
|
-
const result = await provider.handleCallback(pendingId, "t", "u", "c", "");
|
|
195
|
-
const code = new URL((result as { redirectUri: string }).redirectUri).searchParams.get(
|
|
196
|
-
"code",
|
|
197
|
-
) as string;
|
|
198
|
-
|
|
199
|
-
await provider.exchangeAuthorizationCode(client, code);
|
|
200
|
-
await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow(
|
|
201
|
-
/Invalid or expired authorization code/,
|
|
202
|
-
);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("a pendingId cannot be replayed", async () => {
|
|
206
|
-
const pendingId = await authorizeAndGetPendingId();
|
|
207
|
-
await provider.handleCallback(pendingId, "t", "u", "c", "");
|
|
208
|
-
const second = await provider.handleCallback(pendingId, "t", "u", "c", "");
|
|
209
|
-
expect("error" in second).toBe(true);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("revoked tokens fail verification", async () => {
|
|
213
|
-
// Build a JWT-shaped token with a future exp so only revocation can reject it.
|
|
214
|
-
const future = Math.floor(Date.now() / 1000) + 3600;
|
|
215
|
-
const payload = Buffer.from(
|
|
216
|
-
JSON.stringify({ exp: future, userId: "u", companyId: "c" }),
|
|
217
|
-
).toString("base64url");
|
|
218
|
-
const token = `header.${payload}.sig`;
|
|
219
|
-
|
|
220
|
-
await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({
|
|
221
|
-
clientId: "opsee",
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
await provider.revokeToken(client, { token });
|
|
225
|
-
await expect(provider.verifyAccessToken(token)).rejects.toThrow(/revoked/);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// --- verifyAccessToken validation edge cases (require exp) ---
|
|
229
|
-
|
|
230
|
-
// Build a JWT-shaped token (header.payload.sig) from a payload object.
|
|
231
|
-
const tokenWith = (payload: Record<string, unknown>) =>
|
|
232
|
-
`header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.sig`;
|
|
233
|
-
|
|
234
|
-
test("accepts a token with a future exp and exposes its claims", async () => {
|
|
235
|
-
const future = Math.floor(Date.now() / 1000) + 3600;
|
|
236
|
-
const token = tokenWith({ exp: future, userId: "u1", companyId: "c1", role: "admin" });
|
|
237
|
-
await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({
|
|
238
|
-
clientId: "opsee",
|
|
239
|
-
expiresAt: future,
|
|
240
|
-
extra: { userId: "u1", companyId: "c1", role: "admin" },
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
test("rejects a token with no exp claim (not valid indefinitely)", async () => {
|
|
245
|
-
const token = tokenWith({ userId: "u", companyId: "c" });
|
|
246
|
-
await expect(provider.verifyAccessToken(token)).rejects.toThrow(/missing expiry/i);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
test("rejects a token whose exp is not a number", async () => {
|
|
250
|
-
const token = tokenWith({ exp: "later", userId: "u" });
|
|
251
|
-
await expect(provider.verifyAccessToken(token)).rejects.toThrow(/missing expiry/i);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test("rejects an expired token", async () => {
|
|
255
|
-
const past = Math.floor(Date.now() / 1000) - 10;
|
|
256
|
-
const token = tokenWith({ exp: past, userId: "u" });
|
|
257
|
-
await expect(provider.verifyAccessToken(token)).rejects.toThrow(/expired/i);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("rejects a token with a non-JSON payload as Invalid token", async () => {
|
|
261
|
-
const badBody = Buffer.from("this is not json").toString("base64url");
|
|
262
|
-
await expect(provider.verifyAccessToken(`header.${badBody}.sig`)).rejects.toThrow(
|
|
263
|
-
/Invalid token/,
|
|
264
|
-
);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("rejects a token that is not three parts", async () => {
|
|
268
|
-
await expect(provider.verifyAccessToken("only.two")).rejects.toThrow(
|
|
269
|
-
/Invalid token format/,
|
|
270
|
-
);
|
|
271
|
-
});
|
|
272
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { Code, ConnectError } from "@connectrpc/connect";
|
|
3
|
-
import { formatError } from "../format.js";
|
|
4
|
-
|
|
5
|
-
describe("formatError classification", () => {
|
|
6
|
-
test("ConnectError Unauthenticated → login instruction (correct command)", () => {
|
|
7
|
-
const msg = formatError(new ConnectError("nope", Code.Unauthenticated));
|
|
8
|
-
expect(msg).toContain("npx opsee-mcp login");
|
|
9
|
-
expect(msg).toMatch(/Not authenticated/i);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("ConnectError Unavailable → reach-API message with raw detail", () => {
|
|
13
|
-
const msg = formatError(new ConnectError("backend down", Code.Unavailable));
|
|
14
|
-
expect(msg).toMatch(/Could not reach Opsee API/i);
|
|
15
|
-
expect(msg).toContain("backend down");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("other ConnectError codes pass through as [Code] rawMessage", () => {
|
|
19
|
-
const msg = formatError(new ConnectError("task missing", Code.NotFound));
|
|
20
|
-
expect(msg).toBe("[NotFound] task missing");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("does NOT misclassify a ConnectError whose text embeds 401/ECONNREFUSED", () => {
|
|
24
|
-
// The point of keying on the structured code: a NotFound whose message
|
|
25
|
-
// contains "401" must stay NotFound, not become an auth error.
|
|
26
|
-
const msg = formatError(new ConnectError('task "401 ECONNREFUSED" missing', Code.NotFound));
|
|
27
|
-
expect(msg).toBe('[NotFound] task "401 ECONNREFUSED" missing');
|
|
28
|
-
expect(msg).not.toMatch(/Not authenticated/i);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("raw ECONNREFUSED error (err.code) → reach-API message", () => {
|
|
32
|
-
const err = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), {
|
|
33
|
-
code: "ECONNREFUSED",
|
|
34
|
-
});
|
|
35
|
-
expect(formatError(err)).toMatch(/Could not reach Opsee API/i);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("raw ENOTFOUND error (err.code) → reach-API message", () => {
|
|
39
|
-
const err = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
|
|
40
|
-
code: "ENOTFOUND",
|
|
41
|
-
});
|
|
42
|
-
expect(formatError(err)).toMatch(/Could not reach Opsee API/i);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("plain Error without a code → its message verbatim", () => {
|
|
46
|
-
expect(formatError(new Error("something odd"))).toBe("something odd");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("non-Error value → String(value)", () => {
|
|
50
|
-
expect(formatError("boom")).toBe("boom");
|
|
51
|
-
expect(formatError(42)).toBe("42");
|
|
52
|
-
});
|
|
53
|
-
});
|