@jskit-ai/auth-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.
Files changed (39) hide show
  1. package/package.descriptor.mjs +290 -0
  2. package/package.json +29 -0
  3. package/src/client/composables/useDefaultLoginView.js +935 -0
  4. package/src/client/composables/useDefaultSignOutView.js +113 -0
  5. package/src/client/index.js +19 -0
  6. package/src/client/lib/returnToPath.js +20 -0
  7. package/src/client/lib/surfaceLinkTarget.js +19 -0
  8. package/src/client/providers/AuthWebClientProvider.js +72 -0
  9. package/src/client/runtime/authGuardRuntime.js +499 -0
  10. package/src/client/runtime/authHttpClient.js +19 -0
  11. package/src/client/runtime/inject.js +43 -0
  12. package/src/client/runtime/tokens.js +7 -0
  13. package/src/client/runtime/useLoginView.js +7 -0
  14. package/src/client/runtime/useSignOut.js +121 -0
  15. package/src/client/views/AuthProfileMenuLinkItem.vue +83 -0
  16. package/src/client/views/AuthProfileWidget.vue +100 -0
  17. package/src/client/views/DefaultLoginView.vue +291 -0
  18. package/src/client/views/DefaultSignOutView.vue +58 -0
  19. package/src/server/constants/authActionIds.js +15 -0
  20. package/src/server/controllers/AuthController.js +183 -0
  21. package/src/server/providers/AuthRouteServiceProvider.js +31 -0
  22. package/src/server/providers/AuthWebServiceProvider.js +23 -0
  23. package/src/server/routes/authRoutes.js +244 -0
  24. package/src/server/services/AuthWebService.js +126 -0
  25. package/templates/src/pages/auth/login.vue +17 -0
  26. package/templates/src/pages/auth/signout.vue +17 -0
  27. package/templates/src/runtime/authGuardRuntime.js +7 -0
  28. package/templates/src/runtime/authHttpClient.js +1 -0
  29. package/templates/src/runtime/useSignOut.js +1 -0
  30. package/templates/src/views/auth/LoginView.vue +7 -0
  31. package/templates/src/views/auth/SignOutView.vue +7 -0
  32. package/test/authGuardRuntime.test.js +361 -0
  33. package/test/clientBoot.test.js +16 -0
  34. package/test/clientSurface.test.js +89 -0
  35. package/test/index.test.js +21 -0
  36. package/test/logoutFallback.test.js +50 -0
  37. package/test/providerRuntime.test.js +100 -0
  38. package/test/returnToPath.test.js +72 -0
  39. package/test/surfaceLinkTarget.test.js +80 -0
@@ -0,0 +1,361 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createAuthGuardRuntime } from "../src/client/runtime/authGuardRuntime.js";
4
+
5
+ function createPlacementRuntimeStub(initialContext = {}) {
6
+ let context = initialContext && typeof initialContext === "object" ? { ...initialContext } : {};
7
+ const setCalls = [];
8
+
9
+ return {
10
+ getContext() {
11
+ return context;
12
+ },
13
+ setContext(nextContext = {}, { replace = false } = {}) {
14
+ const patch = nextContext && typeof nextContext === "object" ? nextContext : {};
15
+ context = replace ? { ...patch } : { ...context, ...patch };
16
+ setCalls.push(context);
17
+ },
18
+ setCalls
19
+ };
20
+ }
21
+
22
+ function createEventTargetStub() {
23
+ const listenersByType = new Map();
24
+
25
+ return {
26
+ addEventListener(type, listener) {
27
+ if (typeof listener !== "function") {
28
+ return;
29
+ }
30
+ const listeners = listenersByType.get(type) || new Set();
31
+ listeners.add(listener);
32
+ listenersByType.set(type, listeners);
33
+ },
34
+ removeEventListener(type, listener) {
35
+ const listeners = listenersByType.get(type);
36
+ if (!listeners) {
37
+ return;
38
+ }
39
+ listeners.delete(listener);
40
+ },
41
+ emit(type) {
42
+ const listeners = listenersByType.get(type);
43
+ if (!listeners) {
44
+ return;
45
+ }
46
+ for (const listener of [...listeners]) {
47
+ listener();
48
+ }
49
+ }
50
+ };
51
+ }
52
+
53
+ function createDocumentStub(initialVisibilityState = "hidden") {
54
+ const eventTarget = createEventTargetStub();
55
+ let visibilityState = initialVisibilityState;
56
+
57
+ return {
58
+ addEventListener: eventTarget.addEventListener,
59
+ removeEventListener: eventTarget.removeEventListener,
60
+ emit: eventTarget.emit,
61
+ get visibilityState() {
62
+ return visibilityState;
63
+ },
64
+ setVisibilityState(nextVisibilityState) {
65
+ visibilityState = String(nextVisibilityState || "");
66
+ }
67
+ };
68
+ }
69
+
70
+ function flushPendingRefresh() {
71
+ return new Promise((resolve) => {
72
+ setTimeout(resolve, 0);
73
+ });
74
+ }
75
+
76
+ test("auth guard runtime keeps previous auth state on transient refresh failure", async () => {
77
+ const placementRuntime = createPlacementRuntimeStub();
78
+ let callCount = 0;
79
+
80
+ const runtime = createAuthGuardRuntime({
81
+ placementRuntime,
82
+ fetchImplementation: async () => {
83
+ callCount += 1;
84
+ if (callCount === 1) {
85
+ return {
86
+ ok: true,
87
+ async json() {
88
+ return {
89
+ authenticated: true,
90
+ username: "ada"
91
+ };
92
+ }
93
+ };
94
+ }
95
+ throw new Error("network temporarily unavailable");
96
+ }
97
+ });
98
+
99
+ await runtime.initialize();
100
+ assert.equal(runtime.getState().authenticated, true);
101
+ const setCallCountBeforeTransientFailure = placementRuntime.setCalls.length;
102
+
103
+ await runtime.refresh();
104
+ assert.equal(runtime.getState().authenticated, true);
105
+ assert.equal(placementRuntime.setCalls.length, setCallCountBeforeTransientFailure);
106
+ });
107
+
108
+ test("auth guard runtime only updates placement auth context", async () => {
109
+ const placementRuntime = createPlacementRuntimeStub({
110
+ user: {
111
+ id: 1,
112
+ displayName: "Existing User"
113
+ },
114
+ workspace: {
115
+ id: 9,
116
+ slug: "acme"
117
+ }
118
+ });
119
+
120
+ const runtime = createAuthGuardRuntime({
121
+ placementRuntime,
122
+ fetchImplementation: async () => {
123
+ return {
124
+ ok: true,
125
+ async json() {
126
+ return {
127
+ authenticated: true,
128
+ username: "ada",
129
+ oauthProviders: [{ id: "github", label: "GitHub" }],
130
+ oauthDefaultProvider: "github"
131
+ };
132
+ }
133
+ };
134
+ }
135
+ });
136
+
137
+ await runtime.initialize();
138
+
139
+ const context = placementRuntime.getContext();
140
+ assert.deepEqual(context.user, {
141
+ id: 1,
142
+ displayName: "Existing User"
143
+ });
144
+ assert.deepEqual(context.workspace, {
145
+ id: 9,
146
+ slug: "acme"
147
+ });
148
+ assert.deepEqual(context.auth, {
149
+ authenticated: true,
150
+ oauthDefaultProvider: "github",
151
+ oauthProviders: [{ id: "github", label: "GitHub" }]
152
+ });
153
+ });
154
+
155
+ test("auth guard runtime refreshes on reconnect/focus/visibility when explicitly enabled", async () => {
156
+ const originalWindow = globalThis.window;
157
+ const originalDocument = globalThis.document;
158
+ const windowStub = createEventTargetStub();
159
+ const documentStub = createDocumentStub("hidden");
160
+ globalThis.window = {
161
+ addEventListener: windowStub.addEventListener,
162
+ removeEventListener: windowStub.removeEventListener,
163
+ location: {
164
+ pathname: "/w/acme",
165
+ search: ""
166
+ }
167
+ };
168
+ globalThis.document = documentStub;
169
+
170
+ try {
171
+ const placementRuntime = createPlacementRuntimeStub();
172
+ let callCount = 0;
173
+ const runtime = createAuthGuardRuntime({
174
+ placementRuntime,
175
+ refreshOnForeground: true,
176
+ refreshOnReconnect: true,
177
+ fetchImplementation: async () => {
178
+ callCount += 1;
179
+ return {
180
+ ok: true,
181
+ async json() {
182
+ if (callCount === 1) {
183
+ return {
184
+ authenticated: false
185
+ };
186
+ }
187
+ return {
188
+ authenticated: true,
189
+ username: "ada"
190
+ };
191
+ }
192
+ };
193
+ }
194
+ });
195
+
196
+ const observedStates = [];
197
+ runtime.subscribe((state) => {
198
+ observedStates.push(state);
199
+ });
200
+
201
+ await runtime.initialize();
202
+ assert.equal(runtime.getState().authenticated, false);
203
+ assert.equal(observedStates.length, 1);
204
+
205
+ windowStub.emit("online");
206
+ await flushPendingRefresh();
207
+ assert.equal(runtime.getState().authenticated, true);
208
+ assert.equal(observedStates.length, 2);
209
+
210
+ const setCallCountBeforeVisibilityRefresh = placementRuntime.setCalls.length;
211
+ documentStub.setVisibilityState("visible");
212
+ documentStub.emit("visibilitychange");
213
+ await flushPendingRefresh();
214
+ assert.equal(placementRuntime.setCalls.length, setCallCountBeforeVisibilityRefresh + 1);
215
+ assert.equal(observedStates.length, 3);
216
+ } finally {
217
+ globalThis.window = originalWindow;
218
+ globalThis.document = originalDocument;
219
+ }
220
+ });
221
+
222
+ test("auth guard runtime does not refresh on browser events when foreground/reconnect refresh is disabled", async () => {
223
+ const originalWindow = globalThis.window;
224
+ const originalDocument = globalThis.document;
225
+ const windowStub = createEventTargetStub();
226
+ const documentStub = createDocumentStub("visible");
227
+ globalThis.window = {
228
+ addEventListener: windowStub.addEventListener,
229
+ removeEventListener: windowStub.removeEventListener,
230
+ location: {
231
+ pathname: "/w/acme",
232
+ search: ""
233
+ }
234
+ };
235
+ globalThis.document = documentStub;
236
+
237
+ try {
238
+ const placementRuntime = createPlacementRuntimeStub();
239
+ let callCount = 0;
240
+ const runtime = createAuthGuardRuntime({
241
+ placementRuntime,
242
+ refreshOnForeground: false,
243
+ fetchImplementation: async () => {
244
+ callCount += 1;
245
+ return {
246
+ ok: true,
247
+ async json() {
248
+ return {
249
+ authenticated: true,
250
+ username: "ada"
251
+ };
252
+ }
253
+ };
254
+ }
255
+ });
256
+
257
+ await runtime.initialize();
258
+ assert.equal(callCount, 1);
259
+
260
+ windowStub.emit("focus");
261
+ await flushPendingRefresh();
262
+ assert.equal(callCount, 1);
263
+
264
+ documentStub.emit("visibilitychange");
265
+ await flushPendingRefresh();
266
+ assert.equal(callCount, 1);
267
+
268
+ windowStub.emit("online");
269
+ await flushPendingRefresh();
270
+ assert.equal(callCount, 1);
271
+ } finally {
272
+ globalThis.window = originalWindow;
273
+ globalThis.document = originalDocument;
274
+ }
275
+ });
276
+
277
+ test("auth guard runtime refreshes session when realtime refresh events are received", async () => {
278
+ const placementRuntime = createPlacementRuntimeStub();
279
+ const socketListeners = new Map();
280
+ let callCount = 0;
281
+ const runtime = createAuthGuardRuntime({
282
+ placementRuntime,
283
+ realtimeSocket: {
284
+ on(eventName, handler) {
285
+ socketListeners.set(eventName, handler);
286
+ },
287
+ off() {}
288
+ },
289
+ fetchImplementation: async () => {
290
+ callCount += 1;
291
+ return {
292
+ ok: true,
293
+ async json() {
294
+ return {
295
+ authenticated: callCount > 1,
296
+ username: "ada"
297
+ };
298
+ }
299
+ };
300
+ }
301
+ });
302
+
303
+ await runtime.initialize();
304
+ assert.equal(runtime.getState().authenticated, false);
305
+
306
+ socketListeners.get("users.bootstrap.changed")?.({});
307
+ await flushPendingRefresh();
308
+ assert.equal(runtime.getState().authenticated, true);
309
+ });
310
+
311
+ test("auth guard runtime encodes absolute returnTo for external login routes", async () => {
312
+ const originalWindow = globalThis.window;
313
+ globalThis.window = {
314
+ location: {
315
+ origin: "https://app.example.com",
316
+ href: "https://app.example.com/w/acme?tab=1",
317
+ pathname: "/w/acme",
318
+ search: "?tab=1"
319
+ }
320
+ };
321
+
322
+ try {
323
+ const placementRuntime = createPlacementRuntimeStub();
324
+ const runtime = createAuthGuardRuntime({
325
+ placementRuntime,
326
+ loginRoute: "https://auth.example.com/auth/login",
327
+ fetchImplementation: async () => {
328
+ return {
329
+ ok: true,
330
+ async json() {
331
+ return {
332
+ authenticated: false
333
+ };
334
+ }
335
+ };
336
+ }
337
+ });
338
+
339
+ await runtime.initialize();
340
+ const evaluator = globalThis.__JSKIT_WEB_SHELL_GUARD_EVALUATOR__;
341
+ const outcome = evaluator({
342
+ guard: {
343
+ policy: "authenticated"
344
+ },
345
+ context: {
346
+ location: {
347
+ pathname: "/w/acme",
348
+ search: "?tab=1"
349
+ }
350
+ }
351
+ });
352
+
353
+ assert.deepEqual(outcome, {
354
+ allow: false,
355
+ redirectTo: "https://auth.example.com/auth/login?returnTo=https%3A%2F%2Fapp.example.com%2Fw%2Facme%3Ftab%3D1",
356
+ reason: "auth-required"
357
+ });
358
+ } finally {
359
+ globalThis.window = originalWindow;
360
+ }
361
+ });
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import test from "node:test";
5
+
6
+ test("auth-web client index defines provider-based client routes surface", () => {
7
+ const source = readFileSync(fileURLToPath(new URL("../src/client/index.js", import.meta.url)), "utf8");
8
+
9
+ assert.equal(source.includes("const routeComponents = Object.freeze({"), true);
10
+ assert.equal(source.includes('"auth-login": DefaultLoginView'), true);
11
+ assert.equal(source.includes('"auth-signout": DefaultSignOutView'), true);
12
+ assert.equal(source.includes('"auth-default-login": DefaultLoginView'), true);
13
+ assert.equal(source.includes("const clientProviders = Object.freeze([AuthWebClientProvider]);"), true);
14
+ assert.equal(source.includes("async function bootClient(context) {"), false);
15
+ assert.equal(source.includes("export { routeComponents, clientProviders };"), true);
16
+ });
@@ -0,0 +1,89 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import descriptor from "../package.descriptor.mjs";
6
+ import {
7
+ useSignOut as fromRuntimeUseSignOut,
8
+ createSignOutAction as fromRuntimeCreateSignOutAction,
9
+ performSignOutRequest as fromRuntimePerformSignOutRequest
10
+ } from "../src/client/runtime/useSignOut.js";
11
+
12
+ test("auth-web descriptor declares auth surface ui routes", () => {
13
+ const uiRoutes = Array.isArray(descriptor?.metadata?.ui?.routes) ? descriptor.metadata.ui.routes : [];
14
+ const authRoutes = uiRoutes.filter((route) => String(route?.path || "").startsWith("/auth/"));
15
+
16
+ assert.equal(authRoutes.length >= 2, true);
17
+ for (const route of authRoutes) {
18
+ assert.equal(String(route?.scope || "").trim().toLowerCase(), "surface");
19
+ assert.equal(String(route?.surface || "").trim().toLowerCase(), "auth");
20
+ assert.equal(String(route?.guard?.policy || "").trim().toLowerCase(), "public");
21
+ }
22
+ });
23
+
24
+ test("auth-web auth page templates declare public route guard", () => {
25
+ const loginTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/login.vue", import.meta.url));
26
+ const signOutTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/signout.vue", import.meta.url));
27
+ const loginTemplateSource = readFileSync(loginTemplatePath, "utf8");
28
+ const signOutTemplateSource = readFileSync(signOutTemplatePath, "utf8");
29
+
30
+ assert.match(loginTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
31
+ assert.match(signOutTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
32
+ });
33
+
34
+ test("auth-web exports runtime signout helpers directly", () => {
35
+ assert.equal(typeof fromRuntimeUseSignOut, "function");
36
+ assert.equal(typeof fromRuntimeCreateSignOutAction, "function");
37
+ assert.equal(typeof fromRuntimePerformSignOutRequest, "function");
38
+ });
39
+
40
+ test("auth-web removes legacy client wrapper modules", () => {
41
+ const legacyFiles = [
42
+ "../src/client/composables/authHttpClient.js",
43
+ "../src/client/composables/authGuardRuntime.js",
44
+ "../src/client/composables/useSignOut.js",
45
+ "../src/client/api/AuthHttpClient.js"
46
+ ];
47
+
48
+ for (const relativePath of legacyFiles) {
49
+ const absolutePath = fileURLToPath(new URL(relativePath, import.meta.url));
50
+ assert.equal(existsSync(absolutePath), false, `${relativePath} must not exist.`);
51
+ }
52
+ });
53
+
54
+ test("auth-web runtime/useLoginView delegates to useDefaultLoginView", () => {
55
+ const runtimeUseLoginViewPath = fileURLToPath(new URL("../src/client/runtime/useLoginView.js", import.meta.url));
56
+ const runtimeUseLoginViewSource = readFileSync(runtimeUseLoginViewPath, "utf8");
57
+
58
+ assert.match(runtimeUseLoginViewSource, /import\s+\{\s*useDefaultLoginView\s*\}\s+from\s+"..\/composables\/useDefaultLoginView\.js";/);
59
+ assert.match(runtimeUseLoginViewSource, /function\s+useLoginView\s*\(\)\s*\{\s*return\s+useDefaultLoginView\(\);\s*\}/);
60
+ assert.match(runtimeUseLoginViewSource, /export\s+\{\s*useLoginView,\s*useDefaultLoginView\s*\};/);
61
+ });
62
+
63
+ test("auth-web package exports only minimal client runtime/view subpaths", () => {
64
+ const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
65
+ const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
66
+
67
+ assert.equal(
68
+ exportsMap["./client/views/DefaultLoginView"],
69
+ "./src/client/views/DefaultLoginView.vue"
70
+ );
71
+ assert.equal(
72
+ exportsMap["./client/views/DefaultSignOutView"],
73
+ "./src/client/views/DefaultSignOutView.vue"
74
+ );
75
+ assert.equal(
76
+ exportsMap["./client/runtime/authGuardRuntime"],
77
+ "./src/client/runtime/authGuardRuntime.js"
78
+ );
79
+ assert.equal(
80
+ exportsMap["./client/runtime/authHttpClient"],
81
+ "./src/client/runtime/authHttpClient.js"
82
+ );
83
+ assert.equal(exportsMap["./client/runtime/useSignOut"], "./src/client/runtime/useSignOut.js");
84
+ assert.equal(exportsMap["./client/composables/useDefaultLoginView"], undefined);
85
+ assert.equal(exportsMap["./client/composables/useDefaultSignOutView"], undefined);
86
+ assert.equal(exportsMap["./client/runtime/useLoginView"], undefined);
87
+ assert.equal(exportsMap["./client/views/AuthProfileWidget"], undefined);
88
+ assert.equal(exportsMap["./client/views/AuthProfileMenuLinkItem"], undefined);
89
+ });
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import path from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { AuthController } from "../src/server/controllers/AuthController.js";
7
+ import { buildRoutes } from "../src/server/routes/authRoutes.js";
8
+ import { authLoginPasswordCommand } from "@jskit-ai/auth-core/shared/commands/authLoginPasswordCommand";
9
+
10
+ test("auth fastify adapter exports controller/routes backed by shared command validators", () => {
11
+ assert.equal(typeof AuthController, "function");
12
+ assert.equal(typeof buildRoutes, "function");
13
+ assert.ok(authLoginPasswordCommand.operation.bodyValidator.schema);
14
+ });
15
+
16
+ test("auth-web no longer contains legacy server/schema directory", () => {
17
+ const testFilePath = fileURLToPath(import.meta.url);
18
+ const packageRoot = path.resolve(path.dirname(testFilePath), "..");
19
+ const legacySchemaDir = path.join(packageRoot, "src", "server", "schema");
20
+ assert.equal(existsSync(legacySchemaDir), false, "src/server/schema must not exist.");
21
+ });
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AuthController } from "../src/server/controllers/AuthController.js";
4
+
5
+ function createReplyStub() {
6
+ return {
7
+ statusCode: null,
8
+ payload: null,
9
+ code(value) {
10
+ this.statusCode = value;
11
+ return this;
12
+ },
13
+ send(value) {
14
+ this.payload = value;
15
+ return this;
16
+ }
17
+ };
18
+ }
19
+
20
+ test("logout clears local cookies and returns ok when auth service is unavailable", async () => {
21
+ let clearSessionCalls = 0;
22
+
23
+ const controller = new AuthController({
24
+ service: {
25
+ async logout() {
26
+ throw new Error("upstream unavailable");
27
+ },
28
+ clearSessionCookies() {
29
+ clearSessionCalls += 1;
30
+ }
31
+ }
32
+ });
33
+
34
+ const logs = [];
35
+ const request = {
36
+ log: {
37
+ warn(payload, message) {
38
+ logs.push({ payload, message });
39
+ }
40
+ }
41
+ };
42
+ const reply = createReplyStub();
43
+
44
+ await controller.logout(request, reply);
45
+
46
+ assert.equal(clearSessionCalls, 1);
47
+ assert.equal(reply.statusCode, 200);
48
+ assert.deepEqual(reply.payload, { ok: true });
49
+ assert.equal(logs.length, 1);
50
+ });
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createApplication, createHttpRuntime } from "@jskit-ai/kernel/_testable";
4
+ import { AuthRouteServiceProvider } from "../src/server/providers/AuthRouteServiceProvider.js";
5
+ import { AuthWebServiceProvider } from "../src/server/providers/AuthWebServiceProvider.js";
6
+
7
+ function createFastifyStub() {
8
+ const routes = [];
9
+ return {
10
+ routes,
11
+ errorHandler: null,
12
+ route(definition) {
13
+ routes.push(definition);
14
+ },
15
+ setErrorHandler(handler) {
16
+ this.errorHandler = handler;
17
+ }
18
+ };
19
+ }
20
+
21
+ function createReplyStub() {
22
+ return {
23
+ sent: false,
24
+ statusCode: null,
25
+ payload: null,
26
+ code(value) {
27
+ this.statusCode = value;
28
+ return this;
29
+ },
30
+ send(value) {
31
+ this.payload = value;
32
+ this.sent = true;
33
+ return this;
34
+ }
35
+ };
36
+ }
37
+
38
+ test("auth route provider registers routes and executes login/logout handlers", async () => {
39
+ const events = [];
40
+ const fastify = createFastifyStub();
41
+ const app = createApplication();
42
+ const httpRuntime = createHttpRuntime({ app, fastify });
43
+
44
+ const authService = {
45
+ writeSessionCookies(_reply, session) {
46
+ events.push({ type: "writeSession", session });
47
+ },
48
+ clearSessionCookies() {
49
+ events.push({ type: "clearSession" });
50
+ },
51
+ getOAuthProviderCatalog() {
52
+ return { providers: [], defaultProvider: "" };
53
+ }
54
+ };
55
+
56
+ app.instance("authService", authService);
57
+ app.instance("actionExecutor", {
58
+ async execute({ actionId }) {
59
+ if (actionId === "auth.login.password") {
60
+ return {
61
+ session: { access_token: "a", refresh_token: "r" },
62
+ profile: { displayName: "Ada" }
63
+ };
64
+ }
65
+ if (actionId === "auth.logout") {
66
+ return {
67
+ ok: true,
68
+ clearSession: true
69
+ };
70
+ }
71
+ return {};
72
+ }
73
+ });
74
+
75
+ class MockAuthProvider {
76
+ static id = "auth.provider";
77
+ }
78
+
79
+ await app.start({ providers: [MockAuthProvider, AuthWebServiceProvider, AuthRouteServiceProvider] });
80
+
81
+ const registration = httpRuntime.registerRoutes();
82
+ assert.equal(registration.routeCount > 0, true);
83
+
84
+ const loginRoute = fastify.routes.find((route) => route.method === "POST" && route.url === "/api/login");
85
+ assert.ok(loginRoute);
86
+ const loginReply = createReplyStub();
87
+ await loginRoute.handler({ body: { email: "ada@example.com", password: "pass" } }, loginReply);
88
+ assert.equal(loginReply.statusCode, 200);
89
+ assert.equal(loginReply.payload.username, "Ada");
90
+
91
+ const logoutRoute = fastify.routes.find((route) => route.method === "POST" && route.url === "/api/logout");
92
+ assert.ok(logoutRoute);
93
+ const logoutReply = createReplyStub();
94
+ await logoutRoute.handler({}, logoutReply);
95
+ assert.equal(logoutReply.statusCode, 200);
96
+ assert.equal(logoutReply.payload.ok, true);
97
+
98
+ assert.equal(events.some((entry) => entry.type === "writeSession"), true);
99
+ assert.equal(events.some((entry) => entry.type === "clearSession"), true);
100
+ });