@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,690 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Hoisted mocks ──────────────────────────────────────────────────────────
|
|
4
|
+
// All node:fs, node:fs/promises, and node:os functions are mocked before any
|
|
5
|
+
// module-level code runs. This is critical because SKILLS_DIR is computed at
|
|
6
|
+
// import time via homedir().
|
|
7
|
+
|
|
8
|
+
const mockExistsSync = vi.hoisted(() => vi.fn(() => false));
|
|
9
|
+
const mockReaddir = vi.hoisted(() => vi.fn(async () => []));
|
|
10
|
+
const mockReadFile = vi.hoisted(() => vi.fn(async () => ""));
|
|
11
|
+
const mockWriteFile = vi.hoisted(() => vi.fn(async (_path: string, _content: string) => {}));
|
|
12
|
+
const mockRm = vi.hoisted(() => vi.fn(async () => {}));
|
|
13
|
+
const mockMkdir = vi.hoisted(() => vi.fn(async () => {}));
|
|
14
|
+
|
|
15
|
+
vi.mock("node:fs", () => ({ existsSync: mockExistsSync }));
|
|
16
|
+
vi.mock("node:fs/promises", () => ({
|
|
17
|
+
readdir: mockReaddir,
|
|
18
|
+
readFile: mockReadFile,
|
|
19
|
+
writeFile: mockWriteFile,
|
|
20
|
+
rm: mockRm,
|
|
21
|
+
mkdir: mockMkdir,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("node:os", () => ({ homedir: () => "/mock-home" }));
|
|
24
|
+
|
|
25
|
+
import { Hono } from "hono";
|
|
26
|
+
import { registerSkillRoutes } from "./skills-routes.js";
|
|
27
|
+
|
|
28
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
29
|
+
// SKILLS_DIR resolves to /mock-home/.claude/skills because homedir() is mocked.
|
|
30
|
+
const SKILLS_DIR = "/mock-home/.claude/skills";
|
|
31
|
+
|
|
32
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a fake directory entry for readdir({ withFileTypes: true }).
|
|
36
|
+
* Simulates a Dirent object with name and isDirectory method.
|
|
37
|
+
*/
|
|
38
|
+
function makeDirent(name: string, isDir = true) {
|
|
39
|
+
return { name, isDirectory: () => isDir };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Builds a SKILL.md file content string with YAML front matter.
|
|
44
|
+
* Allows testing the front matter parser with various name/description values.
|
|
45
|
+
*/
|
|
46
|
+
function makeSkillMd(name: string, description: string, body = "") {
|
|
47
|
+
return `---\nname: ${name}\ndescription: "${description}"\n---\n\n${body}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Test setup ─────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
let app: Hono;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
// Reset all mocks to their default return values
|
|
57
|
+
mockExistsSync.mockReturnValue(false);
|
|
58
|
+
mockReaddir.mockResolvedValue([]);
|
|
59
|
+
mockReadFile.mockResolvedValue("");
|
|
60
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
61
|
+
mockRm.mockResolvedValue(undefined);
|
|
62
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
63
|
+
|
|
64
|
+
app = new Hono();
|
|
65
|
+
registerSkillRoutes(app);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── GET /skills ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("GET /skills", () => {
|
|
71
|
+
it("returns an empty array when SKILLS_DIR does not exist", async () => {
|
|
72
|
+
// existsSync returns false by default, so the directory doesn't exist
|
|
73
|
+
mockExistsSync.mockReturnValue(false);
|
|
74
|
+
|
|
75
|
+
const res = await app.request("/skills");
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
const json = await res.json();
|
|
79
|
+
expect(json).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns an empty array when SKILLS_DIR exists but is empty", async () => {
|
|
83
|
+
// The first existsSync call checks SKILLS_DIR itself
|
|
84
|
+
mockExistsSync.mockReturnValue(true);
|
|
85
|
+
mockReaddir.mockResolvedValue([]);
|
|
86
|
+
|
|
87
|
+
const res = await app.request("/skills");
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(200);
|
|
90
|
+
const json = await res.json();
|
|
91
|
+
expect(json).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips non-directory entries in SKILLS_DIR", async () => {
|
|
95
|
+
// Files inside the skills directory should be ignored (only directories matter)
|
|
96
|
+
mockExistsSync.mockReturnValue(true);
|
|
97
|
+
mockReaddir.mockResolvedValue([
|
|
98
|
+
makeDirent("readme.txt", false),
|
|
99
|
+
makeDirent(".DS_Store", false),
|
|
100
|
+
] as any);
|
|
101
|
+
|
|
102
|
+
const res = await app.request("/skills");
|
|
103
|
+
|
|
104
|
+
expect(res.status).toBe(200);
|
|
105
|
+
const json = await res.json();
|
|
106
|
+
expect(json).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("skips directories that have no SKILL.md file", async () => {
|
|
110
|
+
// A directory exists but its SKILL.md path check returns false
|
|
111
|
+
mockExistsSync
|
|
112
|
+
.mockReturnValueOnce(true) // SKILLS_DIR exists
|
|
113
|
+
.mockReturnValueOnce(false); // SKILL.md does not exist
|
|
114
|
+
mockReaddir.mockResolvedValue([makeDirent("orphan-dir")] as any);
|
|
115
|
+
|
|
116
|
+
const res = await app.request("/skills");
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const json = await res.json();
|
|
120
|
+
expect(json).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("parses front matter and returns skill metadata for valid skills", async () => {
|
|
124
|
+
// Two valid skill directories with properly formatted SKILL.md files
|
|
125
|
+
mockExistsSync.mockReturnValue(true);
|
|
126
|
+
mockReaddir.mockResolvedValue([
|
|
127
|
+
makeDirent("my-skill"),
|
|
128
|
+
makeDirent("another-skill"),
|
|
129
|
+
] as any);
|
|
130
|
+
mockReadFile
|
|
131
|
+
.mockResolvedValueOnce(makeSkillMd("My Skill", "Does cool things", "# Usage\nRun it."))
|
|
132
|
+
.mockResolvedValueOnce(makeSkillMd("Another Skill", "Also useful"));
|
|
133
|
+
|
|
134
|
+
const res = await app.request("/skills");
|
|
135
|
+
|
|
136
|
+
expect(res.status).toBe(200);
|
|
137
|
+
const json = await res.json();
|
|
138
|
+
expect(json).toHaveLength(2);
|
|
139
|
+
expect(json[0]).toEqual({
|
|
140
|
+
slug: "my-skill",
|
|
141
|
+
name: "My Skill",
|
|
142
|
+
description: "Does cool things",
|
|
143
|
+
path: `${SKILLS_DIR}/my-skill/SKILL.md`,
|
|
144
|
+
});
|
|
145
|
+
expect(json[1]).toEqual({
|
|
146
|
+
slug: "another-skill",
|
|
147
|
+
name: "Another Skill",
|
|
148
|
+
description: "Also useful",
|
|
149
|
+
path: `${SKILLS_DIR}/another-skill/SKILL.md`,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses the directory name as fallback when front matter has no name field", async () => {
|
|
154
|
+
// Front matter exists but has no name: line — slug should be used as name
|
|
155
|
+
mockExistsSync.mockReturnValue(true);
|
|
156
|
+
mockReaddir.mockResolvedValue([makeDirent("fallback-skill")] as any);
|
|
157
|
+
mockReadFile.mockResolvedValue("---\ndescription: \"just a description\"\n---\n\nsome content");
|
|
158
|
+
|
|
159
|
+
const res = await app.request("/skills");
|
|
160
|
+
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
expect(json).toHaveLength(1);
|
|
164
|
+
expect(json[0].name).toBe("fallback-skill");
|
|
165
|
+
expect(json[0].description).toBe("just a description");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("handles SKILL.md files with no front matter (no --- delimiters)", async () => {
|
|
169
|
+
// Content without front matter — name falls back to directory name, description empty
|
|
170
|
+
mockExistsSync.mockReturnValue(true);
|
|
171
|
+
mockReaddir.mockResolvedValue([makeDirent("raw-skill")] as any);
|
|
172
|
+
mockReadFile.mockResolvedValue("# Raw Skill\n\nNo front matter here.");
|
|
173
|
+
|
|
174
|
+
const res = await app.request("/skills");
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
const json = await res.json();
|
|
178
|
+
expect(json).toHaveLength(1);
|
|
179
|
+
expect(json[0].name).toBe("raw-skill");
|
|
180
|
+
expect(json[0].description).toBe("");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("strips quotes from name values in front matter", async () => {
|
|
184
|
+
// Name wrapped in double quotes should have them removed
|
|
185
|
+
mockExistsSync.mockReturnValue(true);
|
|
186
|
+
mockReaddir.mockResolvedValue([makeDirent("quoted-skill")] as any);
|
|
187
|
+
mockReadFile.mockResolvedValue('---\nname: "Quoted Name"\ndescription: "desc"\n---\n\ncontent');
|
|
188
|
+
|
|
189
|
+
const res = await app.request("/skills");
|
|
190
|
+
|
|
191
|
+
expect(res.status).toBe(200);
|
|
192
|
+
const json = await res.json();
|
|
193
|
+
expect(json[0].name).toBe("Quoted Name");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("strips single quotes from name values in front matter", async () => {
|
|
197
|
+
// Name wrapped in single quotes should also be stripped
|
|
198
|
+
mockExistsSync.mockReturnValue(true);
|
|
199
|
+
mockReaddir.mockResolvedValue([makeDirent("sq-skill")] as any);
|
|
200
|
+
mockReadFile.mockResolvedValue("---\nname: 'Single Quoted'\ndescription: 'desc'\n---\n\ncontent");
|
|
201
|
+
|
|
202
|
+
const res = await app.request("/skills");
|
|
203
|
+
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
const json = await res.json();
|
|
206
|
+
expect(json[0].name).toBe("Single Quoted");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("returns a mixed list filtering out non-directories and missing SKILL.md entries", async () => {
|
|
210
|
+
// Mix of valid directories, non-directories, and directories without SKILL.md
|
|
211
|
+
mockExistsSync
|
|
212
|
+
.mockReturnValueOnce(true) // SKILLS_DIR exists
|
|
213
|
+
.mockReturnValueOnce(true) // valid-skill/SKILL.md exists
|
|
214
|
+
.mockReturnValueOnce(false); // no-md-skill/SKILL.md does not exist
|
|
215
|
+
mockReaddir.mockResolvedValue([
|
|
216
|
+
makeDirent("valid-skill"),
|
|
217
|
+
makeDirent("just-a-file.txt", false),
|
|
218
|
+
makeDirent("no-md-skill"),
|
|
219
|
+
] as any);
|
|
220
|
+
mockReadFile.mockResolvedValueOnce(makeSkillMd("Valid", "A valid skill"));
|
|
221
|
+
|
|
222
|
+
const res = await app.request("/skills");
|
|
223
|
+
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
const json = await res.json();
|
|
226
|
+
expect(json).toHaveLength(1);
|
|
227
|
+
expect(json[0].slug).toBe("valid-skill");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("returns 500 when readdir throws an unexpected error", async () => {
|
|
231
|
+
// Simulates a filesystem error during directory listing
|
|
232
|
+
mockExistsSync.mockReturnValue(true);
|
|
233
|
+
mockReaddir.mockRejectedValue(new Error("Permission denied"));
|
|
234
|
+
|
|
235
|
+
const res = await app.request("/skills");
|
|
236
|
+
|
|
237
|
+
expect(res.status).toBe(500);
|
|
238
|
+
const json = await res.json();
|
|
239
|
+
expect(json.error).toContain("Permission denied");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns 500 when readFile throws an unexpected error", async () => {
|
|
243
|
+
// Simulates a read error on an individual SKILL.md file
|
|
244
|
+
mockExistsSync.mockReturnValue(true);
|
|
245
|
+
mockReaddir.mockResolvedValue([makeDirent("broken-skill")] as any);
|
|
246
|
+
mockReadFile.mockRejectedValue(new Error("EACCES: permission denied"));
|
|
247
|
+
|
|
248
|
+
const res = await app.request("/skills");
|
|
249
|
+
|
|
250
|
+
expect(res.status).toBe(500);
|
|
251
|
+
const json = await res.json();
|
|
252
|
+
expect(json.error).toContain("EACCES");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ─── GET /skills/:slug ──────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
describe("GET /skills/:slug", () => {
|
|
259
|
+
it("returns the skill content when slug and SKILL.md are valid", async () => {
|
|
260
|
+
// Happy path: valid slug, file exists, content is returned
|
|
261
|
+
const content = makeSkillMd("My Skill", "A useful skill", "# Usage\nDo stuff.");
|
|
262
|
+
mockExistsSync.mockReturnValue(true);
|
|
263
|
+
mockReadFile.mockResolvedValue(content);
|
|
264
|
+
|
|
265
|
+
const res = await app.request("/skills/my-skill");
|
|
266
|
+
|
|
267
|
+
expect(res.status).toBe(200);
|
|
268
|
+
const json = await res.json();
|
|
269
|
+
expect(json).toEqual({
|
|
270
|
+
slug: "my-skill",
|
|
271
|
+
path: `${SKILLS_DIR}/my-skill/SKILL.md`,
|
|
272
|
+
content,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("returns 404 when SKILL.md does not exist for the slug", async () => {
|
|
277
|
+
mockExistsSync.mockReturnValue(false);
|
|
278
|
+
|
|
279
|
+
const res = await app.request("/skills/nonexistent");
|
|
280
|
+
|
|
281
|
+
expect(res.status).toBe(404);
|
|
282
|
+
const json = await res.json();
|
|
283
|
+
expect(json.error).toBe("Skill not found");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("returns 400 when slug contains '..'", async () => {
|
|
287
|
+
// Path traversal attempt with double dots
|
|
288
|
+
const res = await app.request("/skills/..%2F..%2Fetc");
|
|
289
|
+
|
|
290
|
+
expect(res.status).toBe(400);
|
|
291
|
+
const json = await res.json();
|
|
292
|
+
expect(json.error).toBe("Invalid slug");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("returns 400 when slug contains a forward slash", async () => {
|
|
296
|
+
// The route parameter itself won't contain a literal '/' since Hono
|
|
297
|
+
// treats it as a path separator. However, URL-encoded '/' (%2F) in
|
|
298
|
+
// the slug param is tested here to verify the validation logic.
|
|
299
|
+
// We test the validation by calling the path directly with a known bad slug.
|
|
300
|
+
const res = await app.request("/skills/bad%2Fslug");
|
|
301
|
+
|
|
302
|
+
expect(res.status).toBe(400);
|
|
303
|
+
const json = await res.json();
|
|
304
|
+
expect(json.error).toBe("Invalid slug");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("returns 400 when slug contains a backslash", async () => {
|
|
308
|
+
const res = await app.request("/skills/bad%5Cslug");
|
|
309
|
+
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
const json = await res.json();
|
|
312
|
+
expect(json.error).toBe("Invalid slug");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─── POST /skills ───────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe("POST /skills", () => {
|
|
319
|
+
it("creates a new skill with name, description, and content", async () => {
|
|
320
|
+
// Happy path: new skill that does not yet exist
|
|
321
|
+
mockExistsSync.mockReturnValue(false); // SKILL.md doesn't exist yet
|
|
322
|
+
|
|
323
|
+
const res = await app.request("/skills", {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: { "Content-Type": "application/json" },
|
|
326
|
+
body: JSON.stringify({
|
|
327
|
+
name: "My New Skill",
|
|
328
|
+
description: "Does amazing things",
|
|
329
|
+
content: "# My New Skill\n\nHere are the instructions.",
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(res.status).toBe(200);
|
|
334
|
+
const json = await res.json();
|
|
335
|
+
expect(json.slug).toBe("my-new-skill");
|
|
336
|
+
expect(json.name).toBe("My New Skill");
|
|
337
|
+
expect(json.description).toBe("Does amazing things");
|
|
338
|
+
expect(json.path).toBe(`${SKILLS_DIR}/my-new-skill/SKILL.md`);
|
|
339
|
+
|
|
340
|
+
// Verify mkdir was called for both SKILLS_DIR and the skill directory
|
|
341
|
+
expect(mockMkdir).toHaveBeenCalledWith(SKILLS_DIR, { recursive: true });
|
|
342
|
+
expect(mockMkdir).toHaveBeenCalledWith(`${SKILLS_DIR}/my-new-skill`, { recursive: true });
|
|
343
|
+
|
|
344
|
+
// Verify writeFile was called with the expected markdown content
|
|
345
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
346
|
+
const writtenContent = mockWriteFile.mock.calls[0][1] as string;
|
|
347
|
+
expect(writtenContent).toContain("name: my-new-skill");
|
|
348
|
+
expect(writtenContent).toContain('"Does amazing things"');
|
|
349
|
+
expect(writtenContent).toContain("# My New Skill\n\nHere are the instructions.");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("generates default content when content is not provided", async () => {
|
|
353
|
+
// Omitting content should produce a default "# Name" body
|
|
354
|
+
mockExistsSync.mockReturnValue(false);
|
|
355
|
+
|
|
356
|
+
const res = await app.request("/skills", {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: { "Content-Type": "application/json" },
|
|
359
|
+
body: JSON.stringify({ name: "Bare Skill" }),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(res.status).toBe(200);
|
|
363
|
+
const json = await res.json();
|
|
364
|
+
expect(json.slug).toBe("bare-skill");
|
|
365
|
+
|
|
366
|
+
// Default content should include the skill name as a heading
|
|
367
|
+
const writtenContent = mockWriteFile.mock.calls[0][1] as string;
|
|
368
|
+
expect(writtenContent).toContain("# Bare Skill");
|
|
369
|
+
expect(writtenContent).toContain("Describe what this skill does");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("generates a default description when description is not provided", async () => {
|
|
373
|
+
// Omitting description should fallback to "Skill: <name>"
|
|
374
|
+
mockExistsSync.mockReturnValue(false);
|
|
375
|
+
|
|
376
|
+
const res = await app.request("/skills", {
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: { "Content-Type": "application/json" },
|
|
379
|
+
body: JSON.stringify({ name: "No Desc" }),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(res.status).toBe(200);
|
|
383
|
+
const json = await res.json();
|
|
384
|
+
expect(json.description).toBe("Skill: No Desc");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("returns 400 when name is missing", async () => {
|
|
388
|
+
const res = await app.request("/skills", {
|
|
389
|
+
method: "POST",
|
|
390
|
+
headers: { "Content-Type": "application/json" },
|
|
391
|
+
body: JSON.stringify({ description: "No name provided" }),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(res.status).toBe(400);
|
|
395
|
+
const json = await res.json();
|
|
396
|
+
expect(json.error).toBe("name is required");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("returns 400 when name is not a string", async () => {
|
|
400
|
+
const res = await app.request("/skills", {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: { "Content-Type": "application/json" },
|
|
403
|
+
body: JSON.stringify({ name: 12345 }),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(res.status).toBe(400);
|
|
407
|
+
const json = await res.json();
|
|
408
|
+
expect(json.error).toBe("name is required");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("returns 400 when name is an empty string", async () => {
|
|
412
|
+
const res = await app.request("/skills", {
|
|
413
|
+
method: "POST",
|
|
414
|
+
headers: { "Content-Type": "application/json" },
|
|
415
|
+
body: JSON.stringify({ name: "" }),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(res.status).toBe(400);
|
|
419
|
+
const json = await res.json();
|
|
420
|
+
expect(json.error).toBe("name is required");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns 400 when name produces an empty slug (all special characters)", async () => {
|
|
424
|
+
// A name like "!!!" would become an empty slug after sanitization
|
|
425
|
+
const res = await app.request("/skills", {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "Content-Type": "application/json" },
|
|
428
|
+
body: JSON.stringify({ name: "!!!" }),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(res.status).toBe(400);
|
|
432
|
+
const json = await res.json();
|
|
433
|
+
expect(json.error).toBe("Invalid name");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("returns 409 when a skill with the same slug already exists", async () => {
|
|
437
|
+
// existsSync returns true for the SKILL.md check, indicating conflict
|
|
438
|
+
mockExistsSync.mockReturnValue(true);
|
|
439
|
+
|
|
440
|
+
const res = await app.request("/skills", {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { "Content-Type": "application/json" },
|
|
443
|
+
body: JSON.stringify({ name: "Existing Skill" }),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(res.status).toBe(409);
|
|
447
|
+
const json = await res.json();
|
|
448
|
+
expect(json.error).toContain("already exists");
|
|
449
|
+
expect(json.error).toContain("existing-skill");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("handles invalid JSON body gracefully", async () => {
|
|
453
|
+
// Malformed JSON should not crash the server — c.req.json().catch returns {}
|
|
454
|
+
const res = await app.request("/skills", {
|
|
455
|
+
method: "POST",
|
|
456
|
+
headers: { "Content-Type": "application/json" },
|
|
457
|
+
body: "not valid json",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(res.status).toBe(400);
|
|
461
|
+
const json = await res.json();
|
|
462
|
+
expect(json.error).toBe("name is required");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("generates a correct slug from a name with special characters", async () => {
|
|
466
|
+
// Names with mixed case, spaces, and special chars should produce clean slugs
|
|
467
|
+
mockExistsSync.mockReturnValue(false);
|
|
468
|
+
|
|
469
|
+
const res = await app.request("/skills", {
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: { "Content-Type": "application/json" },
|
|
472
|
+
body: JSON.stringify({ name: " Hello World!!! @#$ Test " }),
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(res.status).toBe(200);
|
|
476
|
+
const json = await res.json();
|
|
477
|
+
// The slug generation lowercases, replaces non-alphanum with hyphens,
|
|
478
|
+
// and trims leading/trailing hyphens
|
|
479
|
+
expect(json.slug).toBe("hello-world-test");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("trims leading and trailing hyphens from the generated slug", async () => {
|
|
483
|
+
// A name with leading/trailing special chars should not produce leading/trailing hyphens
|
|
484
|
+
mockExistsSync.mockReturnValue(false);
|
|
485
|
+
|
|
486
|
+
const res = await app.request("/skills", {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: { "Content-Type": "application/json" },
|
|
489
|
+
body: JSON.stringify({ name: "---trimmed---" }),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
expect(res.status).toBe(200);
|
|
493
|
+
const json = await res.json();
|
|
494
|
+
expect(json.slug).toBe("trimmed");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ─── PUT /skills/:slug ──────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
describe("PUT /skills/:slug", () => {
|
|
501
|
+
it("updates the skill content when slug is valid and skill exists", async () => {
|
|
502
|
+
// Happy path: existing skill, valid content update
|
|
503
|
+
mockExistsSync.mockReturnValue(true);
|
|
504
|
+
const newContent = "---\nname: updated\n---\n\n# Updated content";
|
|
505
|
+
|
|
506
|
+
const res = await app.request("/skills/my-skill", {
|
|
507
|
+
method: "PUT",
|
|
508
|
+
headers: { "Content-Type": "application/json" },
|
|
509
|
+
body: JSON.stringify({ content: newContent }),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(res.status).toBe(200);
|
|
513
|
+
const json = await res.json();
|
|
514
|
+
expect(json).toEqual({
|
|
515
|
+
ok: true,
|
|
516
|
+
slug: "my-skill",
|
|
517
|
+
path: `${SKILLS_DIR}/my-skill/SKILL.md`,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Verify writeFile was called with the new content
|
|
521
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
522
|
+
`${SKILLS_DIR}/my-skill/SKILL.md`,
|
|
523
|
+
newContent,
|
|
524
|
+
"utf-8",
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("allows updating with empty string content", async () => {
|
|
529
|
+
// An empty string is still a valid content value (typeof is "string")
|
|
530
|
+
mockExistsSync.mockReturnValue(true);
|
|
531
|
+
|
|
532
|
+
const res = await app.request("/skills/my-skill", {
|
|
533
|
+
method: "PUT",
|
|
534
|
+
headers: { "Content-Type": "application/json" },
|
|
535
|
+
body: JSON.stringify({ content: "" }),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
expect(res.status).toBe(200);
|
|
539
|
+
const json = await res.json();
|
|
540
|
+
expect(json.ok).toBe(true);
|
|
541
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
542
|
+
`${SKILLS_DIR}/my-skill/SKILL.md`,
|
|
543
|
+
"",
|
|
544
|
+
"utf-8",
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("returns 404 when the skill does not exist", async () => {
|
|
549
|
+
mockExistsSync.mockReturnValue(false);
|
|
550
|
+
|
|
551
|
+
const res = await app.request("/skills/nonexistent", {
|
|
552
|
+
method: "PUT",
|
|
553
|
+
headers: { "Content-Type": "application/json" },
|
|
554
|
+
body: JSON.stringify({ content: "anything" }),
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(res.status).toBe(404);
|
|
558
|
+
const json = await res.json();
|
|
559
|
+
expect(json.error).toBe("Skill not found");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("returns 400 when content is missing from the body", async () => {
|
|
563
|
+
mockExistsSync.mockReturnValue(true);
|
|
564
|
+
|
|
565
|
+
const res = await app.request("/skills/my-skill", {
|
|
566
|
+
method: "PUT",
|
|
567
|
+
headers: { "Content-Type": "application/json" },
|
|
568
|
+
body: JSON.stringify({ name: "no content field" }),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
expect(res.status).toBe(400);
|
|
572
|
+
const json = await res.json();
|
|
573
|
+
expect(json.error).toBe("content is required");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("returns 400 when content is not a string", async () => {
|
|
577
|
+
mockExistsSync.mockReturnValue(true);
|
|
578
|
+
|
|
579
|
+
const res = await app.request("/skills/my-skill", {
|
|
580
|
+
method: "PUT",
|
|
581
|
+
headers: { "Content-Type": "application/json" },
|
|
582
|
+
body: JSON.stringify({ content: 42 }),
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
expect(res.status).toBe(400);
|
|
586
|
+
const json = await res.json();
|
|
587
|
+
expect(json.error).toBe("content is required");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("returns 400 when slug contains '..'", async () => {
|
|
591
|
+
const res = await app.request("/skills/..%2Fevil", {
|
|
592
|
+
method: "PUT",
|
|
593
|
+
headers: { "Content-Type": "application/json" },
|
|
594
|
+
body: JSON.stringify({ content: "pwned" }),
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
expect(res.status).toBe(400);
|
|
598
|
+
const json = await res.json();
|
|
599
|
+
expect(json.error).toBe("Invalid slug");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("returns 400 when slug contains a backslash", async () => {
|
|
603
|
+
const res = await app.request("/skills/back%5Cslash", {
|
|
604
|
+
method: "PUT",
|
|
605
|
+
headers: { "Content-Type": "application/json" },
|
|
606
|
+
body: JSON.stringify({ content: "test" }),
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(res.status).toBe(400);
|
|
610
|
+
const json = await res.json();
|
|
611
|
+
expect(json.error).toBe("Invalid slug");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("handles invalid JSON body gracefully", async () => {
|
|
615
|
+
// Malformed body falls through to the content check
|
|
616
|
+
mockExistsSync.mockReturnValue(true);
|
|
617
|
+
|
|
618
|
+
const res = await app.request("/skills/my-skill", {
|
|
619
|
+
method: "PUT",
|
|
620
|
+
headers: { "Content-Type": "application/json" },
|
|
621
|
+
body: "{{bad json",
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
expect(res.status).toBe(400);
|
|
625
|
+
const json = await res.json();
|
|
626
|
+
expect(json.error).toBe("content is required");
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// ─── DELETE /skills/:slug ───────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
describe("DELETE /skills/:slug", () => {
|
|
633
|
+
it("deletes the skill directory when it exists", async () => {
|
|
634
|
+
// Happy path: directory exists, rm succeeds
|
|
635
|
+
mockExistsSync.mockReturnValue(true);
|
|
636
|
+
|
|
637
|
+
const res = await app.request("/skills/doomed-skill", { method: "DELETE" });
|
|
638
|
+
|
|
639
|
+
expect(res.status).toBe(200);
|
|
640
|
+
const json = await res.json();
|
|
641
|
+
expect(json).toEqual({ ok: true, slug: "doomed-skill" });
|
|
642
|
+
|
|
643
|
+
// Verify rm was called with recursive and force flags on the directory
|
|
644
|
+
expect(mockRm).toHaveBeenCalledWith(
|
|
645
|
+
`${SKILLS_DIR}/doomed-skill`,
|
|
646
|
+
{ recursive: true, force: true },
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("returns 404 when the skill directory does not exist", async () => {
|
|
651
|
+
mockExistsSync.mockReturnValue(false);
|
|
652
|
+
|
|
653
|
+
const res = await app.request("/skills/ghost", { method: "DELETE" });
|
|
654
|
+
|
|
655
|
+
expect(res.status).toBe(404);
|
|
656
|
+
const json = await res.json();
|
|
657
|
+
expect(json.error).toBe("Skill not found");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("returns 400 when slug contains '..'", async () => {
|
|
661
|
+
const res = await app.request("/skills/..%2F..%2Fetc", { method: "DELETE" });
|
|
662
|
+
|
|
663
|
+
expect(res.status).toBe(400);
|
|
664
|
+
const json = await res.json();
|
|
665
|
+
expect(json.error).toBe("Invalid slug");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("returns 400 when slug contains a forward slash", async () => {
|
|
669
|
+
const res = await app.request("/skills/a%2Fb", { method: "DELETE" });
|
|
670
|
+
|
|
671
|
+
expect(res.status).toBe(400);
|
|
672
|
+
const json = await res.json();
|
|
673
|
+
expect(json.error).toBe("Invalid slug");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("returns 400 when slug contains a backslash", async () => {
|
|
677
|
+
const res = await app.request("/skills/a%5Cb", { method: "DELETE" });
|
|
678
|
+
|
|
679
|
+
expect(res.status).toBe(400);
|
|
680
|
+
const json = await res.json();
|
|
681
|
+
expect(json.error).toBe("Invalid slug");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("does not call rm when slug validation fails", async () => {
|
|
685
|
+
// Ensure that the filesystem is never touched for invalid slugs
|
|
686
|
+
await app.request("/skills/..%2Fetc%2Fpasswd", { method: "DELETE" });
|
|
687
|
+
|
|
688
|
+
expect(mockRm).not.toHaveBeenCalled();
|
|
689
|
+
});
|
|
690
|
+
});
|