|
| 1 | +/** |
| 2 | + * Copyright (C) 2024-present Puter Technologies Inc. |
| 3 | + * |
| 4 | + * This file is part of Puter. |
| 5 | + * |
| 6 | + * Puter is free software: you can redistribute it and/or modify |
| 7 | + * it under the terms of the GNU Affero General Public License as published |
| 8 | + * by the Free Software Foundation, either version 3 of the License, or |
| 9 | + * (at your option) any later version. |
| 10 | + * |
| 11 | + * This program is distributed in the hope that it will be useful, |
| 12 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | + * GNU Affero General Public License for more details. |
| 15 | + * |
| 16 | + * You should have received a copy of the GNU Affero General Public License |
| 17 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 18 | + */ |
| 19 | + |
| 20 | +/* eslint-disable @typescript-eslint/no-explicit-any */ |
| 21 | +import { EventEmitter } from 'node:events'; |
| 22 | +import type { Request, RequestHandler, Response } from 'express'; |
| 23 | +import { beforeEach, describe, expect, it } from 'vitest'; |
| 24 | +import { runWithContext } from '../../core/context.js'; |
| 25 | +import { isHttpError } from '../../core/http/HttpError.js'; |
| 26 | +import { configureRateLimit } from '../../core/http/middleware/rateLimit.js'; |
| 27 | +import { DriverController } from './DriverController.js'; |
| 28 | + |
| 29 | +// Focused integration coverage for the concurrent-acquire path inside |
| 30 | +// `#handleCall`. The main DriverController.test.ts boots the full |
| 31 | +// PuterServer, but exercising concurrent-slot behaviour through real |
| 32 | +// drivers requires real provider machinery — too much for a unit test. |
| 33 | +// Here we instantiate the controller directly with a synthetic driver |
| 34 | +// that carries the only field we care about (`concurrent`), then drive |
| 35 | +// `#handleCall` through the same captureRoutes trick the main file uses. |
| 36 | + |
| 37 | +// ── Test harness ──────────────────────────────────────────────────── |
| 38 | + |
| 39 | +const captureCallHandler = (controller: DriverController): RequestHandler => { |
| 40 | + let handler: RequestHandler | undefined; |
| 41 | + const fakeRouter = { |
| 42 | + post: (path: string, _opts: unknown, h: RequestHandler) => { |
| 43 | + if (path === '/call') handler = h; |
| 44 | + return fakeRouter; |
| 45 | + }, |
| 46 | + get: () => fakeRouter, |
| 47 | + use: () => fakeRouter, |
| 48 | + }; |
| 49 | + controller.registerRoutes( |
| 50 | + fakeRouter as unknown as Parameters< |
| 51 | + typeof controller.registerRoutes |
| 52 | + >[0], |
| 53 | + ); |
| 54 | + if (!handler) throw new Error('failed to capture POST /call handler'); |
| 55 | + return handler; |
| 56 | +}; |
| 57 | + |
| 58 | +// `res.once('finish'|'close')` is the trigger for slot release, so the |
| 59 | +// stub must actually be an EventEmitter — that's the contract the |
| 60 | +// controller relies on. |
| 61 | +class StubRes extends EventEmitter { |
| 62 | + statusCode = 200; |
| 63 | + body: unknown = undefined; |
| 64 | + headers: Record<string, string> = {}; |
| 65 | + sentBody: string | undefined; |
| 66 | + contentType: string | undefined; |
| 67 | + status(code: number) { |
| 68 | + this.statusCode = code; |
| 69 | + return this; |
| 70 | + } |
| 71 | + json(body: unknown) { |
| 72 | + this.body = body; |
| 73 | + return this; |
| 74 | + } |
| 75 | + setHeader(key: string, value: string) { |
| 76 | + this.headers[key.toLowerCase()] = value; |
| 77 | + return this; |
| 78 | + } |
| 79 | + type(t: string) { |
| 80 | + this.contentType = t; |
| 81 | + return this; |
| 82 | + } |
| 83 | + send(body: string) { |
| 84 | + this.sentBody = body; |
| 85 | + return this; |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +// Memory-backend counters are module-level state that persists across |
| 90 | +// tests. To avoid one test's held slot leaking into the next we vary |
| 91 | +// the request fingerprint per test (the helper keys off |
| 92 | +// `req.actor?.user?.uuid || fingerprint(req)`, where fingerprint mixes |
| 93 | +// IP + UA + accept headers). |
| 94 | +const makeReq = ( |
| 95 | + body: Record<string, unknown> = {}, |
| 96 | + fingerprintTag = 'default', |
| 97 | +): Request => |
| 98 | + ({ |
| 99 | + body, |
| 100 | + // Anonymous — `#handleCall` skips the permission gate when there's no |
| 101 | + // actor, which keeps this test focused on the concurrent path. |
| 102 | + actor: undefined, |
| 103 | + headers: { 'user-agent': fingerprintTag }, |
| 104 | + query: {}, |
| 105 | + ip: '127.0.0.1', |
| 106 | + socket: { remoteAddress: '127.0.0.1' }, |
| 107 | + }) as unknown as Request; |
| 108 | + |
| 109 | +// Synthetic driver with a single-slot `concurrent` cap. `ping` doesn't |
| 110 | +// touch the network; it just returns a string so the controller can |
| 111 | +// json-respond. |
| 112 | +const makeSyntheticDriver = () => ({ |
| 113 | + driverInterface: 'test-iface', |
| 114 | + driverName: 'test-driver', |
| 115 | + isDefault: true, |
| 116 | + concurrent: { |
| 117 | + default: { limit: 1 }, |
| 118 | + }, |
| 119 | + onServerStart() {}, |
| 120 | + onServerPrepareShutdown() {}, |
| 121 | + onServerShutdown() {}, |
| 122 | + ping: async () => 'pong', |
| 123 | +}); |
| 124 | + |
| 125 | +const buildController = (driver: ReturnType<typeof makeSyntheticDriver>) => { |
| 126 | + // The controller reads `this.services?.permission` only when an actor |
| 127 | + // is on the request; otherwise the services bag is unused. Empty |
| 128 | + // objects are sufficient for this path. |
| 129 | + return new DriverController( |
| 130 | + {} as any, |
| 131 | + {} as any, |
| 132 | + {} as any, |
| 133 | + {} as any, |
| 134 | + { syntheticDriver: driver } as any, |
| 135 | + ); |
| 136 | +}; |
| 137 | + |
| 138 | +// ── Tests ─────────────────────────────────────────────────────────── |
| 139 | + |
| 140 | +describe('DriverController — concurrent acquire/release', () => { |
| 141 | + beforeEach(() => { |
| 142 | + // Memory backend is sufficient and avoids cross-test redis state. |
| 143 | + configureRateLimit(); |
| 144 | + }); |
| 145 | + |
| 146 | + // `#handleCall` writes to Context (e.g. `driverName`) which requires |
| 147 | + // a request scope — same as the main test file does. |
| 148 | + const callInScope = ( |
| 149 | + handler: RequestHandler, |
| 150 | + req: Request, |
| 151 | + res: Response, |
| 152 | + ) => runWithContext({}, () => handler(req, res, () => {})); |
| 153 | + |
| 154 | + it('admits a call up to the per-method concurrent limit', async () => { |
| 155 | + const controller = buildController(makeSyntheticDriver()); |
| 156 | + const handler = captureCallHandler(controller); |
| 157 | + |
| 158 | + const res = new StubRes(); |
| 159 | + await callInScope( |
| 160 | + handler, |
| 161 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'admit'), |
| 162 | + res as unknown as Response, |
| 163 | + ); |
| 164 | + // Slot freed before next test runs. |
| 165 | + res.emit('finish'); |
| 166 | + |
| 167 | + // Driver method ran and produced the wrapped envelope. |
| 168 | + expect(res.body).toMatchObject({ |
| 169 | + success: true, |
| 170 | + result: 'pong', |
| 171 | + service: { name: 'test-driver' }, |
| 172 | + }); |
| 173 | + }); |
| 174 | + |
| 175 | + it("rejects a second concurrent call with 429 while the first slot is still held", async () => { |
| 176 | + const controller = buildController(makeSyntheticDriver()); |
| 177 | + const handler = captureCallHandler(controller); |
| 178 | + |
| 179 | + // First call admits and finishes synchronously — but we DO NOT |
| 180 | + // emit `finish`/`close`, so its slot stays held. |
| 181 | + const res1 = new StubRes(); |
| 182 | + await callInScope( |
| 183 | + handler, |
| 184 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'reject'), |
| 185 | + res1 as unknown as Response, |
| 186 | + ); |
| 187 | + |
| 188 | + try { |
| 189 | + // Second call must throw a 429 — slot is full. |
| 190 | + await expect( |
| 191 | + callInScope( |
| 192 | + handler, |
| 193 | + makeReq( |
| 194 | + { interface: 'test-iface', method: 'ping' }, |
| 195 | + 'reject', |
| 196 | + ), |
| 197 | + new StubRes() as unknown as Response, |
| 198 | + ), |
| 199 | + ).rejects.toSatisfy( |
| 200 | + (e) => |
| 201 | + isHttpError(e) && |
| 202 | + (e as { statusCode: number }).statusCode === 429, |
| 203 | + ); |
| 204 | + } finally { |
| 205 | + // Clean up so the held slot doesn't pin the bucket on subsequent |
| 206 | + // suites that reuse the same fingerprint. |
| 207 | + res1.emit('finish'); |
| 208 | + await new Promise((r) => setImmediate(r)); |
| 209 | + } |
| 210 | + }); |
| 211 | + |
| 212 | + it("releases the slot on res 'finish' so the next caller is admitted", async () => { |
| 213 | + const controller = buildController(makeSyntheticDriver()); |
| 214 | + const handler = captureCallHandler(controller); |
| 215 | + |
| 216 | + const res1 = new StubRes(); |
| 217 | + await callInScope( |
| 218 | + handler, |
| 219 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'finish'), |
| 220 | + res1 as unknown as Response, |
| 221 | + ); |
| 222 | + // Emulate response completion. |
| 223 | + res1.emit('finish'); |
| 224 | + // The release path uses `Promise.resolve().then(...)`, so flush |
| 225 | + // the microtask queue before re-attempting. |
| 226 | + await new Promise((r) => setImmediate(r)); |
| 227 | + |
| 228 | + // A fresh call must now succeed. |
| 229 | + const res2 = new StubRes(); |
| 230 | + await callInScope( |
| 231 | + handler, |
| 232 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'finish'), |
| 233 | + res2 as unknown as Response, |
| 234 | + ); |
| 235 | + res2.emit('finish'); |
| 236 | + expect(res2.body).toMatchObject({ success: true, result: 'pong' }); |
| 237 | + }); |
| 238 | + |
| 239 | + it("releases on 'close' too — aborted requests don't pin the slot", async () => { |
| 240 | + const controller = buildController(makeSyntheticDriver()); |
| 241 | + const handler = captureCallHandler(controller); |
| 242 | + |
| 243 | + const res1 = new StubRes(); |
| 244 | + await callInScope( |
| 245 | + handler, |
| 246 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'abort'), |
| 247 | + res1 as unknown as Response, |
| 248 | + ); |
| 249 | + // Simulate the client closing the connection mid-flight. |
| 250 | + res1.emit('close'); |
| 251 | + await new Promise((r) => setImmediate(r)); |
| 252 | + |
| 253 | + const res2 = new StubRes(); |
| 254 | + await callInScope( |
| 255 | + handler, |
| 256 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'abort'), |
| 257 | + res2 as unknown as Response, |
| 258 | + ); |
| 259 | + res2.emit('finish'); |
| 260 | + expect(res2.body).toMatchObject({ success: true, result: 'pong' }); |
| 261 | + }); |
| 262 | + |
| 263 | + it('does not attach release listeners when the driver declares no concurrent config', async () => { |
| 264 | + // The optimisation that lets the existing test stubs in |
| 265 | + // DriverController.test.ts get away without an EventEmitter-shaped |
| 266 | + // `res`: skip the once() wiring entirely when there's no spec. |
| 267 | + const driver = makeSyntheticDriver(); |
| 268 | + (driver as { concurrent?: unknown }).concurrent = undefined; |
| 269 | + const controller = buildController(driver); |
| 270 | + const handler = captureCallHandler(controller); |
| 271 | + |
| 272 | + // Bare object with no event-emitter surface — exposes the bug |
| 273 | + // case where the gate would try to call `res.once`. |
| 274 | + const bareRes = { |
| 275 | + statusCode: 200, |
| 276 | + body: undefined as unknown, |
| 277 | + status(code: number) { |
| 278 | + this.statusCode = code; |
| 279 | + return this; |
| 280 | + }, |
| 281 | + json(body: unknown) { |
| 282 | + this.body = body; |
| 283 | + return this; |
| 284 | + }, |
| 285 | + setHeader() { |
| 286 | + return this; |
| 287 | + }, |
| 288 | + type() { |
| 289 | + return this; |
| 290 | + }, |
| 291 | + send() { |
| 292 | + return this; |
| 293 | + }, |
| 294 | + }; |
| 295 | + |
| 296 | + await callInScope( |
| 297 | + handler, |
| 298 | + makeReq({ interface: 'test-iface', method: 'ping' }, 'no-spec'), |
| 299 | + bareRes as unknown as Response, |
| 300 | + ); |
| 301 | + expect(bareRes.body).toMatchObject({ success: true, result: 'pong' }); |
| 302 | + }); |
| 303 | +}); |
0 commit comments