kcg-code 0.0.0-stage → 0.2.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/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrasi kunci aplikasi: server nyata (`createKcgServer`) + fetch &
|
|
3
|
+
* WebSocket nyata. Memastikan SELURUH rute `/api/*` terkunci, cookie sesi
|
|
4
|
+
* benar, Origin lintas situs ditolak, dan WebSocket ikut dijaga.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { openSessionStore } from "../../db";
|
|
11
|
+
import { createKcgServer, type KcgServer } from "../app";
|
|
12
|
+
import { createAuthService } from "../services/auth";
|
|
13
|
+
import type { OpenCodeServerManager } from "../services/opencode-server";
|
|
14
|
+
|
|
15
|
+
/** Server opencode palsu: rute non-auth cukup tidak crash. */
|
|
16
|
+
const fakeServers: OpenCodeServerManager = {
|
|
17
|
+
ensureServer: async () => ({ ok: false, error: "SERVER_START_FAILED" }),
|
|
18
|
+
ensureFreshServer: async () => ({ ok: false, error: "SERVER_START_FAILED" }),
|
|
19
|
+
getServer: () => undefined,
|
|
20
|
+
stopServer: async () => {},
|
|
21
|
+
stopAll: async () => {},
|
|
22
|
+
onServerExit: () => {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
|
|
26
|
+
|
|
27
|
+
describe("kunci aplikasi — HTTP & WebSocket", () => {
|
|
28
|
+
let app: KcgServer;
|
|
29
|
+
let root: string;
|
|
30
|
+
let base: string;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
root = mkdtempSync(path.join(tmpdir(), "kcg-auth-"));
|
|
34
|
+
mkdirSync(path.join(root, "proj"), { recursive: true });
|
|
35
|
+
const store = openSessionStore(":memory:");
|
|
36
|
+
app = createKcgServer({
|
|
37
|
+
config: { sandboxRoot: root, configPath: "test" },
|
|
38
|
+
store,
|
|
39
|
+
servers: fakeServers,
|
|
40
|
+
uploadsRoot: path.join(root, "uploads"),
|
|
41
|
+
port: 0,
|
|
42
|
+
auth: createAuthService({
|
|
43
|
+
store,
|
|
44
|
+
hash: async (s) => `h:${s}`,
|
|
45
|
+
verify: async (s, h) => h === `h:${s}`,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
base = `http://127.0.0.1:${app.server.port}`;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await app.close();
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const call = (p: string, init: RequestInit & { cookie?: string } = {}) => {
|
|
57
|
+
const headers = new Headers(init.headers);
|
|
58
|
+
if (init.cookie) headers.set("cookie", init.cookie);
|
|
59
|
+
if (init.body && typeof init.body === "string") headers.set("content-type", "application/json");
|
|
60
|
+
return fetch(`${base}${p}`, { ...init, headers, redirect: "manual" });
|
|
61
|
+
};
|
|
62
|
+
const cookieOf = (res: Response) => res.headers.get("set-cookie")?.split(";")[0] ?? "";
|
|
63
|
+
|
|
64
|
+
test("tanpa kunci: API terbuka & status melaporkan tidak terlindungi", async () => {
|
|
65
|
+
expect((await call("/api/projects")).status).toBe(200);
|
|
66
|
+
const st = (await (await call("/api/auth/status")).json()) as { protected: boolean };
|
|
67
|
+
expect(st.protected).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let cookie = "";
|
|
71
|
+
|
|
72
|
+
test("atur kunci -> cookie sesi HttpOnly + SameSite=Strict", async () => {
|
|
73
|
+
const res = await call("/api/auth/lock", {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
body: JSON.stringify({ kind: "pin", secret: "482915" }),
|
|
76
|
+
});
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
const raw = res.headers.get("set-cookie") ?? "";
|
|
79
|
+
expect(raw).toContain("kcg_session=");
|
|
80
|
+
expect(raw).toContain("HttpOnly");
|
|
81
|
+
expect(raw).toContain("SameSite=Strict");
|
|
82
|
+
// HTTP biasa (localhost) -> tanpa Secure; lewat tunnel HTTPS -> Secure.
|
|
83
|
+
expect(raw).not.toContain("Secure");
|
|
84
|
+
cookie = cookieOf(res);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("setelah dikunci: SELURUH rute /api/* non-publik -> 401 tanpa cookie", async () => {
|
|
88
|
+
const apiPaths = [
|
|
89
|
+
"/api/projects",
|
|
90
|
+
"/api/projects/x",
|
|
91
|
+
"/api/projects/x/models",
|
|
92
|
+
"/api/projects/x/mcp",
|
|
93
|
+
"/api/projects/x/skills",
|
|
94
|
+
"/api/projects/x/agents",
|
|
95
|
+
"/api/projects/x/skills/install",
|
|
96
|
+
"/api/fs",
|
|
97
|
+
"/api/sessions",
|
|
98
|
+
"/api/sessions/x",
|
|
99
|
+
"/api/sessions/x/files",
|
|
100
|
+
"/api/sessions/x/context",
|
|
101
|
+
"/api/sessions/x/uploads",
|
|
102
|
+
"/api/sessions/x/stop",
|
|
103
|
+
"/api/uploads/s/i",
|
|
104
|
+
"/api/skills/search?q=react",
|
|
105
|
+
"/api/skills/audit?source=a/b&skill=c",
|
|
106
|
+
"/api/skills/installs",
|
|
107
|
+
"/api/skills/installs/x",
|
|
108
|
+
"/api/skills/installs/x/cancel",
|
|
109
|
+
"/api/auth/logout",
|
|
110
|
+
"/api/auth/lock",
|
|
111
|
+
"/api/auth/profile",
|
|
112
|
+
"/api/auth/settings",
|
|
113
|
+
"/api/auth/devices",
|
|
114
|
+
"/api/auth/devices/revoke-others",
|
|
115
|
+
"/api/auth/devices/x",
|
|
116
|
+
// Publik hanya untuk GET; PUT/DELETE wajib login.
|
|
117
|
+
"/api/auth/avatar",
|
|
118
|
+
"/api/auth/avatar/preset",
|
|
119
|
+
"/api/tunnel",
|
|
120
|
+
"/api/tunnel/logs",
|
|
121
|
+
"/api/tunnel/connect",
|
|
122
|
+
"/api/tunnel/connect/cancel",
|
|
123
|
+
"/api/tunnel/start",
|
|
124
|
+
"/api/tunnel/stop",
|
|
125
|
+
"/api/tunnel/rotate-secret",
|
|
126
|
+
"/api/tunnel/signout",
|
|
127
|
+
"/api/tunnel/provider",
|
|
128
|
+
"/api/tunnel/lhr",
|
|
129
|
+
"/api/tunnel/lhr/logs",
|
|
130
|
+
"/api/tunnel/lhr/start",
|
|
131
|
+
"/api/tunnel/lhr/stop",
|
|
132
|
+
"/api/meta",
|
|
133
|
+
"/api/auth/onboarding/complete",
|
|
134
|
+
"/api/workspace",
|
|
135
|
+
"/api/auth/passkeys",
|
|
136
|
+
"/api/auth/passkeys/register/options",
|
|
137
|
+
"/api/auth/passkeys/register",
|
|
138
|
+
"/api/auth/passkeys/x",
|
|
139
|
+
];
|
|
140
|
+
// Setiap pola rute /api terdaftar (kecuali publik) harus tercakup daftar
|
|
141
|
+
// di atas — rute baru yang belum diuji membuat test ini gagal.
|
|
142
|
+
const PUBLIC = new Set([
|
|
143
|
+
"/api/auth/status",
|
|
144
|
+
"/api/auth/login",
|
|
145
|
+
"/api/auth/passkeys/availability",
|
|
146
|
+
"/api/auth/passkeys/login/options",
|
|
147
|
+
"/api/auth/passkeys/login",
|
|
148
|
+
]);
|
|
149
|
+
const toRegex = (pattern: string) => new RegExp(`^${pattern.replace(/:[^/]+/g, "[^/]+")}$`);
|
|
150
|
+
for (const pattern of app.apiRoutePatterns) {
|
|
151
|
+
if (PUBLIC.has(pattern)) continue;
|
|
152
|
+
const re = toRegex(pattern);
|
|
153
|
+
const covered = apiPaths.some((p) => re.test(p.split("?")[0] ?? p));
|
|
154
|
+
expect({ pattern, covered }).toEqual({ pattern, covered: true });
|
|
155
|
+
}
|
|
156
|
+
for (const p of apiPaths) {
|
|
157
|
+
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
|
158
|
+
// GET avatar memang publik (lock screen).
|
|
159
|
+
if (p === "/api/auth/avatar" && method === "GET") continue;
|
|
160
|
+
const res = await call(p, { method, body: method === "GET" ? undefined : "{}" });
|
|
161
|
+
// 405/404 = method tak terdaftar untuk rute ini (tidak ada handler) — aman.
|
|
162
|
+
expect({ p, method, status: res.status }).toEqual({
|
|
163
|
+
p,
|
|
164
|
+
method,
|
|
165
|
+
status: [404, 405].includes(res.status) ? res.status : 401,
|
|
166
|
+
});
|
|
167
|
+
if (res.status !== 401) continue;
|
|
168
|
+
expect(((await res.json()) as { error: string }).error).toBe("AUTH_REQUIRED");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Rute publik lock screen tetap bisa diakses.
|
|
172
|
+
expect((await call("/api/auth/status")).status).toBe(200);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("dengan cookie: API bisa dipakai", async () => {
|
|
176
|
+
expect((await call("/api/projects", { cookie })).status).toBe(200);
|
|
177
|
+
const st = (await (await call("/api/auth/status", { cookie })).json()) as {
|
|
178
|
+
protected: boolean;
|
|
179
|
+
authenticated: boolean;
|
|
180
|
+
lockKind: string;
|
|
181
|
+
};
|
|
182
|
+
expect(st).toMatchObject({ protected: true, authenticated: true, lockKind: "pin" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("cookie palsu -> 401; status membersihkan cookie basi", async () => {
|
|
186
|
+
const fake = "kcg_session=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
187
|
+
expect((await call("/api/projects", { cookie: fake })).status).toBe(401);
|
|
188
|
+
const st = await call("/api/auth/status", { cookie: fake });
|
|
189
|
+
expect(st.headers.get("set-cookie")).toContain("Max-Age=0");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("Origin lintas situs ditolak untuk request yang mengubah state", async () => {
|
|
193
|
+
const res = await call("/api/auth/profile", {
|
|
194
|
+
method: "PATCH",
|
|
195
|
+
cookie,
|
|
196
|
+
body: JSON.stringify({ nickname: "evil" }),
|
|
197
|
+
headers: { origin: "https://evil.example" },
|
|
198
|
+
});
|
|
199
|
+
expect(res.status).toBe(403);
|
|
200
|
+
const same = await call("/api/auth/profile", {
|
|
201
|
+
method: "PATCH",
|
|
202
|
+
cookie,
|
|
203
|
+
body: JSON.stringify({ nickname: "Irsyad" }),
|
|
204
|
+
headers: { origin: base },
|
|
205
|
+
});
|
|
206
|
+
expect(same.status).toBe(200);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("passkey HTTP: endpoint publik terbatas, kelola wajib sesi + sandi", async () => {
|
|
210
|
+
// Publik (lock screen): http://127.0.0.1 bukan secure context -> tidak didukung.
|
|
211
|
+
const avail = await call("/api/auth/passkeys/availability", { headers: { origin: base } });
|
|
212
|
+
expect(avail.status).toBe(200);
|
|
213
|
+
expect(await avail.json()).toEqual({ supported: false, count: 0 });
|
|
214
|
+
// GET seperti browser (tanpa Origin) via localhost -> secure context.
|
|
215
|
+
const availLocal = await call("/api/auth/passkeys/availability", {
|
|
216
|
+
headers: { "x-forwarded-host": `localhost:${app.server.port}` },
|
|
217
|
+
});
|
|
218
|
+
expect(await availLocal.json()).toEqual({ supported: true, count: 0 });
|
|
219
|
+
// Hanya jumlah — tidak ada daftar/nama passkey yang bocor.
|
|
220
|
+
const opts = await call("/api/auth/passkeys/login/options", {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { origin: base },
|
|
223
|
+
});
|
|
224
|
+
expect(opts.status).toBe(400);
|
|
225
|
+
expect(((await opts.json()) as { error: string }).error).toBe("PASSKEY_UNSUPPORTED_ORIGIN");
|
|
226
|
+
|
|
227
|
+
// Daftar & pendaftaran tanpa cookie -> 401.
|
|
228
|
+
expect((await call("/api/auth/passkeys")).status).toBe(401);
|
|
229
|
+
expect(
|
|
230
|
+
(
|
|
231
|
+
await call("/api/auth/passkeys/register/options", {
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: JSON.stringify({ current: "482915" }),
|
|
234
|
+
})
|
|
235
|
+
).status,
|
|
236
|
+
).toBe(401);
|
|
237
|
+
// Dengan cookie: daftar kosong; tanpa sandi yang benar pendaftaran ditolak.
|
|
238
|
+
const list = await call("/api/auth/passkeys", { cookie });
|
|
239
|
+
expect(await list.json()).toEqual({ passkeys: [] });
|
|
240
|
+
// Origin localhost (secure context) lewat proxy header -> sampai ke cek sandi.
|
|
241
|
+
const local = `localhost:${app.server.port}`;
|
|
242
|
+
const localHeaders = { origin: `http://${local}`, "x-forwarded-host": local };
|
|
243
|
+
const wrong = await call("/api/auth/passkeys/register/options", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
cookie,
|
|
246
|
+
body: JSON.stringify({ current: "000000" }),
|
|
247
|
+
headers: { ...localHeaders, "x-forwarded-for": "7.7.7.7" },
|
|
248
|
+
});
|
|
249
|
+
expect(wrong.status).toBe(401);
|
|
250
|
+
expect(((await wrong.json()) as { error: string }).error).toBe("CURRENT_SECRET_INVALID");
|
|
251
|
+
// Sandi benar -> options WebAuthn untuk rpId localhost.
|
|
252
|
+
const right = await call("/api/auth/passkeys/register/options", {
|
|
253
|
+
method: "POST",
|
|
254
|
+
cookie,
|
|
255
|
+
body: JSON.stringify({ current: "482915" }),
|
|
256
|
+
headers: localHeaders,
|
|
257
|
+
});
|
|
258
|
+
expect(right.status).toBe(200);
|
|
259
|
+
const body = (await right.json()) as {
|
|
260
|
+
challengeId: string;
|
|
261
|
+
options: { rp: { id: string }; challenge: string };
|
|
262
|
+
};
|
|
263
|
+
expect(body.options.rp.id).toBe("localhost");
|
|
264
|
+
expect(body.challengeId.length).toBeGreaterThan(0);
|
|
265
|
+
// Origin lintas situs ditolak sebelum apa pun.
|
|
266
|
+
const cross = await call("/api/auth/passkeys/login/options", {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { origin: "https://evil.example" },
|
|
269
|
+
});
|
|
270
|
+
expect(cross.status).toBe(403);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("login: salah -> 401, benar -> cookie baru; rate limit -> 429 + Retry-After", async () => {
|
|
274
|
+
const bad = await call("/api/auth/login", {
|
|
275
|
+
method: "POST",
|
|
276
|
+
body: JSON.stringify({ secret: "000000" }),
|
|
277
|
+
});
|
|
278
|
+
expect(bad.status).toBe(401);
|
|
279
|
+
const good = await call("/api/auth/login", {
|
|
280
|
+
method: "POST",
|
|
281
|
+
body: JSON.stringify({ secret: "482915" }),
|
|
282
|
+
});
|
|
283
|
+
expect(good.status).toBe(200);
|
|
284
|
+
expect(cookieOf(good)).toMatch(/^kcg_session=/);
|
|
285
|
+
|
|
286
|
+
let limited: Response | null = null;
|
|
287
|
+
for (let i = 0; i < 6; i++) {
|
|
288
|
+
const r = await call("/api/auth/login", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
body: JSON.stringify({ secret: "000000" }),
|
|
291
|
+
headers: { "x-forwarded-for": "9.9.9.9" },
|
|
292
|
+
});
|
|
293
|
+
if (r.status === 429) limited = r;
|
|
294
|
+
}
|
|
295
|
+
expect(limited?.status).toBe(429);
|
|
296
|
+
expect(Number(limited?.headers.get("retry-after"))).toBeGreaterThan(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("avatar: upload (sniff), GET publik dengan header aman, SVG ditolak", async () => {
|
|
300
|
+
const form = new FormData();
|
|
301
|
+
form.append("file", new File([PNG], "a.png", { type: "image/png" }));
|
|
302
|
+
const up = await fetch(`${base}/api/auth/avatar`, {
|
|
303
|
+
method: "PUT",
|
|
304
|
+
body: form,
|
|
305
|
+
headers: { cookie },
|
|
306
|
+
});
|
|
307
|
+
expect(up.status).toBe(200);
|
|
308
|
+
const { profile } = (await up.json()) as { profile: { avatarUrl: string } };
|
|
309
|
+
|
|
310
|
+
// Publik: lock screen menampilkannya sebelum login.
|
|
311
|
+
const img = await call(profile.avatarUrl);
|
|
312
|
+
expect(img.status).toBe(200);
|
|
313
|
+
expect(img.headers.get("content-type")).toBe("image/png");
|
|
314
|
+
expect(img.headers.get("x-content-type-options")).toBe("nosniff");
|
|
315
|
+
|
|
316
|
+
const svg = new FormData();
|
|
317
|
+
svg.append("file", new File(["<svg onload=alert(1)>"], "x.svg", { type: "image/png" }));
|
|
318
|
+
const bad = await fetch(`${base}/api/auth/avatar`, {
|
|
319
|
+
method: "PUT",
|
|
320
|
+
body: svg,
|
|
321
|
+
headers: { cookie },
|
|
322
|
+
});
|
|
323
|
+
expect(bad.status).toBe(400);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("WebSocket: tanpa cookie ditolak; dengan cookie terbuka; Origin asing ditolak", async () => {
|
|
327
|
+
const tryWs = (headers: Record<string, string>) =>
|
|
328
|
+
new Promise<"open" | "closed">((resolve) => {
|
|
329
|
+
const ws = new WebSocket(`ws://127.0.0.1:${app.server.port}/ws`, { headers } as never);
|
|
330
|
+
ws.onopen = () => {
|
|
331
|
+
ws.close();
|
|
332
|
+
resolve("open");
|
|
333
|
+
};
|
|
334
|
+
ws.onerror = () => resolve("closed");
|
|
335
|
+
ws.onclose = () => resolve("closed");
|
|
336
|
+
});
|
|
337
|
+
expect(await tryWs({})).toBe("closed");
|
|
338
|
+
expect(await tryWs({ cookie })).toBe("open");
|
|
339
|
+
expect(await tryWs({ cookie, origin: "https://evil.example" })).toBe("closed");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("logout -> cookie lama tidak berlaku, WebSocket terbuka ditutup", async () => {
|
|
343
|
+
const login = await call("/api/auth/login", {
|
|
344
|
+
method: "POST",
|
|
345
|
+
body: JSON.stringify({ secret: "482915" }),
|
|
346
|
+
});
|
|
347
|
+
const c2 = cookieOf(login);
|
|
348
|
+
const closed = new Promise<number>((resolve) => {
|
|
349
|
+
const ws = new WebSocket(`ws://127.0.0.1:${app.server.port}/ws`, {
|
|
350
|
+
headers: { cookie: c2 },
|
|
351
|
+
} as never);
|
|
352
|
+
ws.onopen = async () => {
|
|
353
|
+
await call("/api/auth/logout", { method: "POST", cookie: c2 });
|
|
354
|
+
};
|
|
355
|
+
ws.onclose = (ev) => resolve(ev.code);
|
|
356
|
+
});
|
|
357
|
+
expect(await closed).toBe(4401);
|
|
358
|
+
expect((await call("/api/projects", { cookie: c2 })).status).toBe(401);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("hapus kunci -> API terbuka lagi", async () => {
|
|
362
|
+
const res = await call("/api/auth/lock", {
|
|
363
|
+
method: "DELETE",
|
|
364
|
+
cookie,
|
|
365
|
+
body: JSON.stringify({ current: "482915" }),
|
|
366
|
+
});
|
|
367
|
+
expect(res.status).toBe(200);
|
|
368
|
+
expect((await call("/api/projects")).status).toBe(200);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test `config.ts` (task 2.2).
|
|
3
|
+
* Kasus: field `sandboxRoot` hilang, direktori tidak ditemukan,
|
|
4
|
+
* dan konfigurasi valid. Requirement 10.1.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { resolveConfig } from "../../config";
|
|
11
|
+
|
|
12
|
+
function makeTempDir(): string {
|
|
13
|
+
return mkdtempSync(path.join(tmpdir(), "kcg-config-"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("config: field sandboxRoot hilang -> error", () => {
|
|
17
|
+
const dir = makeTempDir();
|
|
18
|
+
const cfgPath = path.join(dir, "kcg-code.config.json");
|
|
19
|
+
writeFileSync(cfgPath, JSON.stringify({}), "utf8");
|
|
20
|
+
const res = resolveConfig(cfgPath);
|
|
21
|
+
expect(res.ok).toBe(false);
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
expect(res.error).toContain("sandboxRoot");
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("config: direktori sandboxRoot tidak ditemukan -> error", () => {
|
|
28
|
+
const dir = makeTempDir();
|
|
29
|
+
const cfgPath = path.join(dir, "kcg-code.config.json");
|
|
30
|
+
const missing = path.join(dir, "tidak-ada");
|
|
31
|
+
writeFileSync(cfgPath, JSON.stringify({ sandboxRoot: missing }), "utf8");
|
|
32
|
+
const res = resolveConfig(cfgPath);
|
|
33
|
+
expect(res.ok).toBe(false);
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
expect(res.error).toContain("not found");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("config: berkas tidak ada -> error", () => {
|
|
40
|
+
const dir = makeTempDir();
|
|
41
|
+
const res = resolveConfig(path.join(dir, "missing.json"));
|
|
42
|
+
expect(res.ok).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("config: JSON tidak valid -> error", () => {
|
|
46
|
+
const dir = makeTempDir();
|
|
47
|
+
const cfgPath = path.join(dir, "kcg-code.config.json");
|
|
48
|
+
writeFileSync(cfgPath, "{ not valid json", "utf8");
|
|
49
|
+
const res = resolveConfig(cfgPath);
|
|
50
|
+
expect(res.ok).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("config: konfigurasi valid -> sandboxRoot ter-resolve", () => {
|
|
54
|
+
const dir = makeTempDir();
|
|
55
|
+
const sandbox = path.join(dir, "sandbox");
|
|
56
|
+
mkdirSync(sandbox, { recursive: true });
|
|
57
|
+
const cfgPath = path.join(dir, "kcg-code.config.json");
|
|
58
|
+
writeFileSync(cfgPath, JSON.stringify({ sandboxRoot: sandbox }), "utf8");
|
|
59
|
+
|
|
60
|
+
const res = resolveConfig(cfgPath);
|
|
61
|
+
expect(res.ok).toBe(true);
|
|
62
|
+
if (res.ok) {
|
|
63
|
+
expect(res.data.sandboxRoot).toBe(sandbox);
|
|
64
|
+
expect(res.data.configPath).toBe(cfgPath);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("config: loadConfig keluar dengan kode error saat konfigurasi tidak valid", () => {
|
|
69
|
+
const dir = makeTempDir();
|
|
70
|
+
const cfgPath = path.join(dir, "bad.json");
|
|
71
|
+
writeFileSync(cfgPath, JSON.stringify({}), "utf8");
|
|
72
|
+
|
|
73
|
+
const modulePath = path.join(process.cwd(), "src/config.ts");
|
|
74
|
+
const script = `import { loadConfig } from ${JSON.stringify(modulePath)}; loadConfig();`;
|
|
75
|
+
const proc = Bun.spawnSync(["bun", "-e", script], {
|
|
76
|
+
env: { ...process.env, KCG_CONFIG_PATH: cfgPath },
|
|
77
|
+
cwd: process.cwd(),
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
expect(proc.exitCode).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("config: loadConfig berhasil saat konfigurasi valid", () => {
|
|
85
|
+
const dir = makeTempDir();
|
|
86
|
+
const sandbox = path.join(dir, "sandbox");
|
|
87
|
+
mkdirSync(sandbox, { recursive: true });
|
|
88
|
+
const cfgPath = path.join(dir, "ok.json");
|
|
89
|
+
writeFileSync(cfgPath, JSON.stringify({ sandboxRoot: sandbox }), "utf8");
|
|
90
|
+
|
|
91
|
+
const modulePath = path.join(process.cwd(), "src/config.ts");
|
|
92
|
+
const script = `import { loadConfig } from ${JSON.stringify(modulePath)}; const c = loadConfig(); console.log(c.sandboxRoot);`;
|
|
93
|
+
const proc = Bun.spawnSync(["bun", "-e", script], {
|
|
94
|
+
env: { ...process.env, KCG_CONFIG_PATH: cfgPath },
|
|
95
|
+
cwd: process.cwd(),
|
|
96
|
+
stdout: "pipe",
|
|
97
|
+
stderr: "pipe",
|
|
98
|
+
});
|
|
99
|
+
expect(proc.exitCode).toBe(0);
|
|
100
|
+
expect(proc.stdout.toString()).toContain(sandbox);
|
|
101
|
+
});
|