kcg-code 0.0.0-stage → 0.1.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 +228 -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 +83 -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__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -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 +142 -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 +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -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 +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -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 +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -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 +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -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 +220 -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 +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -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 +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -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/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 +85 -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 +97 -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 +234 -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 +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -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 +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -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 +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -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 +360 -0
- package/src/lib/skills-catalog.ts +99 -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 +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -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 +415 -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 +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -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__/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 +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -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/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 +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -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,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test Auth_Service (kunci aplikasi). Hash diinjeksi (cepat) kecuali
|
|
3
|
+
* satu test yang memastikan default argon2id berfungsi.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { openSessionStore } from "../../../db";
|
|
7
|
+
import {
|
|
8
|
+
AVATAR_MAX_BYTES,
|
|
9
|
+
createAuthService,
|
|
10
|
+
hashToken,
|
|
11
|
+
lockoutMs,
|
|
12
|
+
SESSION_TTL_MS,
|
|
13
|
+
sniffImageMime,
|
|
14
|
+
validateSecret,
|
|
15
|
+
} from "../auth";
|
|
16
|
+
|
|
17
|
+
const fastHash = async (s: string) => `h:${s}`;
|
|
18
|
+
const fastVerify = async (s: string, h: string) => h === `h:${s}`;
|
|
19
|
+
const ctx = { ip: "1.1.1.1", userAgent: "test-agent" };
|
|
20
|
+
|
|
21
|
+
function harness() {
|
|
22
|
+
let t = 1_000_000;
|
|
23
|
+
const store = openSessionStore(":memory:");
|
|
24
|
+
const auth = createAuthService({ store, now: () => t, hash: fastHash, verify: fastVerify });
|
|
25
|
+
const ended: string[] = [];
|
|
26
|
+
auth.onSessionEnded((id) => ended.push(id));
|
|
27
|
+
return {
|
|
28
|
+
store,
|
|
29
|
+
auth,
|
|
30
|
+
ended,
|
|
31
|
+
advance: (ms: number) => {
|
|
32
|
+
t += ms;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]);
|
|
38
|
+
|
|
39
|
+
describe("validateSecret", () => {
|
|
40
|
+
test("PIN: angka saja, 6-12 digit, tolak pola trivial", () => {
|
|
41
|
+
expect(validateSecret("pin", "482915")).toBeNull();
|
|
42
|
+
expect(validateSecret("pin", "48291a")).toBe("PIN_DIGITS_ONLY");
|
|
43
|
+
expect(validateSecret("pin", "48291")).toBe("PIN_LENGTH");
|
|
44
|
+
expect(validateSecret("pin", "4829154829154")).toBe("PIN_LENGTH");
|
|
45
|
+
expect(validateSecret("pin", "111111")).toBe("PIN_TOO_SIMPLE");
|
|
46
|
+
expect(validateSecret("pin", "123456")).toBe("PIN_TOO_SIMPLE");
|
|
47
|
+
expect(validateSecret("pin", "987654")).toBe("PIN_TOO_SIMPLE");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("password: 8-128 karakter, bukan spasi saja", () => {
|
|
51
|
+
expect(validateSecret("password", "correct horse")).toBeNull();
|
|
52
|
+
expect(validateSecret("password", "short")).toBe("PASSWORD_TOO_SHORT");
|
|
53
|
+
expect(validateSecret("password", " ")).toBe("PASSWORD_TOO_SHORT");
|
|
54
|
+
expect(validateSecret("password", "x".repeat(129))).toBe("PASSWORD_TOO_LONG");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("util", () => {
|
|
59
|
+
test("sniffImageMime dari magic bytes", () => {
|
|
60
|
+
expect(sniffImageMime(PNG)).toBe("image/png");
|
|
61
|
+
expect(sniffImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe("image/jpeg");
|
|
62
|
+
expect(sniffImageMime(new TextEncoder().encode("GIF89a"))).toBe("image/gif");
|
|
63
|
+
expect(sniffImageMime(new TextEncoder().encode("RIFF1234WEBPVP8 "))).toBe("image/webp");
|
|
64
|
+
expect(sniffImageMime(new TextEncoder().encode("<svg onload=alert(1)>"))).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("lockoutMs: bebas lalu berlipat, dibatasi maksimum", () => {
|
|
68
|
+
expect(lockoutMs(4, 5)).toBe(0);
|
|
69
|
+
expect(lockoutMs(5, 5)).toBe(30_000);
|
|
70
|
+
expect(lockoutMs(6, 5)).toBe(60_000);
|
|
71
|
+
expect(lockoutMs(50, 5)).toBe(15 * 60_000);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("createAuthService", () => {
|
|
76
|
+
test("tanpa kunci: terbuka, login ditolak AUTH_NOT_CONFIGURED", async () => {
|
|
77
|
+
const h = harness();
|
|
78
|
+
expect(h.auth.isProtected()).toBe(false);
|
|
79
|
+
const st = h.auth.status(null, ctx.ip);
|
|
80
|
+
expect(st).toMatchObject({ protected: false, authenticated: true, lockKind: null });
|
|
81
|
+
expect(await h.auth.login("whatever", ctx)).toEqual({
|
|
82
|
+
ok: false,
|
|
83
|
+
error: "AUTH_NOT_CONFIGURED",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("atur kunci -> sesi baru; token disimpan sebagai hash, bukan mentah", async () => {
|
|
88
|
+
const h = harness();
|
|
89
|
+
const res = await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
90
|
+
expect(res.ok).toBe(true);
|
|
91
|
+
if (!res.ok) return;
|
|
92
|
+
expect(h.auth.isProtected()).toBe(true);
|
|
93
|
+
const rows = h.store.listAuthSessions();
|
|
94
|
+
expect(rows).toHaveLength(1);
|
|
95
|
+
expect(rows[0]?.tokenHash).toBe(hashToken(res.data.token));
|
|
96
|
+
expect(rows[0]?.tokenHash).not.toBe(res.data.token);
|
|
97
|
+
expect(h.store.getAuthSettings(0).lockHash).not.toContain("482915".repeat(2));
|
|
98
|
+
expect(h.auth.status(res.data.token, ctx.ip)).toMatchObject({
|
|
99
|
+
protected: true,
|
|
100
|
+
authenticated: true,
|
|
101
|
+
lockKind: "pin",
|
|
102
|
+
});
|
|
103
|
+
expect(h.auth.status(null, ctx.ip).authenticated).toBe(false);
|
|
104
|
+
expect(h.auth.status("x".repeat(43), ctx.ip).authenticated).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("login benar/salah; logout mencabut sesi", async () => {
|
|
108
|
+
const h = harness();
|
|
109
|
+
await h.auth.setLock({ kind: "password", secret: "correct horse" }, ctx);
|
|
110
|
+
expect((await h.auth.login("wrong horse", ctx)).ok).toBe(false);
|
|
111
|
+
const ok = await h.auth.login("correct horse", ctx);
|
|
112
|
+
expect(ok.ok).toBe(true);
|
|
113
|
+
if (!ok.ok) return;
|
|
114
|
+
expect(h.auth.authenticate(ok.data.token)?.id).toBe(ok.data.session.id);
|
|
115
|
+
h.auth.logout(ok.data.token);
|
|
116
|
+
expect(h.auth.authenticate(ok.data.token)).toBeNull();
|
|
117
|
+
expect(h.ended).toContain(ok.data.session.id);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("ganti kunci wajib sandi lama & mencabut semua sesi lain", async () => {
|
|
121
|
+
const h = harness();
|
|
122
|
+
const first = await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
123
|
+
const other = await h.auth.login("482915", { ...ctx, ip: "2.2.2.2" });
|
|
124
|
+
if (!first.ok || !other.ok) throw new Error("setup gagal");
|
|
125
|
+
|
|
126
|
+
expect(await h.auth.setLock({ kind: "password", secret: "new password!" }, ctx)).toEqual({
|
|
127
|
+
ok: false,
|
|
128
|
+
error: "CURRENT_SECRET_INVALID",
|
|
129
|
+
});
|
|
130
|
+
const changed = await h.auth.setLock(
|
|
131
|
+
{ kind: "password", secret: "new password!", current: "482915" },
|
|
132
|
+
ctx,
|
|
133
|
+
);
|
|
134
|
+
expect(changed.ok).toBe(true);
|
|
135
|
+
if (!changed.ok) return;
|
|
136
|
+
expect(h.auth.authenticate(first.data.token)).toBeNull();
|
|
137
|
+
expect(h.auth.authenticate(other.data.token)).toBeNull();
|
|
138
|
+
expect(h.auth.authenticate(changed.data.token)).not.toBeNull();
|
|
139
|
+
expect(h.auth.status(null, ctx.ip).lockKind).toBe("password");
|
|
140
|
+
expect((await h.auth.login("482915", ctx)).ok).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("hapus kunci wajib sandi saat ini", async () => {
|
|
144
|
+
const h = harness();
|
|
145
|
+
await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
146
|
+
expect((await h.auth.removeLock("000000", ctx)).ok).toBe(false);
|
|
147
|
+
expect(h.auth.isProtected()).toBe(true);
|
|
148
|
+
expect((await h.auth.removeLock("482915", ctx)).ok).toBe(true);
|
|
149
|
+
expect(h.auth.isProtected()).toBe(false);
|
|
150
|
+
expect(h.store.listAuthSessions()).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("kunci otomatis: sesi idle melewati batas ditolak & dihapus; aktivitas memperpanjang", async () => {
|
|
154
|
+
const h = harness();
|
|
155
|
+
const res = await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
156
|
+
if (!res.ok) throw new Error("setup gagal");
|
|
157
|
+
expect(h.auth.setAutoLock(5).ok).toBe(true);
|
|
158
|
+
|
|
159
|
+
// Aktif tiap 4 menit -> tetap hidup.
|
|
160
|
+
for (let i = 0; i < 3; i++) {
|
|
161
|
+
h.advance(4 * 60_000);
|
|
162
|
+
expect(h.auth.authenticate(res.data.token)).not.toBeNull();
|
|
163
|
+
}
|
|
164
|
+
// Diam 6 menit -> terkunci.
|
|
165
|
+
h.advance(6 * 60_000);
|
|
166
|
+
expect(h.auth.authenticate(res.data.token)).toBeNull();
|
|
167
|
+
expect(h.store.listAuthSessions()).toHaveLength(0);
|
|
168
|
+
expect(h.ended).toContain(res.data.session.id);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("auto-lock 0 = tidak pernah (hanya TTL 30 hari)", async () => {
|
|
172
|
+
const h = harness();
|
|
173
|
+
const res = await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
174
|
+
if (!res.ok) throw new Error("setup gagal");
|
|
175
|
+
h.auth.setAutoLock(0);
|
|
176
|
+
h.advance(10 * 24 * 60 * 60_000);
|
|
177
|
+
expect(h.auth.authenticate(res.data.token)).not.toBeNull();
|
|
178
|
+
h.advance(SESSION_TTL_MS);
|
|
179
|
+
expect(h.auth.authenticate(res.data.token)).toBeNull();
|
|
180
|
+
expect(h.auth.setAutoLock(7)).toEqual({ ok: false, error: "AUTO_LOCK_INVALID" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("isSessionAlive (WebSocket) ikut memperpanjang aktivitas", async () => {
|
|
184
|
+
const h = harness();
|
|
185
|
+
const res = await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
186
|
+
if (!res.ok) throw new Error("setup gagal");
|
|
187
|
+
h.auth.setAutoLock(5);
|
|
188
|
+
h.advance(4 * 60_000);
|
|
189
|
+
expect(h.auth.isSessionAlive(res.data.session.id)).toBe(true);
|
|
190
|
+
h.advance(4 * 60_000);
|
|
191
|
+
expect(h.auth.isSessionAlive(res.data.session.id)).toBe(true);
|
|
192
|
+
h.advance(6 * 60_000);
|
|
193
|
+
expect(h.auth.isSessionAlive(res.data.session.id)).toBe(false);
|
|
194
|
+
expect(h.auth.isSessionAlive("tidak-ada")).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("anti brute-force per IP: 5 gagal bebas, lalu ditunda; sandi benar pun ditolak selama jeda", async () => {
|
|
198
|
+
const h = harness();
|
|
199
|
+
await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
200
|
+
const attacker = { ip: "6.6.6.6", userAgent: null };
|
|
201
|
+
for (let i = 0; i < 4; i++) {
|
|
202
|
+
expect(await h.auth.login("000000", attacker)).toEqual({ ok: false, error: "AUTH_INVALID" });
|
|
203
|
+
}
|
|
204
|
+
const fifth = await h.auth.login("000000", attacker);
|
|
205
|
+
expect(!fifth.ok && fifth.error).toBe("AUTH_RATE_LIMITED:30");
|
|
206
|
+
// Selama jeda, bahkan sandi benar ditolak (tidak ada oracle).
|
|
207
|
+
const during = await h.auth.login("482915", attacker);
|
|
208
|
+
expect(!during.ok && during.error.startsWith("AUTH_RATE_LIMITED")).toBe(true);
|
|
209
|
+
expect(h.auth.status(null, attacker.ip).retryAfterSec).toBe(30);
|
|
210
|
+
// IP lain tidak terdampak (masih di bawah batas global).
|
|
211
|
+
expect((await h.auth.login("482915", ctx)).ok).toBe(true);
|
|
212
|
+
// Setelah jeda lewat, boleh mencoba lagi.
|
|
213
|
+
h.advance(31_000);
|
|
214
|
+
expect((await h.auth.login("482915", attacker)).ok).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("anti brute-force global: banyak IP berbeda tetap tertahan", async () => {
|
|
218
|
+
const h = harness();
|
|
219
|
+
await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
220
|
+
let limited = false;
|
|
221
|
+
for (let i = 0; i < 25; i++) {
|
|
222
|
+
const r = await h.auth.login("000000", { ip: `10.0.0.${i}`, userAgent: null });
|
|
223
|
+
if (!r.ok && r.error.startsWith("AUTH_RATE_LIMITED")) limited = true;
|
|
224
|
+
}
|
|
225
|
+
expect(limited).toBe(true);
|
|
226
|
+
const fresh = await h.auth.login("482915", { ip: "10.9.9.9", userAgent: null });
|
|
227
|
+
expect(!fresh.ok && fresh.error.startsWith("AUTH_RATE_LIMITED")).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("profil: nickname dirapikan & divalidasi; avatar disniff dan dibatasi ukurannya", async () => {
|
|
231
|
+
const h = harness();
|
|
232
|
+
expect(h.auth.setNickname(" Irsyad Ibad ")).toEqual({
|
|
233
|
+
ok: true,
|
|
234
|
+
data: { nickname: "Irsyad Ibad", avatarUrl: null, avatarPreset: null },
|
|
235
|
+
});
|
|
236
|
+
expect(h.auth.setNickname("x".repeat(41))).toEqual({ ok: false, error: "NICKNAME_TOO_LONG" });
|
|
237
|
+
expect(h.auth.setNickname("a\u0000b")).toEqual({ ok: false, error: "NICKNAME_INVALID" });
|
|
238
|
+
expect(h.auth.setNickname(" ").ok && h.auth.status(null, "x").profile.nickname).toBeNull();
|
|
239
|
+
|
|
240
|
+
const svg = new TextEncoder().encode("<svg><script>alert(1)</script></svg>");
|
|
241
|
+
expect(h.auth.setAvatar(svg, "image/png")).toEqual({
|
|
242
|
+
ok: false,
|
|
243
|
+
error: "UNSUPPORTED_IMAGE_MIME",
|
|
244
|
+
});
|
|
245
|
+
expect(h.auth.setAvatar(new Uint8Array(AVATAR_MAX_BYTES + 1), "image/png")).toEqual({
|
|
246
|
+
ok: false,
|
|
247
|
+
error: "AVATAR_TOO_LARGE",
|
|
248
|
+
});
|
|
249
|
+
const up = h.auth.setAvatar(PNG, "image/png");
|
|
250
|
+
expect(up.ok && up.data.avatarUrl).toBe("/api/auth/avatar?v=1");
|
|
251
|
+
expect(h.auth.getAvatar()?.mime).toBe("image/png");
|
|
252
|
+
const again = h.auth.setAvatar(PNG, "image/png");
|
|
253
|
+
expect(again.ok && again.data.avatarUrl).toBe("/api/auth/avatar?v=2");
|
|
254
|
+
expect(h.auth.setAvatar(null, null).ok).toBe(true);
|
|
255
|
+
expect(h.auth.getAvatar()).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("avatar preset: valid dipakai; tak dikenal ditolak; eksklusif dengan foto", () => {
|
|
259
|
+
const h = harness();
|
|
260
|
+
expect(h.auth.setAvatarPreset("../etc")).toEqual({ ok: false, error: "AVATAR_PRESET_INVALID" });
|
|
261
|
+
const cat = h.auth.setAvatarPreset("cat");
|
|
262
|
+
expect(cat.ok && cat.data).toEqual({ nickname: null, avatarUrl: null, avatarPreset: "cat" });
|
|
263
|
+
|
|
264
|
+
// Upload foto menggantikan preset.
|
|
265
|
+
const photo = h.auth.setAvatar(PNG, "image/png");
|
|
266
|
+
expect(photo.ok && photo.data.avatarPreset).toBeNull();
|
|
267
|
+
expect(photo.ok && photo.data.avatarUrl).toMatch(/^\/api\/auth\/avatar\?v=\d+$/);
|
|
268
|
+
|
|
269
|
+
// Pilih preset lagi menghapus foto.
|
|
270
|
+
const rocket = h.auth.setAvatarPreset("rocket");
|
|
271
|
+
expect(rocket.ok && rocket.data).toEqual({
|
|
272
|
+
nickname: null,
|
|
273
|
+
avatarUrl: null,
|
|
274
|
+
avatarPreset: "rocket",
|
|
275
|
+
});
|
|
276
|
+
expect(h.auth.getAvatar()).toBeNull();
|
|
277
|
+
|
|
278
|
+
// Hapus avatar -> kosong semua.
|
|
279
|
+
expect(h.auth.setAvatar(null, null).ok).toBe(true);
|
|
280
|
+
expect(h.auth.status(null, "x").profile).toEqual({
|
|
281
|
+
nickname: null,
|
|
282
|
+
avatarUrl: null,
|
|
283
|
+
avatarPreset: null,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("perangkat: daftar, cabut lainnya, cabut satu", async () => {
|
|
288
|
+
const h = harness();
|
|
289
|
+
const me = await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
290
|
+
const b = await h.auth.login("482915", { ip: "2.2.2.2", userAgent: "phone" });
|
|
291
|
+
const c = await h.auth.login("482915", { ip: "3.3.3.3", userAgent: "tablet" });
|
|
292
|
+
if (!me.ok || !b.ok || !c.ok) throw new Error("setup gagal");
|
|
293
|
+
const list = h.auth.listDevices(me.data.token);
|
|
294
|
+
expect(list).toHaveLength(3);
|
|
295
|
+
expect(list.filter((d) => d.current).map((d) => d.id)).toEqual([me.data.session.id]);
|
|
296
|
+
|
|
297
|
+
h.auth.revokeDevice(b.data.session.id);
|
|
298
|
+
expect(h.auth.authenticate(b.data.token)).toBeNull();
|
|
299
|
+
h.auth.revokeOthers(me.data.token);
|
|
300
|
+
expect(h.auth.authenticate(c.data.token)).toBeNull();
|
|
301
|
+
expect(h.auth.authenticate(me.data.token)).not.toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("resetLock (CLI): kunci & semua sesi terhapus", async () => {
|
|
305
|
+
const h = harness();
|
|
306
|
+
await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
307
|
+
h.auth.resetLock();
|
|
308
|
+
expect(h.auth.isProtected()).toBe(false);
|
|
309
|
+
expect(h.store.listAuthSessions()).toHaveLength(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("hash default = argon2id (tanpa injeksi)", async () => {
|
|
313
|
+
const store = openSessionStore(":memory:");
|
|
314
|
+
const auth = createAuthService({ store });
|
|
315
|
+
const res = await auth.setLock({ kind: "password", secret: "correct horse" }, ctx);
|
|
316
|
+
expect(res.ok).toBe(true);
|
|
317
|
+
expect(store.getAuthSettings(0).lockHash?.startsWith("$argon2id$")).toBe(true);
|
|
318
|
+
expect((await auth.login("correct horse", ctx)).ok).toBe(true);
|
|
319
|
+
expect((await auth.login("wrong horse!", ctx)).ok).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("onboarding (welcome screen)", () => {
|
|
324
|
+
test("instalasi baru -> needsOnboarding; selesai -> false (idempoten)", () => {
|
|
325
|
+
const h = harness();
|
|
326
|
+
expect(h.auth.status(null, ctx.ip).needsOnboarding).toBe(true);
|
|
327
|
+
h.auth.completeOnboarding();
|
|
328
|
+
expect(h.auth.status(null, ctx.ip).needsOnboarding).toBe(false);
|
|
329
|
+
const first = h.store.getAuthSettings(0).onboardedAt;
|
|
330
|
+
h.advance(1000);
|
|
331
|
+
h.auth.completeOnboarding();
|
|
332
|
+
expect(h.store.getAuthSettings(0).onboardedAt).toBe(first);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("mengatur kunci menandai onboarding selesai; app terkunci tak pernah needsOnboarding", async () => {
|
|
336
|
+
const h = harness();
|
|
337
|
+
await h.auth.setLock({ kind: "pin", secret: "482915" }, ctx);
|
|
338
|
+
expect(h.store.getAuthSettings(0).onboardedAt).not.toBeNull();
|
|
339
|
+
expect(h.auth.status(null, ctx.ip).needsOnboarding).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createFrpcInstaller, FRP_VERSION } from "../frpc-installer";
|
|
6
|
+
|
|
7
|
+
let dirs: string[] = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
10
|
+
dirs = [];
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Build a real frp-shaped .tar.gz with a fake frpc script inside. */
|
|
14
|
+
async function fakeRelease(): Promise<{ bytes: ArrayBuffer; sha256: string }> {
|
|
15
|
+
const src = mkdtempSync(path.join(tmpdir(), "kcg-frp-src-"));
|
|
16
|
+
dirs.push(src);
|
|
17
|
+
const name = `frp_${FRP_VERSION}_linux_amd64`;
|
|
18
|
+
mkdirSync(path.join(src, name));
|
|
19
|
+
await Bun.write(path.join(src, name, "frpc"), "#!/bin/sh\necho fake\n");
|
|
20
|
+
const archive = path.join(src, `${name}.tar.gz`);
|
|
21
|
+
const proc = Bun.spawn(["tar", "-czf", archive, "-C", src, name]);
|
|
22
|
+
expect(await proc.exited).toBe(0);
|
|
23
|
+
const bytes = await Bun.file(archive).arrayBuffer();
|
|
24
|
+
return { bytes, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex") };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("frpc installer", () => {
|
|
28
|
+
test("rejects an archive whose checksum does not match the pinned one", async () => {
|
|
29
|
+
const { bytes } = await fakeRelease();
|
|
30
|
+
const binDir = mkdtempSync(path.join(tmpdir(), "kcg-frpc-test-"));
|
|
31
|
+
dirs.push(binDir);
|
|
32
|
+
const installer = createFrpcInstaller({
|
|
33
|
+
binDir,
|
|
34
|
+
platform: "linux",
|
|
35
|
+
arch: "x64",
|
|
36
|
+
which: () => null,
|
|
37
|
+
fetch: (async () => new Response(bytes)) as unknown as typeof fetch,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const r = await installer.ensure();
|
|
41
|
+
expect(r).toEqual({ ok: false, error: "FRPC_CHECKSUM_MISMATCH" });
|
|
42
|
+
expect(existsSync(path.join(binDir, "frpc"))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("installs across filesystems (tmpdir -> home) without EXDEV, atomically", async () => {
|
|
46
|
+
const { bytes, sha256 } = await fakeRelease();
|
|
47
|
+
// binDir under $HOME while extraction happens in tmpdir(): on many systems
|
|
48
|
+
// /tmp is tmpfs, so a plain rename() would fail with EXDEV.
|
|
49
|
+
const binDir = mkdtempSync(path.join(homedir(), ".kcg-frpc-test-"));
|
|
50
|
+
dirs.push(binDir);
|
|
51
|
+
const installer = createFrpcInstaller({
|
|
52
|
+
binDir,
|
|
53
|
+
platform: "linux",
|
|
54
|
+
arch: "x64",
|
|
55
|
+
which: () => null,
|
|
56
|
+
fetch: (async () => new Response(bytes)) as unknown as typeof fetch,
|
|
57
|
+
sha256Override: sha256,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const r = await installer.ensure();
|
|
61
|
+
expect(r.ok).toBe(true);
|
|
62
|
+
const target = path.join(binDir, "frpc");
|
|
63
|
+
expect(r.ok && r.data).toBe(target);
|
|
64
|
+
expect(statSync(target).mode & 0o777).toBe(0o755);
|
|
65
|
+
expect(readdirSync(binDir)).toEqual(["frpc"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("prefers an existing binary and does not download", async () => {
|
|
69
|
+
const binDir = mkdtempSync(path.join(tmpdir(), "kcg-frpc-test-"));
|
|
70
|
+
dirs.push(binDir);
|
|
71
|
+
let fetched = false;
|
|
72
|
+
const installer = createFrpcInstaller({
|
|
73
|
+
binDir,
|
|
74
|
+
which: () => "/usr/local/bin/frpc",
|
|
75
|
+
fetch: (async () => {
|
|
76
|
+
fetched = true;
|
|
77
|
+
return new Response("");
|
|
78
|
+
}) as unknown as typeof fetch,
|
|
79
|
+
});
|
|
80
|
+
expect(await installer.ensure()).toEqual({ ok: true, data: "/usr/local/bin/frpc" });
|
|
81
|
+
expect(fetched).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test `git-info.ts` — dijalankan pada repo git SUNGGUHAN di tmpdir
|
|
3
|
+
* (bukan mock `Bun.spawn`), karena yang rawan salah justru penafsiran output
|
|
4
|
+
* git: urutan `--left-right` pada `rev-list` mudah tertukar antara
|
|
5
|
+
* ahead & behind.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { readGitInfo } from "../git-info";
|
|
12
|
+
|
|
13
|
+
async function run(cwd: string, args: string[]): Promise<void> {
|
|
14
|
+
const proc = Bun.spawn(args, { cwd, stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
15
|
+
await proc.exited;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function commit(cwd: string, name: string): Promise<void> {
|
|
19
|
+
writeFileSync(path.join(cwd, name), name);
|
|
20
|
+
await run(cwd, ["git", "add", "."]);
|
|
21
|
+
await run(cwd, ["git", "commit", "-m", name]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("cabang, ahead/behind, dan remote dibaca dari repo sungguhan", async () => {
|
|
25
|
+
const root = mkdtempSync(path.join(tmpdir(), "kcg-git-"));
|
|
26
|
+
const remote = path.join(root, "remote.git");
|
|
27
|
+
const work = path.join(root, "work");
|
|
28
|
+
try {
|
|
29
|
+
await run(root, ["git", "init", "--bare", "-b", "main", remote]);
|
|
30
|
+
await run(root, ["git", "clone", remote, work]);
|
|
31
|
+
await run(work, ["git", "config", "user.email", "t@example.com"]);
|
|
32
|
+
await run(work, ["git", "config", "user.name", "Tester"]);
|
|
33
|
+
await commit(work, "a.txt");
|
|
34
|
+
await run(work, ["git", "push", "-u", "origin", "main"]);
|
|
35
|
+
|
|
36
|
+
const synced = await readGitInfo(work);
|
|
37
|
+
expect(synced.branch).toBe("main");
|
|
38
|
+
expect(synced.hasRemote).toBe(true);
|
|
39
|
+
expect(synced.ahead).toBe(0);
|
|
40
|
+
expect(synced.behind).toBe(0);
|
|
41
|
+
|
|
42
|
+
// Dua commit lokal yang belum dipush = ahead 2 (BUKAN behind).
|
|
43
|
+
await commit(work, "b.txt");
|
|
44
|
+
await commit(work, "c.txt");
|
|
45
|
+
const ahead = await readGitInfo(work);
|
|
46
|
+
expect(ahead.ahead).toBe(2);
|
|
47
|
+
expect(ahead.behind).toBe(0);
|
|
48
|
+
|
|
49
|
+
// Cabang baru tanpa upstream -> selisih tidak diketahui, bukan 0.
|
|
50
|
+
await run(work, ["git", "checkout", "-b", "feature"]);
|
|
51
|
+
const noUpstream = await readGitInfo(work);
|
|
52
|
+
expect(noUpstream.branch).toBe("feature");
|
|
53
|
+
expect(noUpstream.ahead).toBeNull();
|
|
54
|
+
expect(noUpstream.behind).toBeNull();
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("direktori biasa (bukan repo git) -> info kosong, bukan error", async () => {
|
|
61
|
+
const dir = mkdtempSync(path.join(tmpdir(), "kcg-nogit-"));
|
|
62
|
+
try {
|
|
63
|
+
const info = await readGitInfo(dir);
|
|
64
|
+
expect(info).toEqual({ branch: null, ahead: null, behind: null, hasRemote: false });
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("path yang tidak ada -> info kosong (tidak melempar)", async () => {
|
|
71
|
+
const info = await readGitInfo("/tmp/kcg-does-not-exist-xyz");
|
|
72
|
+
expect(info.branch).toBeNull();
|
|
73
|
+
expect(info.hasRemote).toBe(false);
|
|
74
|
+
});
|