|
21 | 21 | * the deployment declares `trustProxy`. A list that includes it saves. |
22 | 22 | * - The recovery the refusal names: `OS_AUTH_ALLOWED_IP_RANGES` set empty |
23 | 23 | * opens the gate on the next boot over a stored list. |
| 24 | + * - [#30398] The read an administrator asks before saving, |
| 25 | + * `GET /api/v1/auth/admin/client-address`, answers the address that same |
| 26 | + * save would be judged by: the plugin's real auth routes on a real Hono app, |
| 27 | + * the transport peer supplied the way `@hono/node-server` supplies it. |
24 | 28 | * |
25 | 29 | * `@objectstack/service-settings` resolves to its `src/` here (this package's |
26 | 30 | * `vitest.config.ts`), so the routes and the service are the checkout's. |
27 | 31 | */ |
28 | 32 |
|
29 | 33 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 34 | +import { Hono } from 'hono'; |
30 | 35 | import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; |
31 | 36 | import { |
32 | 37 | SettingsService, |
@@ -56,11 +61,18 @@ class MockHttp implements IHttpServer { |
56 | 61 | getInstance() { return null; } |
57 | 62 | } |
58 | 63 |
|
| 64 | +type Transport = { headers?: Record<string, string>; remoteAddress?: string }; |
| 65 | + |
| 66 | +/** The session the read's platform-admin gate is handed: a platform admin unless a test says otherwise. */ |
| 67 | +const PLATFORM_ADMIN_SESSION = { user: { id: 'usr_admin', email: 'admin@example.com', isPlatformAdmin: true } }; |
| 68 | + |
59 | 69 | interface Rig { |
60 | 70 | svc: SettingsService; |
61 | 71 | manager: AuthManager; |
62 | | - save(body: Record<string, unknown>, transport?: { headers?: Record<string, string>; remoteAddress?: string }): |
63 | | - Promise<{ status: number; body: any }>; |
| 72 | + save(body: Record<string, unknown>, transport?: Transport): Promise<{ status: number; body: any }>; |
| 73 | + /** `GET /api/v1/auth/admin/client-address` on the plugin's real auth routes. */ |
| 74 | + read(transport?: Transport, opts?: { session?: unknown; query?: string }): |
| 75 | + Promise<{ status: number; body: any; cacheControl: string | null }>; |
64 | 76 | } |
65 | 77 |
|
66 | 78 | async function boot( |
@@ -116,7 +128,28 @@ async function boot( |
116 | 128 | await http.routes.get('PUT /api/settings/:namespace')!(req, res); |
117 | 129 | return out; |
118 | 130 | }; |
119 | | - return { svc, manager, save }; |
| 131 | + |
| 132 | + let app: Hono | undefined; |
| 133 | + const read: Rig['read'] = async (transport = {}, opts = {}) => { |
| 134 | + if (!app) { |
| 135 | + app = new Hono(); |
| 136 | + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); |
| 137 | + } |
| 138 | + const session = 'session' in opts ? opts.session : PLATFORM_ADMIN_SESSION; |
| 139 | + // The gate reads the session through the auth service's in-process read. |
| 140 | + const getApi = vi.spyOn(manager, 'getApi').mockResolvedValue({ getSession: async () => session } as any); |
| 141 | + try { |
| 142 | + const res = await app.request( |
| 143 | + `http://localhost:3000/api/v1/auth/admin/client-address${opts.query ?? ''}`, |
| 144 | + { method: 'GET', headers: transport.headers ?? {} }, |
| 145 | + transport.remoteAddress ? { incoming: { socket: { remoteAddress: transport.remoteAddress } } } : {}, |
| 146 | + ); |
| 147 | + return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') }; |
| 148 | + } finally { |
| 149 | + getApi.mockRestore(); |
| 150 | + } |
| 151 | + }; |
| 152 | + return { svc, manager, save, read }; |
120 | 153 | } |
121 | 154 |
|
122 | 155 | const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null; |
@@ -257,3 +290,98 @@ describe('#30320 an IPv6 range is judged at both doors as the range it names', ( |
257 | 290 | expect(manager.isClientIpAllowed('2001:db8:1234::7')).toBe(true); |
258 | 291 | }); |
259 | 292 | }); |
| 293 | + |
| 294 | +describe('#30398 the address read serves the address the save check judges', () => { |
| 295 | + // TEST-NET-1: no address any case below connects from falls inside it, so a |
| 296 | + // save of this list is always refused and its refusal names the judged address. |
| 297 | + const NOBODY = '192.0.2.0/24'; |
| 298 | + |
| 299 | + /** One connection, asked at both doors: the read's answer and the save check's judged address. */ |
| 300 | + const bothDoors = async (rig: Rig, transport: Transport) => { |
| 301 | + const read = await rig.read(transport); |
| 302 | + expect(read.status, JSON.stringify(read.body)).toBe(200); |
| 303 | + const save = await rig.save({ allowed_ip_ranges: NOBODY }, transport); |
| 304 | + expect(save.status, JSON.stringify(save.body)).toBe(400); |
| 305 | + const judged = save.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges').constraint.clientAddress; |
| 306 | + return { shown: read.body.data, judged }; |
| 307 | + }; |
| 308 | + |
| 309 | + it('a direct peer: the read shows the peer address the save judges', async () => { |
| 310 | + const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '203.0.113.9' }); |
| 311 | + expect(shown).toEqual({ clientAddress: '203.0.113.9', trustProxy: false }); |
| 312 | + expect(judged).toBe(shown.clientAddress); |
| 313 | + }); |
| 314 | + |
| 315 | + it('an IPv4-mapped peer: the read shows the mapped text the save judges, not a rewritten one', async () => { |
| 316 | + const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '::ffff:127.0.0.1' }); |
| 317 | + expect(shown).toEqual({ clientAddress: '::ffff:127.0.0.1', trustProxy: false }); |
| 318 | + expect(judged).toBe(shown.clientAddress); |
| 319 | + }); |
| 320 | + |
| 321 | + it('trustProxy declared: the read shows the forwarded address the save judges', async () => { |
| 322 | + const forwarded = { headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.2' }, remoteAddress: '10.0.0.1' }; |
| 323 | + const { shown, judged } = await bothDoors(await boot({ plugin: { trustProxy: true } }), forwarded); |
| 324 | + expect(shown).toEqual({ clientAddress: '198.51.100.7', trustProxy: true }); |
| 325 | + expect(judged).toBe(shown.clientAddress); |
| 326 | + }); |
| 327 | + |
| 328 | + it('trustProxy not declared: a forged X-Forwarded-For is ignored at both doors, the peer is shown', async () => { |
| 329 | + const forged = { headers: { 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '198.51.100.8' }, remoteAddress: '10.0.0.1' }; |
| 330 | + const { shown, judged } = await bothDoors(await boot(), forged); |
| 331 | + expect(shown).toEqual({ clientAddress: '10.0.0.1', trustProxy: false }); |
| 332 | + expect(judged).toBe(shown.clientAddress); |
| 333 | + }); |
| 334 | + |
| 335 | + it('an address the server cannot establish reads null at both doors', async () => { |
| 336 | + const { shown, judged } = await bothDoors(await boot(), {}); |
| 337 | + expect(shown).toEqual({ clientAddress: null, trustProxy: false }); |
| 338 | + expect(judged).toBeNull(); |
| 339 | + }); |
| 340 | + |
| 341 | + it('the answer is never stored by a cache', async () => { |
| 342 | + const res = await (await boot()).read({ remoteAddress: '203.0.113.9' }); |
| 343 | + expect(res.status).toBe(200); |
| 344 | + expect(res.cacheControl).toBe('no-store'); |
| 345 | + }); |
| 346 | + |
| 347 | + describe('who may read it: the platform-admin gate of every /admin mount, before any address is read', () => { |
| 348 | + const refusedBeforeTheRead = async (session: unknown) => { |
| 349 | + const rig = await boot(); |
| 350 | + const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders'); |
| 351 | + const res = await rig.read({ remoteAddress: '203.0.113.9' }, { session }); |
| 352 | + expect(asked).not.toHaveBeenCalled(); |
| 353 | + expect(res.body.success).toBe(false); |
| 354 | + expect(res.body.data).toBeUndefined(); |
| 355 | + return res; |
| 356 | + }; |
| 357 | + |
| 358 | + it('no session: 401 UNAUTHENTICATED', async () => { |
| 359 | + const res = await refusedBeforeTheRead(null); |
| 360 | + expect(res.status).toBe(401); |
| 361 | + expect(res.body.error.code).toBe('UNAUTHENTICATED'); |
| 362 | + }); |
| 363 | + |
| 364 | + it('a signed-in caller who is not a platform admin: 403 PERMISSION_DENIED', async () => { |
| 365 | + const res = await refusedBeforeTheRead({ user: { id: 'usr_member', email: 'member@example.com' } }); |
| 366 | + expect(res.status).toBe(403); |
| 367 | + expect(res.body.error.code).toBe('PERMISSION_DENIED'); |
| 368 | + }); |
| 369 | + |
| 370 | + it("a held platform admin's session (ADR-0069 D3): 403 with the posture's own code", async () => { |
| 371 | + const held = { user: { ...PLATFORM_ADMIN_SESSION.user, authGate: { code: 'MFA_REQUIRED', message: 'Set up two-factor authentication.' } } }; |
| 372 | + const res = await refusedBeforeTheRead(held); |
| 373 | + expect(res.status).toBe(403); |
| 374 | + expect(res.body.error.code).toBe('MFA_REQUIRED'); |
| 375 | + }); |
| 376 | + }); |
| 377 | + |
| 378 | + it('the query set is closed and empty: any parameter is refused 400 VALIDATION_ERROR before the address is read', async () => { |
| 379 | + const rig = await boot(); |
| 380 | + const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders'); |
| 381 | + const res = await rig.read({ remoteAddress: '203.0.113.9' }, { query: '?address=198.51.100.7' }); |
| 382 | + expect(res.status).toBe(400); |
| 383 | + expect(res.body.error.code).toBe('VALIDATION_ERROR'); |
| 384 | + expect(res.body.data).toBeUndefined(); |
| 385 | + expect(asked).not.toHaveBeenCalled(); |
| 386 | + }); |
| 387 | +}); |
0 commit comments