@vibe80/vibe80 0.1.1
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/LICENSE +201 -0
- package/README.md +52 -0
- package/bin/vibe80.js +176 -0
- package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
- package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
- package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
- package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
- package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
- package/client/dist/assets/browser-e3WgtMs-.js +8 -0
- package/client/dist/assets/index-CgqGyssr.css +32 -0
- package/client/dist/assets/index-DnwKjoj7.js +706 -0
- package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
- package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/favicon.png +0 -0
- package/client/dist/favicon.svg +35 -0
- package/client/dist/index.html +14 -0
- package/client/index.html +16 -0
- package/client/package.json +34 -0
- package/client/public/favicon.ico +0 -0
- package/client/public/favicon.png +0 -0
- package/client/public/favicon.svg +35 -0
- package/client/public/pwa-192x192.png +0 -0
- package/client/public/pwa-512x512.png +0 -0
- package/client/src/App.jsx +3131 -0
- package/client/src/assets/logo_small.png +0 -0
- package/client/src/assets/vibe80_dark.svg +51 -0
- package/client/src/assets/vibe80_light.svg +50 -0
- package/client/src/components/Chat/ChatComposer.jsx +228 -0
- package/client/src/components/Chat/ChatMessages.jsx +811 -0
- package/client/src/components/Chat/ChatToolbar.jsx +109 -0
- package/client/src/components/Chat/useChatComposer.js +462 -0
- package/client/src/components/Diff/DiffPanel.jsx +129 -0
- package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
- package/client/src/components/Logs/LogsPanel.jsx +80 -0
- package/client/src/components/SessionGate/SessionGate.jsx +874 -0
- package/client/src/components/Settings/SettingsPanel.jsx +212 -0
- package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
- package/client/src/components/Topbar/Topbar.jsx +101 -0
- package/client/src/components/WorktreeTabs.css +419 -0
- package/client/src/components/WorktreeTabs.jsx +604 -0
- package/client/src/hooks/useAttachments.jsx +125 -0
- package/client/src/hooks/useBacklog.js +254 -0
- package/client/src/hooks/useChatClear.js +90 -0
- package/client/src/hooks/useChatCollapse.js +42 -0
- package/client/src/hooks/useChatCommands.js +294 -0
- package/client/src/hooks/useChatExport.js +144 -0
- package/client/src/hooks/useChatMessagesState.js +69 -0
- package/client/src/hooks/useChatSend.js +158 -0
- package/client/src/hooks/useChatSocket.js +1239 -0
- package/client/src/hooks/useDiffNavigation.js +19 -0
- package/client/src/hooks/useExplorerActions.js +1184 -0
- package/client/src/hooks/useGitIdentity.js +114 -0
- package/client/src/hooks/useLayoutMode.js +31 -0
- package/client/src/hooks/useLocalPreferences.js +131 -0
- package/client/src/hooks/useMessageSync.js +30 -0
- package/client/src/hooks/useNotifications.js +132 -0
- package/client/src/hooks/usePaneNavigation.js +67 -0
- package/client/src/hooks/usePanelState.js +13 -0
- package/client/src/hooks/useProviderSelection.js +70 -0
- package/client/src/hooks/useRepoBranchesModels.js +218 -0
- package/client/src/hooks/useRepoStatus.js +350 -0
- package/client/src/hooks/useRpcLogActions.js +19 -0
- package/client/src/hooks/useRpcLogView.js +58 -0
- package/client/src/hooks/useSessionHandoff.js +97 -0
- package/client/src/hooks/useSessionLifecycle.js +287 -0
- package/client/src/hooks/useSessionReset.js +63 -0
- package/client/src/hooks/useSessionResync.js +77 -0
- package/client/src/hooks/useTerminalSession.js +328 -0
- package/client/src/hooks/useToolbarExport.js +27 -0
- package/client/src/hooks/useTurnInterrupt.js +43 -0
- package/client/src/hooks/useVibe80Forms.js +128 -0
- package/client/src/hooks/useWorkspaceAuth.js +932 -0
- package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
- package/client/src/hooks/useWorktrees.js +396 -0
- package/client/src/i18n.jsx +87 -0
- package/client/src/index.css +5147 -0
- package/client/src/locales/en.json +37 -0
- package/client/src/locales/fr.json +321 -0
- package/client/src/main.jsx +16 -0
- package/client/vite.config.js +62 -0
- package/docs/api/asyncapi.json +1511 -0
- package/docs/api/openapi.json +3242 -0
- package/git_hooks/prepare-commit-msg +35 -0
- package/package.json +36 -0
- package/server/package.json +29 -0
- package/server/scripts/rotate-workspace-secret.js +101 -0
- package/server/src/claudeClient.js +454 -0
- package/server/src/clientEvents.js +594 -0
- package/server/src/clientFactory.js +164 -0
- package/server/src/codexClient.js +468 -0
- package/server/src/config.js +27 -0
- package/server/src/helpers.js +138 -0
- package/server/src/index.js +1641 -0
- package/server/src/middleware/auth.js +93 -0
- package/server/src/middleware/debug.js +89 -0
- package/server/src/middleware/errorTypes.js +60 -0
- package/server/src/providerLogger.js +60 -0
- package/server/src/routes/files.js +114 -0
- package/server/src/routes/git.js +183 -0
- package/server/src/routes/health.js +13 -0
- package/server/src/routes/sessions.js +407 -0
- package/server/src/routes/workspaces.js +296 -0
- package/server/src/routes/worktrees.js +993 -0
- package/server/src/runAs.js +458 -0
- package/server/src/runtimeStore.js +32 -0
- package/server/src/services/auth.js +157 -0
- package/server/src/services/claudeThreadDirectory.js +33 -0
- package/server/src/services/session.js +918 -0
- package/server/src/services/workspace.js +858 -0
- package/server/src/storage/index.js +17 -0
- package/server/src/storage/redis.js +412 -0
- package/server/src/storage/sqlite.js +649 -0
- package/server/src/worktreeManager.js +717 -0
- package/server/tests/README.md +13 -0
- package/server/tests/factories/workspaceFactory.js +13 -0
- package/server/tests/fixtures/workspaceCredentials.json +4 -0
- package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
- package/server/tests/setup/env.js +9 -0
- package/server/tests/unit/helpers.test.js +95 -0
- package/server/tests/unit/services/auth.test.js +181 -0
- package/server/tests/unit/services/workspace.test.js +115 -0
- package/server/vitest.config.js +23 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Tests serveur
|
|
2
|
+
|
|
3
|
+
## Commandes
|
|
4
|
+
|
|
5
|
+
- `npm --workspace server run test`
|
|
6
|
+
- `npm --workspace server run test:watch`
|
|
7
|
+
- `npm --workspace server run test:coverage`
|
|
8
|
+
|
|
9
|
+
## Structure
|
|
10
|
+
|
|
11
|
+
- `tests/setup/` : bootstrap d'environnement
|
|
12
|
+
- `tests/unit/` : tests unitaires
|
|
13
|
+
- `tests/integration/` : tests d'intégration des routes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const makeWorkspaceCredentials = (overrides = {}) => ({
|
|
2
|
+
workspaceId: "w0123456789abcdef01234567",
|
|
3
|
+
workspaceSecret: "test-workspace-secret",
|
|
4
|
+
...overrides,
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const makeWorkspaceTokens = (overrides = {}) => ({
|
|
8
|
+
workspaceToken: "workspace-token",
|
|
9
|
+
refreshToken: "refresh-token",
|
|
10
|
+
expiresIn: 3600,
|
|
11
|
+
refreshExpiresIn: 2592000,
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { makeWorkspaceCredentials } from "../../factories/workspaceFactory.js";
|
|
3
|
+
|
|
4
|
+
const verifyWorkspaceSecretMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const issueWorkspaceTokensMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const rotateWorkspaceRefreshTokenMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
const consumeMonoAuthTokenMock = vi.hoisted(() => vi.fn());
|
|
8
|
+
const createWorkspaceMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
const appendAuditLogMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
const getWorkspaceUserIdsMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
const readWorkspaceConfigMock = vi.hoisted(() => vi.fn());
|
|
12
|
+
const sanitizeProvidersForResponseMock = vi.hoisted(() => vi.fn((providers) => providers || {}));
|
|
13
|
+
const updateWorkspaceMock = vi.hoisted(() => vi.fn());
|
|
14
|
+
const mergeProvidersForUpdateMock = vi.hoisted(() =>
|
|
15
|
+
vi.fn((existing, incoming) => ({ ...existing, ...incoming }))
|
|
16
|
+
);
|
|
17
|
+
const storageMock = vi.hoisted(() => ({
|
|
18
|
+
listSessions: vi.fn(async () => []),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("../../../src/storage/index.js", () => ({
|
|
22
|
+
default: storageMock,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("../../../src/services/auth.js", () => ({
|
|
26
|
+
consumeMonoAuthToken: consumeMonoAuthTokenMock,
|
|
27
|
+
issueWorkspaceTokens: issueWorkspaceTokensMock,
|
|
28
|
+
rotateWorkspaceRefreshToken: rotateWorkspaceRefreshTokenMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("../../../src/runtimeStore.js", () => ({
|
|
32
|
+
getExistingSessionRuntime: vi.fn(() => null),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../../../src/services/workspace.js", () => ({
|
|
36
|
+
workspaceIdPattern: /^w[0-9a-f]{24}$/,
|
|
37
|
+
createWorkspace: createWorkspaceMock,
|
|
38
|
+
updateWorkspace: updateWorkspaceMock,
|
|
39
|
+
readWorkspaceConfig: readWorkspaceConfigMock,
|
|
40
|
+
verifyWorkspaceSecret: verifyWorkspaceSecretMock,
|
|
41
|
+
getWorkspaceUserIds: getWorkspaceUserIdsMock,
|
|
42
|
+
sanitizeProvidersForResponse: sanitizeProvidersForResponseMock,
|
|
43
|
+
mergeProvidersForUpdate: mergeProvidersForUpdateMock,
|
|
44
|
+
appendAuditLog: appendAuditLogMock,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
describe("routes/workspaces", () => {
|
|
48
|
+
const validCredentials = makeWorkspaceCredentials();
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
process.env.DEPLOYMENT_MODE = "multi_user";
|
|
53
|
+
issueWorkspaceTokensMock.mockResolvedValue({
|
|
54
|
+
workspaceToken: "workspace-token",
|
|
55
|
+
refreshToken: "refresh-token",
|
|
56
|
+
expiresIn: 3600,
|
|
57
|
+
refreshExpiresIn: 86400,
|
|
58
|
+
});
|
|
59
|
+
consumeMonoAuthTokenMock.mockReturnValue({
|
|
60
|
+
ok: false,
|
|
61
|
+
code: "MONO_AUTH_TOKEN_INVALID",
|
|
62
|
+
});
|
|
63
|
+
getWorkspaceUserIdsMock.mockResolvedValue({ uid: 200001, gid: 200001 });
|
|
64
|
+
appendAuditLogMock.mockResolvedValue();
|
|
65
|
+
storageMock.listSessions.mockResolvedValue([]);
|
|
66
|
+
rotateWorkspaceRefreshTokenMock.mockResolvedValue({
|
|
67
|
+
ok: true,
|
|
68
|
+
payload: {
|
|
69
|
+
workspaceToken: "workspace-token",
|
|
70
|
+
refreshToken: "refresh-token",
|
|
71
|
+
expiresIn: 3600,
|
|
72
|
+
refreshExpiresIn: 86400,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
createWorkspaceMock.mockResolvedValue({
|
|
76
|
+
workspaceId: validCredentials.workspaceId,
|
|
77
|
+
workspaceSecret: validCredentials.workspaceSecret,
|
|
78
|
+
});
|
|
79
|
+
readWorkspaceConfigMock.mockResolvedValue({
|
|
80
|
+
providers: {
|
|
81
|
+
codex: { enabled: true },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
updateWorkspaceMock.mockResolvedValue({
|
|
85
|
+
providers: {
|
|
86
|
+
codex: { enabled: true },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("POST /api/v1/workspaces/login mono_auth_token renvoie 403 hors mono_user", async () => {
|
|
92
|
+
process.env.DEPLOYMENT_MODE = "multi_user";
|
|
93
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
94
|
+
const res = createMockRes();
|
|
95
|
+
await handler(
|
|
96
|
+
{ body: { grantType: "mono_auth_token", monoAuthToken: "token-1" } },
|
|
97
|
+
res
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(res.statusCode).toBe(403);
|
|
101
|
+
expect(res.body).toMatchObject({
|
|
102
|
+
error_type: "MONO_AUTH_FORBIDDEN",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("POST /api/v1/workspaces renvoie 403 en mode mono_user", async () => {
|
|
107
|
+
process.env.DEPLOYMENT_MODE = "mono_user";
|
|
108
|
+
const handler = await createRouteHandler("/workspaces", "post");
|
|
109
|
+
const res = createMockRes();
|
|
110
|
+
await handler(
|
|
111
|
+
{
|
|
112
|
+
body: {
|
|
113
|
+
providers: { codex: { enabled: true, auth: { type: "api_key", value: "x" } } },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
res
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(res.statusCode).toBe(403);
|
|
120
|
+
expect(res.body).toMatchObject({
|
|
121
|
+
error_type: "WORKSPACE_CREATE_FORBIDDEN",
|
|
122
|
+
});
|
|
123
|
+
expect(createWorkspaceMock).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("POST /api/v1/workspaces/login credentials renvoie 403 en mode mono_user", async () => {
|
|
127
|
+
process.env.DEPLOYMENT_MODE = "mono_user";
|
|
128
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
129
|
+
const res = createMockRes();
|
|
130
|
+
await handler(
|
|
131
|
+
{
|
|
132
|
+
body: {
|
|
133
|
+
workspaceId: validCredentials.workspaceId,
|
|
134
|
+
workspaceSecret: "any-secret",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
res
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(res.statusCode).toBe(403);
|
|
141
|
+
expect(res.body).toMatchObject({
|
|
142
|
+
error_type: "WORKSPACE_LOGIN_FORBIDDEN",
|
|
143
|
+
});
|
|
144
|
+
expect(verifyWorkspaceSecretMock).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("POST /api/v1/workspaces/login mono_auth_token renvoie 400 si token manquant", async () => {
|
|
148
|
+
process.env.DEPLOYMENT_MODE = "mono_user";
|
|
149
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
150
|
+
const res = createMockRes();
|
|
151
|
+
await handler({ body: { grantType: "mono_auth_token" } }, res);
|
|
152
|
+
|
|
153
|
+
expect(res.statusCode).toBe(400);
|
|
154
|
+
expect(res.body).toMatchObject({
|
|
155
|
+
error_type: "MONO_AUTH_TOKEN_REQUIRED",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("POST /api/v1/workspaces/login mono_auth_token renvoie 401 si token invalide", async () => {
|
|
160
|
+
process.env.DEPLOYMENT_MODE = "mono_user";
|
|
161
|
+
consumeMonoAuthTokenMock.mockReturnValueOnce({
|
|
162
|
+
ok: false,
|
|
163
|
+
code: "MONO_AUTH_TOKEN_INVALID",
|
|
164
|
+
});
|
|
165
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
166
|
+
const res = createMockRes();
|
|
167
|
+
await handler(
|
|
168
|
+
{ body: { grantType: "mono_auth_token", monoAuthToken: "bad-token" } },
|
|
169
|
+
res
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(res.statusCode).toBe(401);
|
|
173
|
+
expect(res.body).toMatchObject({
|
|
174
|
+
error_type: "MONO_AUTH_TOKEN_INVALID",
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("POST /api/v1/workspaces/login mono_auth_token renvoie 401 si workspace non résolu", async () => {
|
|
179
|
+
process.env.DEPLOYMENT_MODE = "mono_user";
|
|
180
|
+
consumeMonoAuthTokenMock.mockReturnValueOnce({
|
|
181
|
+
ok: true,
|
|
182
|
+
workspaceId: validCredentials.workspaceId,
|
|
183
|
+
});
|
|
184
|
+
getWorkspaceUserIdsMock.mockRejectedValueOnce(new Error("Workspace not found."));
|
|
185
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
186
|
+
const res = createMockRes();
|
|
187
|
+
await handler(
|
|
188
|
+
{ body: { grantType: "mono_auth_token", monoAuthToken: "token-x" } },
|
|
189
|
+
res
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(res.statusCode).toBe(401);
|
|
193
|
+
expect(res.body).toMatchObject({
|
|
194
|
+
error_type: "MONO_AUTH_TOKEN_INVALID",
|
|
195
|
+
});
|
|
196
|
+
expect(appendAuditLogMock).toHaveBeenCalledWith(
|
|
197
|
+
validCredentials.workspaceId,
|
|
198
|
+
"workspace_login_failed",
|
|
199
|
+
{ grantType: "mono_auth_token" }
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("POST /api/v1/workspaces/login mono_auth_token renvoie 200 en succès", async () => {
|
|
204
|
+
process.env.DEPLOYMENT_MODE = "mono_user";
|
|
205
|
+
consumeMonoAuthTokenMock.mockReturnValueOnce({
|
|
206
|
+
ok: true,
|
|
207
|
+
workspaceId: validCredentials.workspaceId,
|
|
208
|
+
});
|
|
209
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
210
|
+
const res = createMockRes();
|
|
211
|
+
await handler(
|
|
212
|
+
{ body: { grantType: "mono_auth_token", monoAuthToken: "token-ok" } },
|
|
213
|
+
res
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(res.statusCode).toBe(200);
|
|
217
|
+
expect(res.body).toMatchObject({
|
|
218
|
+
workspaceToken: "workspace-token",
|
|
219
|
+
refreshToken: "refresh-token",
|
|
220
|
+
});
|
|
221
|
+
expect(issueWorkspaceTokensMock).toHaveBeenCalledWith(validCredentials.workspaceId);
|
|
222
|
+
expect(appendAuditLogMock).toHaveBeenCalledWith(
|
|
223
|
+
validCredentials.workspaceId,
|
|
224
|
+
"workspace_login_success",
|
|
225
|
+
{ grantType: "mono_auth_token" }
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const createRouteHandler = async (path, method) => {
|
|
230
|
+
const { default: workspaceRoutes } = await import("../../../src/routes/workspaces.js");
|
|
231
|
+
const router = workspaceRoutes();
|
|
232
|
+
const layer = router.stack.find(
|
|
233
|
+
(entry) =>
|
|
234
|
+
entry.route &&
|
|
235
|
+
entry.route.path === path &&
|
|
236
|
+
Boolean(entry.route.methods?.[String(method || "").toLowerCase()])
|
|
237
|
+
);
|
|
238
|
+
if (!layer) {
|
|
239
|
+
throw new Error(`Route not found: ${method.toUpperCase()} ${path}`);
|
|
240
|
+
}
|
|
241
|
+
return layer.route.stack[0].handle;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const createMockRes = () => {
|
|
245
|
+
const res = {
|
|
246
|
+
statusCode: 200,
|
|
247
|
+
body: null,
|
|
248
|
+
status(code) {
|
|
249
|
+
this.statusCode = code;
|
|
250
|
+
return this;
|
|
251
|
+
},
|
|
252
|
+
json(payload) {
|
|
253
|
+
this.body = payload;
|
|
254
|
+
return this;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
return res;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
it("POST /api/v1/workspaces/login renvoie 401 si credentials manquants", async () => {
|
|
261
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
262
|
+
const res = createMockRes();
|
|
263
|
+
await handler({ body: {} }, res);
|
|
264
|
+
expect(res.statusCode).toBe(401);
|
|
265
|
+
expect(res.body.error).toBe("Invalid workspace credentials.");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("POST /api/v1/workspaces/login renvoie 401 si secret invalide", async () => {
|
|
269
|
+
verifyWorkspaceSecretMock.mockResolvedValueOnce(false);
|
|
270
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
271
|
+
const res = createMockRes();
|
|
272
|
+
await handler(
|
|
273
|
+
{
|
|
274
|
+
body: {
|
|
275
|
+
workspaceId: validCredentials.workspaceId,
|
|
276
|
+
workspaceSecret: "bad-secret",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
res
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(res.statusCode).toBe(401);
|
|
283
|
+
expect(res.body.error).toBe("Invalid workspace credentials.");
|
|
284
|
+
expect(appendAuditLogMock).toHaveBeenCalledWith(
|
|
285
|
+
validCredentials.workspaceId,
|
|
286
|
+
"workspace_login_failed"
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("POST /api/v1/workspaces/login renvoie 200 si credentials valides", async () => {
|
|
291
|
+
verifyWorkspaceSecretMock.mockResolvedValueOnce(true);
|
|
292
|
+
const handler = await createRouteHandler("/workspaces/login", "post");
|
|
293
|
+
const res = createMockRes();
|
|
294
|
+
await handler(
|
|
295
|
+
{
|
|
296
|
+
body: {
|
|
297
|
+
workspaceId: validCredentials.workspaceId,
|
|
298
|
+
workspaceSecret: "good-secret",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
res
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(res.statusCode).toBe(200);
|
|
305
|
+
expect(res.body).toMatchObject({
|
|
306
|
+
workspaceToken: "workspace-token",
|
|
307
|
+
refreshToken: "refresh-token",
|
|
308
|
+
});
|
|
309
|
+
expect(getWorkspaceUserIdsMock).toHaveBeenCalledWith(validCredentials.workspaceId);
|
|
310
|
+
expect(issueWorkspaceTokensMock).toHaveBeenCalledWith(validCredentials.workspaceId);
|
|
311
|
+
expect(appendAuditLogMock).toHaveBeenCalledWith(
|
|
312
|
+
validCredentials.workspaceId,
|
|
313
|
+
"workspace_login_success"
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("POST /api/v1/workspaces/refresh renvoie 400 si refreshToken manquant", async () => {
|
|
318
|
+
const handler = await createRouteHandler("/workspaces/refresh", "post");
|
|
319
|
+
const res = createMockRes();
|
|
320
|
+
await handler({ body: {} }, res);
|
|
321
|
+
|
|
322
|
+
expect(res.statusCode).toBe(400);
|
|
323
|
+
expect(res.body).toEqual({ error: "refreshToken is required." });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("POST /api/v1/workspaces/refresh renvoie 401 si refresh token invalide", async () => {
|
|
327
|
+
rotateWorkspaceRefreshTokenMock.mockResolvedValueOnce({
|
|
328
|
+
ok: false,
|
|
329
|
+
status: 401,
|
|
330
|
+
payload: {
|
|
331
|
+
error: "Invalid refresh token.",
|
|
332
|
+
code: "invalid_refresh_token",
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const handler = await createRouteHandler("/workspaces/refresh", "post");
|
|
336
|
+
const res = createMockRes();
|
|
337
|
+
await handler({ body: { refreshToken: "bad-refresh-token" } }, res);
|
|
338
|
+
|
|
339
|
+
expect(res.statusCode).toBe(401);
|
|
340
|
+
expect(res.body).toEqual({
|
|
341
|
+
error: "Invalid refresh token.",
|
|
342
|
+
code: "invalid_refresh_token",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("POST /api/v1/workspaces/refresh renvoie 401 si refresh token expiré", async () => {
|
|
347
|
+
rotateWorkspaceRefreshTokenMock.mockResolvedValueOnce({
|
|
348
|
+
ok: false,
|
|
349
|
+
status: 401,
|
|
350
|
+
payload: {
|
|
351
|
+
error: "Refresh token expired.",
|
|
352
|
+
code: "refresh_token_expired",
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const handler = await createRouteHandler("/workspaces/refresh", "post");
|
|
356
|
+
const res = createMockRes();
|
|
357
|
+
await handler({ body: { refreshToken: "expired-refresh-token" } }, res);
|
|
358
|
+
|
|
359
|
+
expect(res.statusCode).toBe(401);
|
|
360
|
+
expect(res.body).toEqual({
|
|
361
|
+
error: "Refresh token expired.",
|
|
362
|
+
code: "refresh_token_expired",
|
|
363
|
+
});
|
|
364
|
+
expect(rotateWorkspaceRefreshTokenMock).toHaveBeenCalledWith("expired-refresh-token");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("POST /api/v1/workspaces/refresh renvoie 401 si refresh token réutilisé", async () => {
|
|
368
|
+
rotateWorkspaceRefreshTokenMock.mockResolvedValueOnce({
|
|
369
|
+
ok: false,
|
|
370
|
+
status: 401,
|
|
371
|
+
payload: {
|
|
372
|
+
error: "Refresh token reused.",
|
|
373
|
+
code: "refresh_token_reused",
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
const handler = await createRouteHandler("/workspaces/refresh", "post");
|
|
377
|
+
const res = createMockRes();
|
|
378
|
+
await handler({ body: { refreshToken: "reused-refresh-token" } }, res);
|
|
379
|
+
|
|
380
|
+
expect(res.statusCode).toBe(401);
|
|
381
|
+
expect(res.body).toEqual({
|
|
382
|
+
error: "Refresh token reused.",
|
|
383
|
+
code: "refresh_token_reused",
|
|
384
|
+
});
|
|
385
|
+
expect(rotateWorkspaceRefreshTokenMock).toHaveBeenCalledWith("reused-refresh-token");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("POST /api/v1/workspaces/refresh renvoie 200 si refresh token valide", async () => {
|
|
389
|
+
rotateWorkspaceRefreshTokenMock.mockResolvedValueOnce({
|
|
390
|
+
ok: true,
|
|
391
|
+
payload: {
|
|
392
|
+
workspaceToken: "workspace-token",
|
|
393
|
+
refreshToken: "refresh-token",
|
|
394
|
+
expiresIn: 3600,
|
|
395
|
+
refreshExpiresIn: 86400,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const handler = await createRouteHandler("/workspaces/refresh", "post");
|
|
399
|
+
const res = createMockRes();
|
|
400
|
+
await handler({ body: { refreshToken: "valid-refresh-token" } }, res);
|
|
401
|
+
|
|
402
|
+
expect(res.statusCode).toBe(200);
|
|
403
|
+
expect(res.body).toMatchObject({
|
|
404
|
+
workspaceToken: "workspace-token",
|
|
405
|
+
refreshToken: "refresh-token",
|
|
406
|
+
});
|
|
407
|
+
expect(rotateWorkspaceRefreshTokenMock).toHaveBeenCalledWith("valid-refresh-token");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("POST /api/v1/workspaces/refresh renvoie 500 en cas d’erreur interne", async () => {
|
|
411
|
+
rotateWorkspaceRefreshTokenMock.mockRejectedValueOnce(new Error("storage down"));
|
|
412
|
+
const handler = await createRouteHandler("/workspaces/refresh", "post");
|
|
413
|
+
const res = createMockRes();
|
|
414
|
+
await handler({ body: { refreshToken: "valid-refresh-token" } }, res);
|
|
415
|
+
|
|
416
|
+
expect(res.statusCode).toBe(500);
|
|
417
|
+
expect(res.body).toEqual({ error: "Failed to refresh workspace token." });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("GET /api/v1/workspaces/:workspaceId renvoie 400 si workspaceId invalide", async () => {
|
|
421
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "get");
|
|
422
|
+
const res = createMockRes();
|
|
423
|
+
await handler({ params: { workspaceId: "invalid-id" } }, res);
|
|
424
|
+
|
|
425
|
+
expect(res.statusCode).toBe(400);
|
|
426
|
+
expect(res.body).toEqual({ error: "Invalid workspaceId." });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("GET /api/v1/workspaces/:workspaceId renvoie 403 si workspaceId ne correspond pas au token", async () => {
|
|
430
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "get");
|
|
431
|
+
const res = createMockRes();
|
|
432
|
+
await handler(
|
|
433
|
+
{
|
|
434
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
435
|
+
workspaceId: "wffffffffffffffffffffffff",
|
|
436
|
+
},
|
|
437
|
+
res
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
expect(res.statusCode).toBe(403);
|
|
441
|
+
expect(res.body).toEqual({ error: "Forbidden." });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("GET /api/v1/workspaces/:workspaceId renvoie 200 si accès autorisé", async () => {
|
|
445
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "get");
|
|
446
|
+
const res = createMockRes();
|
|
447
|
+
await handler(
|
|
448
|
+
{
|
|
449
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
450
|
+
workspaceId: validCredentials.workspaceId,
|
|
451
|
+
},
|
|
452
|
+
res
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(res.statusCode).toBe(200);
|
|
456
|
+
expect(res.body).toEqual({
|
|
457
|
+
workspaceId: validCredentials.workspaceId,
|
|
458
|
+
providers: {
|
|
459
|
+
codex: { enabled: true },
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
expect(readWorkspaceConfigMock).toHaveBeenCalledWith(validCredentials.workspaceId);
|
|
463
|
+
expect(sanitizeProvidersForResponseMock).toHaveBeenCalled();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("GET /api/v1/workspaces/:workspaceId renvoie 400 si la lecture échoue", async () => {
|
|
467
|
+
readWorkspaceConfigMock.mockRejectedValueOnce(new Error("Workspace not found."));
|
|
468
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "get");
|
|
469
|
+
const res = createMockRes();
|
|
470
|
+
await handler(
|
|
471
|
+
{
|
|
472
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
473
|
+
workspaceId: validCredentials.workspaceId,
|
|
474
|
+
},
|
|
475
|
+
res
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
expect(res.statusCode).toBe(400);
|
|
479
|
+
expect(res.body).toEqual({ error: "Workspace not found." });
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("PATCH /api/v1/workspaces/:workspaceId renvoie 400 si workspaceId invalide", async () => {
|
|
483
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "patch");
|
|
484
|
+
const res = createMockRes();
|
|
485
|
+
await handler({ params: { workspaceId: "invalid-id" }, body: {} }, res);
|
|
486
|
+
|
|
487
|
+
expect(res.statusCode).toBe(400);
|
|
488
|
+
expect(res.body).toEqual({ error: "Invalid workspaceId." });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("PATCH /api/v1/workspaces/:workspaceId renvoie 403 si accès interdit", async () => {
|
|
492
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "patch");
|
|
493
|
+
const res = createMockRes();
|
|
494
|
+
await handler(
|
|
495
|
+
{
|
|
496
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
497
|
+
workspaceId: "wffffffffffffffffffffffff",
|
|
498
|
+
body: {},
|
|
499
|
+
},
|
|
500
|
+
res
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
expect(res.statusCode).toBe(403);
|
|
504
|
+
expect(res.body).toEqual({ error: "Forbidden." });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("PATCH /api/v1/workspaces/:workspaceId renvoie 403 si provider actif en session", async () => {
|
|
508
|
+
mergeProvidersForUpdateMock.mockReturnValueOnce({
|
|
509
|
+
codex: { enabled: false },
|
|
510
|
+
});
|
|
511
|
+
readWorkspaceConfigMock.mockResolvedValueOnce({
|
|
512
|
+
providers: {
|
|
513
|
+
codex: { enabled: true },
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
storageMock.listSessions.mockResolvedValueOnce([
|
|
517
|
+
{ sessionId: "s1", activeProvider: "codex" },
|
|
518
|
+
]);
|
|
519
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "patch");
|
|
520
|
+
const res = createMockRes();
|
|
521
|
+
await handler(
|
|
522
|
+
{
|
|
523
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
524
|
+
workspaceId: validCredentials.workspaceId,
|
|
525
|
+
body: { providers: { codex: { enabled: false } } },
|
|
526
|
+
},
|
|
527
|
+
res
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
expect(res.statusCode).toBe(403);
|
|
531
|
+
expect(res.body).toEqual({
|
|
532
|
+
error: "Provider cannot be disabled: active sessions use it.",
|
|
533
|
+
});
|
|
534
|
+
expect(updateWorkspaceMock).not.toHaveBeenCalled();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("PATCH /api/v1/workspaces/:workspaceId renvoie 200 en succès", async () => {
|
|
538
|
+
mergeProvidersForUpdateMock.mockReturnValueOnce({
|
|
539
|
+
codex: { enabled: true },
|
|
540
|
+
});
|
|
541
|
+
updateWorkspaceMock.mockResolvedValueOnce({
|
|
542
|
+
providers: {
|
|
543
|
+
codex: { enabled: true },
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "patch");
|
|
547
|
+
const res = createMockRes();
|
|
548
|
+
await handler(
|
|
549
|
+
{
|
|
550
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
551
|
+
workspaceId: validCredentials.workspaceId,
|
|
552
|
+
body: { providers: { codex: { enabled: true } } },
|
|
553
|
+
},
|
|
554
|
+
res
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
expect(res.statusCode).toBe(200);
|
|
558
|
+
expect(res.body).toEqual({
|
|
559
|
+
workspaceId: validCredentials.workspaceId,
|
|
560
|
+
providers: {
|
|
561
|
+
codex: { enabled: true },
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
expect(updateWorkspaceMock).toHaveBeenCalledWith(validCredentials.workspaceId, {
|
|
565
|
+
codex: { enabled: true },
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("PATCH /api/v1/workspaces/:workspaceId renvoie 400 si update échoue", async () => {
|
|
570
|
+
updateWorkspaceMock.mockRejectedValueOnce(new Error("Failed to update workspace."));
|
|
571
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "patch");
|
|
572
|
+
const res = createMockRes();
|
|
573
|
+
await handler(
|
|
574
|
+
{
|
|
575
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
576
|
+
workspaceId: validCredentials.workspaceId,
|
|
577
|
+
body: { providers: { codex: { enabled: true } } },
|
|
578
|
+
},
|
|
579
|
+
res
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
expect(res.statusCode).toBe(400);
|
|
583
|
+
expect(res.body).toEqual({ error: "Failed to update workspace." });
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("DELETE /api/v1/workspaces/:workspaceId renvoie 400 si workspaceId invalide", async () => {
|
|
587
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "delete");
|
|
588
|
+
const res = createMockRes();
|
|
589
|
+
await handler({ params: { workspaceId: "invalid-id" } }, res);
|
|
590
|
+
|
|
591
|
+
expect(res.statusCode).toBe(400);
|
|
592
|
+
expect(res.body).toEqual({ error: "Invalid workspaceId." });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("DELETE /api/v1/workspaces/:workspaceId renvoie 403 si accès interdit", async () => {
|
|
596
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "delete");
|
|
597
|
+
const res = createMockRes();
|
|
598
|
+
await handler(
|
|
599
|
+
{
|
|
600
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
601
|
+
workspaceId: "wffffffffffffffffffffffff",
|
|
602
|
+
},
|
|
603
|
+
res
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
expect(res.statusCode).toBe(403);
|
|
607
|
+
expect(res.body).toEqual({ error: "Forbidden." });
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("DELETE /api/v1/workspaces/:workspaceId renvoie 405 (désactivé)", async () => {
|
|
611
|
+
const handler = await createRouteHandler("/workspaces/:workspaceId", "delete");
|
|
612
|
+
const res = createMockRes();
|
|
613
|
+
await handler(
|
|
614
|
+
{
|
|
615
|
+
params: { workspaceId: validCredentials.workspaceId },
|
|
616
|
+
workspaceId: validCredentials.workspaceId,
|
|
617
|
+
},
|
|
618
|
+
res
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
expect(res.statusCode).toBe(405);
|
|
622
|
+
expect(res.body).toEqual({
|
|
623
|
+
error: "Workspace deletion is currently disabled.",
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import os from "os";
|
|
3
|
+
|
|
4
|
+
process.env.NODE_ENV = "test";
|
|
5
|
+
process.env.STORAGE_BACKEND = process.env.STORAGE_BACKEND || "sqlite";
|
|
6
|
+
process.env.DEPLOYMENT_MODE = process.env.DEPLOYMENT_MODE || "multi_user";
|
|
7
|
+
process.env.JWT_KEY = process.env.JWT_KEY || "test-jwt-key";
|
|
8
|
+
process.env.JWT_KEY_PATH =
|
|
9
|
+
process.env.JWT_KEY_PATH || path.join(os.tmpdir(), "vibe80-jwt-test.key");
|