@jskit-ai/users-web 0.1.4
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/package.descriptor.mjs +507 -0
- package/package.json +31 -0
- package/src/client/components/ConsoleSettingsClientElement.vue +24 -0
- package/src/client/components/MembersAdminClientElement.vue +404 -0
- package/src/client/components/ProfileClientElement.vue +242 -0
- package/src/client/components/UsersProfileSurfaceSwitchMenuItem.vue +39 -0
- package/src/client/components/UsersShellMenuLinkItem.vue +140 -0
- package/src/client/components/UsersSurfaceAwareMenuLinkItem.vue +87 -0
- package/src/client/components/UsersWorkspaceMembersMenuItem.vue +36 -0
- package/src/client/components/UsersWorkspacePermissionMenuItem.vue +90 -0
- package/src/client/components/UsersWorkspaceSelector.vue +237 -0
- package/src/client/components/UsersWorkspaceSettingsMenuItem.vue +39 -0
- package/src/client/components/UsersWorkspaceToolsWidget.vue +23 -0
- package/src/client/components/WorkspaceMembersClientElement.vue +663 -0
- package/src/client/components/WorkspaceSettingsClientElement.vue +230 -0
- package/src/client/components/WorkspacesClientElement.vue +514 -0
- package/src/client/composables/accountSettingsAvatarUploadRuntime.js +241 -0
- package/src/client/composables/accountSettingsInvitesRuntime.js +88 -0
- package/src/client/composables/accountSettingsRuntimeConstants.js +77 -0
- package/src/client/composables/accountSettingsRuntimeHelpers.js +75 -0
- package/src/client/composables/errorMessageHelpers.js +66 -0
- package/src/client/composables/internal/useOperationScope.js +144 -0
- package/src/client/composables/modelStateHelpers.js +49 -0
- package/src/client/composables/operationUiHelpers.js +121 -0
- package/src/client/composables/operationValidationHelpers.js +52 -0
- package/src/client/composables/refValueHelpers.js +19 -0
- package/src/client/composables/scopeHelpers.js +145 -0
- package/src/client/composables/useAccess.js +109 -0
- package/src/client/composables/useAccountSettingsRuntime.js +533 -0
- package/src/client/composables/useAddEdit.js +135 -0
- package/src/client/composables/useAddEditCore.js +137 -0
- package/src/client/composables/useBootstrapQuery.js +52 -0
- package/src/client/composables/useCommand.js +112 -0
- package/src/client/composables/useCommandCore.js +130 -0
- package/src/client/composables/useEndpointResource.js +104 -0
- package/src/client/composables/useFieldErrorBag.js +61 -0
- package/src/client/composables/useList.js +85 -0
- package/src/client/composables/useListCore.js +65 -0
- package/src/client/composables/usePagedCollection.js +125 -0
- package/src/client/composables/usePaths.js +108 -0
- package/src/client/composables/useRealtimeQueryInvalidation.js +105 -0
- package/src/client/composables/useScopeRuntime.js +107 -0
- package/src/client/composables/useSurfaceRouteContext.js +31 -0
- package/src/client/composables/useUiFeedback.js +96 -0
- package/src/client/composables/useView.js +89 -0
- package/src/client/composables/useViewCore.js +104 -0
- package/src/client/composables/useWorkspaceRouteContext.js +28 -0
- package/src/client/composables/useWorkspaceSurfaceId.js +43 -0
- package/src/client/index.js +7 -0
- package/src/client/lib/bootstrap.js +95 -0
- package/src/client/lib/httpClient.js +67 -0
- package/src/client/lib/menuIcons.js +192 -0
- package/src/client/lib/permissions.js +34 -0
- package/src/client/lib/profileSurfaceMenuLinks.js +142 -0
- package/src/client/lib/surfaceAccessPolicy.js +350 -0
- package/src/client/lib/theme.js +99 -0
- package/src/client/lib/workspaceLinkResolver.js +207 -0
- package/src/client/lib/workspaceSurfaceContext.js +82 -0
- package/src/client/lib/workspaceSurfacePaths.js +163 -0
- package/src/client/providers/UsersWebClientProvider.js +85 -0
- package/src/client/runtime/bootstrapPlacementRouteGuards.js +371 -0
- package/src/client/runtime/bootstrapPlacementRuntime.js +413 -0
- package/src/client/runtime/bootstrapPlacementRuntimeConstants.js +32 -0
- package/src/client/runtime/bootstrapPlacementRuntimeHelpers.js +157 -0
- package/src/client/support/contractGuards.js +34 -0
- package/src/client/support/realtimeWorkspace.js +12 -0
- package/src/client/support/runtimeNormalization.js +27 -0
- package/src/client/support/workspaceQueryKeys.js +15 -0
- package/templates/packages/main/src/client/components/AccountPendingInvitesCue.vue +162 -0
- package/templates/src/components/WorkspaceNotFoundCard.vue +33 -0
- package/templates/src/components/account/settings/AccountSettingsClientElement.vue +153 -0
- package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +77 -0
- package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +55 -0
- package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +125 -0
- package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +94 -0
- package/templates/src/composables/useWorkspaceNotFoundState.js +48 -0
- package/templates/src/pages/account/index.vue +17 -0
- package/templates/src/pages/admin/members/index.vue +7 -0
- package/templates/src/pages/admin/workspace/settings/index.vue +16 -0
- package/templates/src/pages/console/settings/index.vue +16 -0
- package/templates/src/surfaces/admin/index.vue +29 -0
- package/templates/src/surfaces/admin/root.vue +20 -0
- package/templates/src/surfaces/app/index.vue +27 -0
- package/templates/src/surfaces/app/root.vue +20 -0
- package/test/bootstrap.test.js +38 -0
- package/test/bootstrapPlacementRuntime.test.js +991 -0
- package/test/errorMessageHelpers.test.js +28 -0
- package/test/exportsContract.test.js +39 -0
- package/test/menuIcons.test.js +33 -0
- package/test/permissions.test.js +35 -0
- package/test/profileSurfaceMenuLinks.test.js +207 -0
- package/test/refValueHelpers.test.js +14 -0
- package/test/scopeHelpers.test.js +57 -0
- package/test/surfaceAccessPolicy.test.js +129 -0
- package/test/theme.test.js +95 -0
- package/test/workspaceLinkResolver.test.js +61 -0
- package/test/workspaceSurfacePaths.test.js +39 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
CLIENT_MODULE_ROUTER_TOKEN,
|
|
5
|
+
CLIENT_MODULE_VUE_APP_TOKEN
|
|
6
|
+
} from "@jskit-ai/kernel/client/moduleBootstrap";
|
|
7
|
+
import { WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN } from "@jskit-ai/shell-web/client/placement";
|
|
8
|
+
import { REALTIME_SOCKET_CLIENT_TOKEN } from "@jskit-ai/realtime/client/tokens";
|
|
9
|
+
import { USERS_BOOTSTRAP_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
|
|
10
|
+
import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
|
|
11
|
+
import {
|
|
12
|
+
WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
|
|
13
|
+
WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND,
|
|
14
|
+
WORKSPACE_BOOTSTRAP_STATUS_RESOLVED,
|
|
15
|
+
createBootstrapPlacementRuntime
|
|
16
|
+
} from "../src/client/runtime/bootstrapPlacementRuntime.js";
|
|
17
|
+
|
|
18
|
+
const SHELL_GUARD_EVALUATOR_KEY = "__JSKIT_WEB_SHELL_GUARD_EVALUATOR__";
|
|
19
|
+
|
|
20
|
+
function flushTasks() {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
setTimeout(resolve, 0);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test.afterEach(() => {
|
|
27
|
+
try {
|
|
28
|
+
delete globalThis[SHELL_GUARD_EVALUATOR_KEY];
|
|
29
|
+
} catch {}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function createPlacementRuntimeStub() {
|
|
33
|
+
const listeners = new Set();
|
|
34
|
+
const setCalls = [];
|
|
35
|
+
let context = Object.freeze({
|
|
36
|
+
surfaceConfig: {
|
|
37
|
+
tenancyMode: "workspace",
|
|
38
|
+
defaultSurfaceId: "app",
|
|
39
|
+
enabledSurfaceIds: ["app", "admin", "home"],
|
|
40
|
+
surfacesById: {
|
|
41
|
+
home: {
|
|
42
|
+
id: "home",
|
|
43
|
+
enabled: true,
|
|
44
|
+
pagesRoot: "",
|
|
45
|
+
routeBase: "/",
|
|
46
|
+
requiresWorkspace: false
|
|
47
|
+
},
|
|
48
|
+
app: {
|
|
49
|
+
id: "app",
|
|
50
|
+
enabled: true,
|
|
51
|
+
pagesRoot: "w/[workspaceSlug]",
|
|
52
|
+
routeBase: "/w/:workspaceSlug",
|
|
53
|
+
requiresWorkspace: true
|
|
54
|
+
},
|
|
55
|
+
admin: {
|
|
56
|
+
id: "admin",
|
|
57
|
+
enabled: true,
|
|
58
|
+
pagesRoot: "w/[workspaceSlug]/admin",
|
|
59
|
+
routeBase: "/w/:workspaceSlug/admin",
|
|
60
|
+
requiresWorkspace: true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
getContext() {
|
|
68
|
+
return context;
|
|
69
|
+
},
|
|
70
|
+
setContext(patch = {}, { replace = false, source = "" } = {}) {
|
|
71
|
+
context = Object.freeze(
|
|
72
|
+
replace
|
|
73
|
+
? {
|
|
74
|
+
...patch
|
|
75
|
+
}
|
|
76
|
+
: {
|
|
77
|
+
...context,
|
|
78
|
+
...patch
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
setCalls.push({
|
|
82
|
+
patch,
|
|
83
|
+
source
|
|
84
|
+
});
|
|
85
|
+
for (const listener of listeners) {
|
|
86
|
+
listener({
|
|
87
|
+
type: "context.updated",
|
|
88
|
+
source
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return context;
|
|
92
|
+
},
|
|
93
|
+
subscribe(listener) {
|
|
94
|
+
if (typeof listener !== "function") {
|
|
95
|
+
return () => {};
|
|
96
|
+
}
|
|
97
|
+
listeners.add(listener);
|
|
98
|
+
return () => {
|
|
99
|
+
listeners.delete(listener);
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
setCalls
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolvePathFromFullPath(fullPath = "/") {
|
|
107
|
+
const normalizedFullPath = String(fullPath || "").trim() || "/";
|
|
108
|
+
const queryStart = normalizedFullPath.indexOf("?");
|
|
109
|
+
const hashStart = normalizedFullPath.indexOf("#");
|
|
110
|
+
const stopIndex = [queryStart, hashStart]
|
|
111
|
+
.filter((index) => index >= 0)
|
|
112
|
+
.sort((left, right) => left - right)[0];
|
|
113
|
+
if (typeof stopIndex !== "number") {
|
|
114
|
+
return normalizedFullPath;
|
|
115
|
+
}
|
|
116
|
+
return normalizedFullPath.slice(0, stopIndex) || "/";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createRouterStub(initialPath = "/w/acme/dashboard") {
|
|
120
|
+
const afterEachListeners = [];
|
|
121
|
+
const replaceCalls = [];
|
|
122
|
+
const normalizedInitialPath = String(initialPath || "").trim() || "/";
|
|
123
|
+
const router = {
|
|
124
|
+
currentRoute: {
|
|
125
|
+
value: {
|
|
126
|
+
path: resolvePathFromFullPath(normalizedInitialPath),
|
|
127
|
+
fullPath: normalizedInitialPath
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
afterEach(listener) {
|
|
131
|
+
afterEachListeners.push(listener);
|
|
132
|
+
return () => {
|
|
133
|
+
const index = afterEachListeners.indexOf(listener);
|
|
134
|
+
if (index >= 0) {
|
|
135
|
+
afterEachListeners.splice(index, 1);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
replace(target) {
|
|
140
|
+
const fullPath = String(target || "").trim() || "/";
|
|
141
|
+
router.currentRoute.value.path = resolvePathFromFullPath(fullPath);
|
|
142
|
+
router.currentRoute.value.fullPath = fullPath;
|
|
143
|
+
replaceCalls.push(fullPath);
|
|
144
|
+
for (const listener of [...afterEachListeners]) {
|
|
145
|
+
listener();
|
|
146
|
+
}
|
|
147
|
+
return Promise.resolve();
|
|
148
|
+
},
|
|
149
|
+
push(target) {
|
|
150
|
+
return router.replace(target);
|
|
151
|
+
},
|
|
152
|
+
emitAfterEach() {
|
|
153
|
+
for (const listener of [...afterEachListeners]) {
|
|
154
|
+
listener();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
replaceCalls
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return router;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createSocketStub() {
|
|
164
|
+
const listeners = new Map();
|
|
165
|
+
return {
|
|
166
|
+
on(eventName, handler) {
|
|
167
|
+
listeners.set(eventName, handler);
|
|
168
|
+
},
|
|
169
|
+
off(eventName, handler) {
|
|
170
|
+
if (listeners.get(eventName) === handler) {
|
|
171
|
+
listeners.delete(eventName);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
emit(eventName, payload) {
|
|
175
|
+
listeners.get(eventName)?.(payload);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createAppStub(records = {}) {
|
|
181
|
+
const registry = new Map();
|
|
182
|
+
for (const key of Reflect.ownKeys(records)) {
|
|
183
|
+
registry.set(key, records[key]);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
has(token) {
|
|
187
|
+
return registry.has(token);
|
|
188
|
+
},
|
|
189
|
+
make(token) {
|
|
190
|
+
return registry.get(token);
|
|
191
|
+
},
|
|
192
|
+
warn() {}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function createVuetifyThemeController(initial = "light") {
|
|
197
|
+
return {
|
|
198
|
+
global: {
|
|
199
|
+
name: {
|
|
200
|
+
value: initial
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createVueAppWithThemeController(themeController) {
|
|
207
|
+
return {
|
|
208
|
+
_context: {
|
|
209
|
+
provides: {
|
|
210
|
+
[ThemeSymbol]: themeController
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
test("bootstrap placement runtime writes user/workspace/permissions into placement context", async () => {
|
|
217
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
218
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
219
|
+
const fetchCalls = [];
|
|
220
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
221
|
+
app: createAppStub({
|
|
222
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
223
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
224
|
+
}),
|
|
225
|
+
fetchBootstrap: async (workspaceSlug) => {
|
|
226
|
+
fetchCalls.push(workspaceSlug);
|
|
227
|
+
return {
|
|
228
|
+
session: {
|
|
229
|
+
authenticated: true,
|
|
230
|
+
userId: 7
|
|
231
|
+
},
|
|
232
|
+
profile: {
|
|
233
|
+
displayName: "Ada Lovelace",
|
|
234
|
+
email: "ADA@EXAMPLE.COM",
|
|
235
|
+
avatar: {
|
|
236
|
+
effectiveUrl: "https://cdn.example.com/ada.png"
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
app: {
|
|
240
|
+
features: {
|
|
241
|
+
workspaceInvites: true
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
pendingInvites: [
|
|
245
|
+
{ id: 1, workspaceId: 1, token: "a" },
|
|
246
|
+
{ id: 2, workspaceId: 2, token: "b" }
|
|
247
|
+
],
|
|
248
|
+
workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
|
|
249
|
+
permissions: ["workspace.settings.view"]
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await runtime.initialize();
|
|
255
|
+
const context = placementRuntime.getContext();
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(fetchCalls, ["acme"]);
|
|
258
|
+
assert.equal(context.workspace?.slug, "acme");
|
|
259
|
+
assert.equal(Array.isArray(context.workspaces), true);
|
|
260
|
+
assert.equal(context.workspaces.length, 1);
|
|
261
|
+
assert.deepEqual(context.permissions, ["workspace.settings.view"]);
|
|
262
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
|
|
263
|
+
assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
|
|
264
|
+
assert.deepEqual(context.user, {
|
|
265
|
+
id: 7,
|
|
266
|
+
displayName: "Ada Lovelace",
|
|
267
|
+
name: "Ada Lovelace",
|
|
268
|
+
email: "ada@example.com",
|
|
269
|
+
avatarUrl: "https://cdn.example.com/ada.png"
|
|
270
|
+
});
|
|
271
|
+
assert.equal(context.workspaceInvitesEnabled, true);
|
|
272
|
+
assert.equal(context.pendingInvitesCount, 2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("bootstrap placement runtime resolves workspace slug from pathname when surface config is missing", async () => {
|
|
276
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
277
|
+
placementRuntime.setContext({}, { replace: true, source: "test.clear" });
|
|
278
|
+
const router = createRouterStub("/w/acme/admin");
|
|
279
|
+
const fetchCalls = [];
|
|
280
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
281
|
+
app: createAppStub({
|
|
282
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
283
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
284
|
+
}),
|
|
285
|
+
fetchBootstrap: async (workspaceSlug) => {
|
|
286
|
+
fetchCalls.push(workspaceSlug);
|
|
287
|
+
return {
|
|
288
|
+
session: {
|
|
289
|
+
authenticated: true,
|
|
290
|
+
userId: 1
|
|
291
|
+
},
|
|
292
|
+
profile: {
|
|
293
|
+
displayName: "User",
|
|
294
|
+
email: "user@example.com",
|
|
295
|
+
avatar: {
|
|
296
|
+
effectiveUrl: ""
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
|
|
300
|
+
permissions: ["workspace.settings.view"]
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await runtime.initialize();
|
|
306
|
+
|
|
307
|
+
assert.deepEqual(fetchCalls, ["acme"]);
|
|
308
|
+
assert.deepEqual(placementRuntime.getContext().permissions, ["workspace.settings.view"]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("bootstrap placement runtime does not mutate placement auth context", async () => {
|
|
312
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
313
|
+
placementRuntime.setContext(
|
|
314
|
+
{
|
|
315
|
+
auth: {
|
|
316
|
+
authenticated: true,
|
|
317
|
+
oauthDefaultProvider: "github",
|
|
318
|
+
oauthProviders: [{ id: "github", label: "GitHub" }]
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
{ source: "test.seed" }
|
|
322
|
+
);
|
|
323
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
324
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
325
|
+
app: createAppStub({
|
|
326
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
327
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
328
|
+
}),
|
|
329
|
+
fetchBootstrap: async () => {
|
|
330
|
+
return {
|
|
331
|
+
session: {
|
|
332
|
+
authenticated: true,
|
|
333
|
+
userId: 9
|
|
334
|
+
},
|
|
335
|
+
profile: {
|
|
336
|
+
displayName: "User",
|
|
337
|
+
email: "user@example.com",
|
|
338
|
+
avatar: {
|
|
339
|
+
effectiveUrl: ""
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
workspaces: [{ id: 1, slug: "acme", name: "Workspace" }],
|
|
343
|
+
permissions: []
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await runtime.initialize();
|
|
349
|
+
assert.deepEqual(placementRuntime.getContext().auth, {
|
|
350
|
+
authenticated: true,
|
|
351
|
+
oauthDefaultProvider: "github",
|
|
352
|
+
oauthProviders: [{ id: "github", label: "GitHub" }]
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("bootstrap placement runtime refetches on route changes and users.bootstrap.changed events", async () => {
|
|
357
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
358
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
359
|
+
const socket = createSocketStub();
|
|
360
|
+
const fetchCalls = [];
|
|
361
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
362
|
+
app: createAppStub({
|
|
363
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
364
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router,
|
|
365
|
+
[REALTIME_SOCKET_CLIENT_TOKEN]: socket
|
|
366
|
+
}),
|
|
367
|
+
fetchBootstrap: async (workspaceSlug) => {
|
|
368
|
+
fetchCalls.push(workspaceSlug);
|
|
369
|
+
return {
|
|
370
|
+
session: {
|
|
371
|
+
authenticated: true,
|
|
372
|
+
userId: 1
|
|
373
|
+
},
|
|
374
|
+
profile: {
|
|
375
|
+
displayName: "User",
|
|
376
|
+
email: "user@example.com",
|
|
377
|
+
avatar: {
|
|
378
|
+
effectiveUrl: ""
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
|
|
382
|
+
permissions: []
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await runtime.initialize();
|
|
388
|
+
assert.deepEqual(fetchCalls, ["acme"]);
|
|
389
|
+
|
|
390
|
+
router.currentRoute.value.path = "/w/acme/customers";
|
|
391
|
+
router.currentRoute.value.fullPath = "/w/acme/customers";
|
|
392
|
+
router.emitAfterEach();
|
|
393
|
+
await flushTasks();
|
|
394
|
+
assert.deepEqual(fetchCalls, ["acme"]);
|
|
395
|
+
|
|
396
|
+
router.currentRoute.value.path = "/w/zen/dashboard";
|
|
397
|
+
router.currentRoute.value.fullPath = "/w/zen/dashboard";
|
|
398
|
+
router.emitAfterEach();
|
|
399
|
+
await flushTasks();
|
|
400
|
+
assert.deepEqual(fetchCalls, ["acme", "zen"]);
|
|
401
|
+
|
|
402
|
+
socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
|
|
403
|
+
await flushTasks();
|
|
404
|
+
assert.deepEqual(fetchCalls, ["acme", "zen", "zen"]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("bootstrap placement runtime refetches when auth context changes", async () => {
|
|
408
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
409
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
410
|
+
const fetchCalls = [];
|
|
411
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
412
|
+
app: createAppStub({
|
|
413
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
414
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
415
|
+
}),
|
|
416
|
+
fetchBootstrap: async (workspaceSlug) => {
|
|
417
|
+
fetchCalls.push(workspaceSlug);
|
|
418
|
+
return {
|
|
419
|
+
session: {
|
|
420
|
+
authenticated: true,
|
|
421
|
+
userId: 1
|
|
422
|
+
},
|
|
423
|
+
profile: {
|
|
424
|
+
displayName: "User",
|
|
425
|
+
email: "user@example.com",
|
|
426
|
+
avatar: {
|
|
427
|
+
effectiveUrl: ""
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
|
|
431
|
+
permissions: []
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await runtime.initialize();
|
|
437
|
+
assert.deepEqual(fetchCalls, ["acme"]);
|
|
438
|
+
|
|
439
|
+
placementRuntime.setContext(
|
|
440
|
+
{
|
|
441
|
+
auth: {
|
|
442
|
+
authenticated: true,
|
|
443
|
+
oauthDefaultProvider: "",
|
|
444
|
+
oauthProviders: []
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
source: "test.auth"
|
|
449
|
+
}
|
|
450
|
+
);
|
|
451
|
+
await flushTasks();
|
|
452
|
+
await flushTasks();
|
|
453
|
+
assert.deepEqual(fetchCalls, ["acme", "acme"]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("bootstrap placement runtime forces light theme for unauthenticated bootstrap payloads", async () => {
|
|
457
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
458
|
+
const router = createRouterStub("/auth/login");
|
|
459
|
+
const themeController = createVuetifyThemeController("dark");
|
|
460
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
461
|
+
app: createAppStub({
|
|
462
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
463
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router,
|
|
464
|
+
[CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
|
|
465
|
+
}),
|
|
466
|
+
fetchBootstrap: async () => {
|
|
467
|
+
return {
|
|
468
|
+
session: {
|
|
469
|
+
authenticated: false
|
|
470
|
+
},
|
|
471
|
+
workspaces: [],
|
|
472
|
+
permissions: []
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await runtime.initialize();
|
|
478
|
+
assert.equal(themeController.global.name.value, "light");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("bootstrap placement runtime reapplies theme when bootstrap payload changes", async () => {
|
|
482
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
483
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
484
|
+
const socket = createSocketStub();
|
|
485
|
+
const themeController = createVuetifyThemeController("light");
|
|
486
|
+
let fetchCount = 0;
|
|
487
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
488
|
+
app: createAppStub({
|
|
489
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
490
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router,
|
|
491
|
+
[REALTIME_SOCKET_CLIENT_TOKEN]: socket,
|
|
492
|
+
[CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
|
|
493
|
+
}),
|
|
494
|
+
fetchBootstrap: async (workspaceSlug) => {
|
|
495
|
+
fetchCount += 1;
|
|
496
|
+
return {
|
|
497
|
+
session: {
|
|
498
|
+
authenticated: true,
|
|
499
|
+
userId: 1
|
|
500
|
+
},
|
|
501
|
+
profile: {
|
|
502
|
+
displayName: "User",
|
|
503
|
+
email: "user@example.com",
|
|
504
|
+
avatar: {
|
|
505
|
+
effectiveUrl: ""
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
userSettings: {
|
|
509
|
+
theme: fetchCount === 1 ? "dark" : "light"
|
|
510
|
+
},
|
|
511
|
+
workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
|
|
512
|
+
permissions: []
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await runtime.initialize();
|
|
518
|
+
assert.equal(themeController.global.name.value, "dark");
|
|
519
|
+
|
|
520
|
+
socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
|
|
521
|
+
await flushTasks();
|
|
522
|
+
assert.equal(themeController.global.name.value, "light");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("bootstrap placement runtime marks workspace slug as not_found and clears workspace context on 404", async () => {
|
|
526
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
527
|
+
placementRuntime.setContext(
|
|
528
|
+
{
|
|
529
|
+
workspace: { id: 1, slug: "acme", name: "Acme Workspace" },
|
|
530
|
+
workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
|
|
531
|
+
permissions: ["workspace.settings.view"]
|
|
532
|
+
},
|
|
533
|
+
{ source: "test.seed" }
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
537
|
+
app: createAppStub({
|
|
538
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
539
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: createRouterStub("/w/acme/dashboard")
|
|
540
|
+
}),
|
|
541
|
+
fetchBootstrap: async () => {
|
|
542
|
+
const error = new Error("Workspace not found.");
|
|
543
|
+
error.status = 404;
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await runtime.initialize();
|
|
549
|
+
|
|
550
|
+
const context = placementRuntime.getContext();
|
|
551
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
552
|
+
assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
553
|
+
assert.equal(context.workspace, null);
|
|
554
|
+
assert.deepEqual(context.workspaces, []);
|
|
555
|
+
assert.deepEqual(context.permissions, []);
|
|
556
|
+
assert.equal(context.user, null);
|
|
557
|
+
assert.equal(context.pendingInvitesCount, 0);
|
|
558
|
+
assert.equal(context.workspaceInvitesEnabled, false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("bootstrap placement runtime updates status per workspace slug across route changes", async () => {
|
|
562
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
563
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
564
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
565
|
+
app: createAppStub({
|
|
566
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
567
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
568
|
+
}),
|
|
569
|
+
fetchBootstrap: async (workspaceSlug) => {
|
|
570
|
+
if (workspaceSlug === "zen") {
|
|
571
|
+
const error = new Error("Workspace not found.");
|
|
572
|
+
error.status = 404;
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
session: {
|
|
578
|
+
authenticated: true,
|
|
579
|
+
userId: 1
|
|
580
|
+
},
|
|
581
|
+
profile: {
|
|
582
|
+
displayName: "User",
|
|
583
|
+
email: "user@example.com",
|
|
584
|
+
avatar: {
|
|
585
|
+
effectiveUrl: ""
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
|
|
589
|
+
permissions: []
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
await runtime.initialize();
|
|
595
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
|
|
596
|
+
|
|
597
|
+
router.currentRoute.value.path = "/w/zen/dashboard";
|
|
598
|
+
router.currentRoute.value.fullPath = "/w/zen/dashboard";
|
|
599
|
+
router.emitAfterEach();
|
|
600
|
+
await flushTasks();
|
|
601
|
+
|
|
602
|
+
const context = placementRuntime.getContext();
|
|
603
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("zen"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
604
|
+
assert.equal(context.workspaceBootstrapStatuses?.zen, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
605
|
+
assert.equal(context.workspace, null);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("bootstrap placement runtime uses requestedWorkspace status and keeps global workspace list on inaccessible slug", async () => {
|
|
609
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
610
|
+
const router = createRouterStub("/w/tonymobily");
|
|
611
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
612
|
+
app: createAppStub({
|
|
613
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
614
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
615
|
+
}),
|
|
616
|
+
fetchBootstrap: async () => {
|
|
617
|
+
return {
|
|
618
|
+
session: {
|
|
619
|
+
authenticated: true,
|
|
620
|
+
userId: 4
|
|
621
|
+
},
|
|
622
|
+
profile: {
|
|
623
|
+
displayName: "Chiara",
|
|
624
|
+
email: "chiara@example.com",
|
|
625
|
+
avatar: {
|
|
626
|
+
effectiveUrl: ""
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
workspaces: [{ id: 3, slug: "chiaramobily", name: "Chiara Workspace" }],
|
|
630
|
+
requestedWorkspace: {
|
|
631
|
+
slug: "tonymobily",
|
|
632
|
+
status: "forbidden"
|
|
633
|
+
},
|
|
634
|
+
permissions: []
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await runtime.initialize();
|
|
640
|
+
|
|
641
|
+
const context = placementRuntime.getContext();
|
|
642
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("tonymobily"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
|
|
643
|
+
assert.equal(context.workspaceBootstrapStatuses?.tonymobily, WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
|
|
644
|
+
assert.equal(context.workspace, null);
|
|
645
|
+
assert.equal(Array.isArray(context.workspaces), true);
|
|
646
|
+
assert.equal(context.workspaces.length, 1);
|
|
647
|
+
assert.equal(context.workspaces[0]?.slug, "chiaramobily");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("bootstrap placement runtime uses requestedWorkspace=not_found without forcing forbidden fallback", async () => {
|
|
651
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
652
|
+
const router = createRouterStub("/w/missing");
|
|
653
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
654
|
+
app: createAppStub({
|
|
655
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
656
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
657
|
+
}),
|
|
658
|
+
fetchBootstrap: async () => {
|
|
659
|
+
return {
|
|
660
|
+
session: {
|
|
661
|
+
authenticated: true,
|
|
662
|
+
userId: 1
|
|
663
|
+
},
|
|
664
|
+
profile: {
|
|
665
|
+
displayName: "User",
|
|
666
|
+
email: "user@example.com",
|
|
667
|
+
avatar: {
|
|
668
|
+
effectiveUrl: ""
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
|
|
672
|
+
requestedWorkspace: {
|
|
673
|
+
slug: "missing",
|
|
674
|
+
status: "not_found"
|
|
675
|
+
},
|
|
676
|
+
permissions: []
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
await runtime.initialize();
|
|
682
|
+
|
|
683
|
+
const context = placementRuntime.getContext();
|
|
684
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("missing"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
685
|
+
assert.equal(context.workspaceBootstrapStatuses?.missing, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
686
|
+
assert.equal(Array.isArray(context.workspaces), true);
|
|
687
|
+
assert.equal(context.workspaces.length, 1);
|
|
688
|
+
assert.equal(context.workspaces[0]?.slug, "acme");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("bootstrap placement runtime guard wrapper preserves delegated deny outcomes", async () => {
|
|
692
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
693
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
694
|
+
const delegatedOutcome = Object.freeze({
|
|
695
|
+
allow: false,
|
|
696
|
+
redirectTo: "/auth/login?returnTo=%2Fw%2Facme%2Fdashboard",
|
|
697
|
+
reason: "auth-required"
|
|
698
|
+
});
|
|
699
|
+
globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => delegatedOutcome;
|
|
700
|
+
|
|
701
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
702
|
+
app: createAppStub({
|
|
703
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
704
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
705
|
+
}),
|
|
706
|
+
fetchBootstrap: async () => {
|
|
707
|
+
return {
|
|
708
|
+
session: {
|
|
709
|
+
authenticated: true,
|
|
710
|
+
userId: 1
|
|
711
|
+
},
|
|
712
|
+
workspaces: [{ id: 1, slug: "acme", name: "Acme" }],
|
|
713
|
+
permissions: []
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
await runtime.initialize();
|
|
719
|
+
const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
|
|
720
|
+
const outcome = evaluator({
|
|
721
|
+
guard: {
|
|
722
|
+
policy: "authenticated"
|
|
723
|
+
},
|
|
724
|
+
context: {
|
|
725
|
+
to: {
|
|
726
|
+
path: "/w/acme/dashboard",
|
|
727
|
+
fullPath: "/w/acme/dashboard"
|
|
728
|
+
},
|
|
729
|
+
location: {
|
|
730
|
+
pathname: "/w/acme/dashboard",
|
|
731
|
+
search: ""
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
assert.deepEqual(outcome, delegatedOutcome);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("bootstrap placement runtime guard wrapper blocks forbidden workspace routes", async () => {
|
|
740
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
741
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
742
|
+
globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => ({ allow: true, redirectTo: "", reason: "" });
|
|
743
|
+
|
|
744
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
745
|
+
app: createAppStub({
|
|
746
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
747
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
748
|
+
}),
|
|
749
|
+
fetchBootstrap: async () => {
|
|
750
|
+
return {
|
|
751
|
+
session: {
|
|
752
|
+
authenticated: true,
|
|
753
|
+
userId: 1
|
|
754
|
+
},
|
|
755
|
+
workspaces: [],
|
|
756
|
+
permissions: []
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
await runtime.initialize();
|
|
762
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
|
|
763
|
+
|
|
764
|
+
const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
|
|
765
|
+
const outcome = evaluator({
|
|
766
|
+
guard: {
|
|
767
|
+
policy: "authenticated"
|
|
768
|
+
},
|
|
769
|
+
context: {
|
|
770
|
+
to: {
|
|
771
|
+
path: "/w/acme/dashboard",
|
|
772
|
+
fullPath: "/w/acme/dashboard?tab=general"
|
|
773
|
+
},
|
|
774
|
+
location: {
|
|
775
|
+
pathname: "/w/acme/dashboard",
|
|
776
|
+
search: "?tab=general"
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
assert.equal(outcome.allow, false);
|
|
782
|
+
assert.equal(outcome.reason, "workspace-forbidden");
|
|
783
|
+
assert.equal(outcome.redirectTo, "/w/acme");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("bootstrap placement runtime guard wrapper redirects nested not_found routes to workspace surface root", async () => {
|
|
787
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
788
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
789
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
790
|
+
app: createAppStub({
|
|
791
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
792
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
793
|
+
}),
|
|
794
|
+
fetchBootstrap: async () => {
|
|
795
|
+
const error = new Error("Not found");
|
|
796
|
+
error.status = 404;
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
await runtime.initialize();
|
|
802
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
803
|
+
|
|
804
|
+
const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
|
|
805
|
+
const nestedOutcome = evaluator({
|
|
806
|
+
guard: {
|
|
807
|
+
policy: "authenticated"
|
|
808
|
+
},
|
|
809
|
+
context: {
|
|
810
|
+
to: {
|
|
811
|
+
path: "/w/acme/projects",
|
|
812
|
+
fullPath: "/w/acme/projects"
|
|
813
|
+
},
|
|
814
|
+
location: {
|
|
815
|
+
pathname: "/w/acme/projects",
|
|
816
|
+
search: ""
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
assert.deepEqual(nestedOutcome, {
|
|
821
|
+
allow: false,
|
|
822
|
+
redirectTo: "/w/acme",
|
|
823
|
+
reason: "workspace-not-found"
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const rootOutcome = evaluator({
|
|
827
|
+
guard: {
|
|
828
|
+
policy: "authenticated"
|
|
829
|
+
},
|
|
830
|
+
context: {
|
|
831
|
+
to: {
|
|
832
|
+
path: "/w/acme",
|
|
833
|
+
fullPath: "/w/acme"
|
|
834
|
+
},
|
|
835
|
+
location: {
|
|
836
|
+
pathname: "/w/acme",
|
|
837
|
+
search: ""
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
assert.equal(rootOutcome, true);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("bootstrap placement runtime redirects admin nested route to admin root when workspace is not_found", async () => {
|
|
845
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
846
|
+
const router = createRouterStub("/w/acme/admin/workspace/settings");
|
|
847
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
848
|
+
app: createAppStub({
|
|
849
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
850
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
851
|
+
}),
|
|
852
|
+
fetchBootstrap: async () => {
|
|
853
|
+
const error = new Error("Not found");
|
|
854
|
+
error.status = 404;
|
|
855
|
+
throw error;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
await runtime.initialize();
|
|
860
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
|
|
861
|
+
assert.deepEqual(router.replaceCalls, ["/w/acme/admin"]);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("bootstrap placement runtime redirects forbidden workspace route to workspace surface root", async () => {
|
|
865
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
866
|
+
const router = createRouterStub("/w/acme/admin/workspace/settings?tab=general");
|
|
867
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
868
|
+
app: createAppStub({
|
|
869
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
870
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
871
|
+
}),
|
|
872
|
+
fetchBootstrap: async () => {
|
|
873
|
+
const error = new Error("Forbidden");
|
|
874
|
+
error.status = 403;
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
await runtime.initialize();
|
|
880
|
+
assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
|
|
881
|
+
assert.deepEqual(router.replaceCalls, ["/w/acme/admin"]);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("bootstrap placement runtime enforces surface access policies after bootstrap refresh", async () => {
|
|
885
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
886
|
+
placementRuntime.setContext({
|
|
887
|
+
auth: {
|
|
888
|
+
authenticated: true
|
|
889
|
+
},
|
|
890
|
+
surfaceAccessPolicies: {
|
|
891
|
+
public: {},
|
|
892
|
+
console_owner: {
|
|
893
|
+
requireAuth: true,
|
|
894
|
+
requireFlagsAll: ["console_owner"]
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
surfaceConfig: {
|
|
898
|
+
tenancyMode: "workspace",
|
|
899
|
+
defaultSurfaceId: "home",
|
|
900
|
+
enabledSurfaceIds: ["home", "console"],
|
|
901
|
+
surfacesById: {
|
|
902
|
+
home: {
|
|
903
|
+
id: "home",
|
|
904
|
+
enabled: true,
|
|
905
|
+
pagesRoot: "home",
|
|
906
|
+
routeBase: "/home",
|
|
907
|
+
requiresWorkspace: false,
|
|
908
|
+
accessPolicyId: "public"
|
|
909
|
+
},
|
|
910
|
+
console: {
|
|
911
|
+
id: "console",
|
|
912
|
+
enabled: true,
|
|
913
|
+
pagesRoot: "console",
|
|
914
|
+
routeBase: "/console",
|
|
915
|
+
requiresWorkspace: false,
|
|
916
|
+
accessPolicyId: "console_owner"
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
const router = createRouterStub("/console");
|
|
922
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
923
|
+
app: createAppStub({
|
|
924
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
925
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
926
|
+
}),
|
|
927
|
+
fetchBootstrap: async () => {
|
|
928
|
+
return {
|
|
929
|
+
session: {
|
|
930
|
+
authenticated: true,
|
|
931
|
+
userId: 1
|
|
932
|
+
},
|
|
933
|
+
workspaces: [],
|
|
934
|
+
permissions: [],
|
|
935
|
+
surfaceAccess: {
|
|
936
|
+
consoleowner: false
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
await runtime.initialize();
|
|
943
|
+
assert.deepEqual(router.replaceCalls, ["/home"]);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("bootstrap placement runtime captures guard evaluator assignments after initialization", async () => {
|
|
947
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
948
|
+
const router = createRouterStub("/w/acme/dashboard");
|
|
949
|
+
const runtime = createBootstrapPlacementRuntime({
|
|
950
|
+
app: createAppStub({
|
|
951
|
+
[WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
|
|
952
|
+
[CLIENT_MODULE_ROUTER_TOKEN]: router
|
|
953
|
+
}),
|
|
954
|
+
fetchBootstrap: async () => {
|
|
955
|
+
return {
|
|
956
|
+
session: {
|
|
957
|
+
authenticated: true,
|
|
958
|
+
userId: 1
|
|
959
|
+
},
|
|
960
|
+
workspaces: [{ id: 1, slug: "acme", name: "Acme" }],
|
|
961
|
+
permissions: []
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
await runtime.initialize();
|
|
967
|
+
const delegatedOutcome = {
|
|
968
|
+
allow: false,
|
|
969
|
+
redirectTo: "/auth/login",
|
|
970
|
+
reason: "auth-required"
|
|
971
|
+
};
|
|
972
|
+
globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => delegatedOutcome;
|
|
973
|
+
|
|
974
|
+
const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
|
|
975
|
+
const outcome = evaluator({
|
|
976
|
+
guard: {
|
|
977
|
+
policy: "authenticated"
|
|
978
|
+
},
|
|
979
|
+
context: {
|
|
980
|
+
to: {
|
|
981
|
+
path: "/w/acme/dashboard",
|
|
982
|
+
fullPath: "/w/acme/dashboard"
|
|
983
|
+
},
|
|
984
|
+
location: {
|
|
985
|
+
pathname: "/w/acme/dashboard",
|
|
986
|
+
search: ""
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
assert.deepEqual(outcome, delegatedOutcome);
|
|
991
|
+
});
|