|
34 | 34 | * `vitest.config.ts`), so the routes and the service are the checkout's. |
35 | 35 | */ |
36 | 36 |
|
37 | | -import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 37 | +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; |
38 | 38 | import { Hono } from 'hono'; |
39 | 39 | import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; |
40 | 40 | import { |
@@ -73,6 +73,7 @@ const PLATFORM_ADMIN_SESSION = { user: { id: 'usr_admin', email: 'admin@example. |
73 | 73 | interface Rig { |
74 | 74 | svc: SettingsService; |
75 | 75 | manager: AuthManager; |
| 76 | + logger: { info: Mock; error: Mock; warn: Mock; debug: Mock }; |
76 | 77 | save(body: Record<string, unknown>, transport?: Transport): Promise<{ status: number; body: any }>; |
77 | 78 | /** `GET /api/v1/auth/admin/client-address` on the plugin's real auth routes. */ |
78 | 79 | read(transport?: Transport, opts?: { session?: unknown; query?: string }): |
@@ -153,7 +154,7 @@ async function boot( |
153 | 154 | getApi.mockRestore(); |
154 | 155 | } |
155 | 156 | }; |
156 | | - return { svc, manager, save, read }; |
| 157 | + return { svc, manager, logger: ctx.logger, save, read }; |
157 | 158 | } |
158 | 159 |
|
159 | 160 | const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null; |
@@ -390,6 +391,156 @@ describe('#30398 the address read serves the address the save check judges', () |
390 | 391 | }); |
391 | 392 | }); |
392 | 393 |
|
| 394 | +describe('an allowed IP range whose prefix is not decimal digits within its family is refused at every save door and never matches at the gate', () => { |
| 395 | + // An entry is an address, or an address, `/` and a prefix length of one or |
| 396 | + // two decimal digits from 0 to 32 (IPv4) or one to three from 0 to 128 |
| 397 | + // (IPv6). Each entry here is neither. The saving address, 10.1.2.3, is one |
| 398 | + // the list's other entry matches, so only the entry itself can refuse it. |
| 399 | + const UNREADABLE = ['10.0.0.0/', '10.0.0.0/ 24', '10.0.0.0/0x18', '10.0.0.0/33', '10.0.0.0/8/8', '2001:db8::/', '2001:db8::/129', 'office']; |
| 400 | + const SAVER = { remoteAddress: '10.1.2.3' }; |
| 401 | + |
| 402 | + it('the console save refuses a list holding one, names it, and stores nothing', async () => { |
| 403 | + const { svc, save } = await boot(); |
| 404 | + for (const entry of UNREADABLE) { |
| 405 | + for (const list of [entry, `10.1.2.3\n${entry}`]) { |
| 406 | + const res = await save({ allowed_ip_ranges: list }, SAVER); |
| 407 | + expect(res.status, JSON.stringify(list)).toBe(400); |
| 408 | + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); |
| 409 | + const field = res.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges'); |
| 410 | + expect(field.code).toBe('invalid_value'); |
| 411 | + expect(field.constraint).toEqual({ invalidEntries: [entry] }); |
| 412 | + expect(field.message).toContain(entry); |
| 413 | + expect(await stored(svc)).toBeNull(); |
| 414 | + } |
| 415 | + } |
| 416 | + }); |
| 417 | + |
| 418 | + it('the console save names the unreadable entry first, whatever address it is saved from', async () => { |
| 419 | + const { save } = await boot(); |
| 420 | + for (const transport of [{}, { remoteAddress: '198.51.100.1' }]) { |
| 421 | + const res = await save({ allowed_ip_ranges: '203.0.113.0/24, 10.0.0.0/, 2001:db8::/0x20' }, transport); |
| 422 | + expect(res.status).toBe(400); |
| 423 | + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); |
| 424 | + expect(res.body.error.details.fields[0].field).toBe('allowed_ip_ranges'); |
| 425 | + expect(res.body.error.details.fields[0].code).toBe('invalid_value'); |
| 426 | + expect(res.body.error.details.fields[0].constraint).toEqual({ invalidEntries: ['10.0.0.0/', '2001:db8::/0x20'] }); |
| 427 | + } |
| 428 | + }); |
| 429 | + |
| 430 | + it('an in-process write of a list holding one is refused too', async () => { |
| 431 | + const { svc } = await boot(); |
| 432 | + const err: any = await svc.set('auth', 'allowed_ip_ranges', '10.0.0.0/', {}).then(() => null, (e) => e); |
| 433 | + expect(err?.code).toBe('SETTINGS_VALIDATION'); |
| 434 | + expect(err.fields).toEqual([ |
| 435 | + expect.objectContaining({ field: 'allowed_ip_ranges', code: 'invalid_value', constraint: { invalidEntries: ['10.0.0.0/'] } }), |
| 436 | + ]); |
| 437 | + expect(await stored(svc)).toBeNull(); |
| 438 | + }); |
| 439 | + |
| 440 | + it("the deployment's own declared allowedIpRanges holding one refuses the boot, naming it", async () => { |
| 441 | + await expect(boot({ plugin: { allowedIpRanges: ['10.0.0.0/8', '10.0.0.0/'] } })).rejects.toThrow( |
| 442 | + /allowedIpRanges.*"10\.0\.0\.0\/"/, |
| 443 | + ); |
| 444 | + }); |
| 445 | + |
| 446 | + it('a list stored before the save check admits no client through the unreadable entry, and the boot names it', async () => { |
| 447 | + const svc = new SettingsService({ env: {} }); |
| 448 | + svc.registerManifest(authManifest); |
| 449 | + // Written before the plugin registers its save check: the shape of a list stored by an earlier release. |
| 450 | + await svc.set('auth', 'allowed_ip_ranges', '10.0.0.0/\n192.0.2.7', {}); |
| 451 | + const { manager, logger } = await boot({ svc }); |
| 452 | + expect(manager.isClientIpAllowed('10.1.2.3')).toBe(false); |
| 453 | + expect(manager.isClientIpAllowed('8.8.8.8')).toBe(false); |
| 454 | + expect(manager.isClientIpAllowed('192.0.2.7')).toBe(true); |
| 455 | + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('"10.0.0.0/"')); |
| 456 | + }); |
| 457 | + |
| 458 | + it('OS_AUTH_ALLOWED_IP_RANGES holding one admits no client through it, and the boot names it', async () => { |
| 459 | + const { manager, logger } = await boot({ env: { OS_AUTH_ALLOWED_IP_RANGES: '10.0.0.0/, 192.0.2.7' } }); |
| 460 | + expect(manager.isClientIpAllowed('10.1.2.3')).toBe(false); |
| 461 | + expect(manager.isClientIpAllowed('192.0.2.7')).toBe(true); |
| 462 | + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('"10.0.0.0/"')); |
| 463 | + }); |
| 464 | + |
| 465 | + it('a list of well-formed entries saves and gates as before (preservation)', async () => { |
| 466 | + const list = '10.0.0.0/8\n10.0.0.0/08, 192.0.2.7/32\n2001:db8::/32\n::1/128\n203.0.113.5\n2001:db8::1'; |
| 467 | + const { svc, manager, logger, save } = await boot(); |
| 468 | + const res = await save({ allowed_ip_ranges: list }, SAVER); |
| 469 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 470 | + expect(await stored(svc)).toBe(list); |
| 471 | + await vi.waitFor(() => expect(manager.isClientIpAllowed('198.51.100.1')).toBe(false)); |
| 472 | + expect(manager.isClientIpAllowed('10.200.0.1')).toBe(true); |
| 473 | + expect(manager.isClientIpAllowed('192.0.2.7')).toBe(true); |
| 474 | + expect(manager.isClientIpAllowed('192.0.2.8')).toBe(false); |
| 475 | + expect(manager.isClientIpAllowed('2001:db8:ffff::1')).toBe(true); |
| 476 | + expect(manager.isClientIpAllowed('::1')).toBe(true); |
| 477 | + expect(manager.isClientIpAllowed('203.0.113.5')).toBe(true); |
| 478 | + expect(logger.error).not.toHaveBeenCalledWith(expect.stringContaining('allowed_ip_ranges')); |
| 479 | + }); |
| 480 | +}); |
| 481 | + |
| 482 | +describe('an allowed_ip_ranges value that is not text is refused at every save door, and where it is stored the gate admits nobody through it', () => { |
| 483 | + // The setting is a textarea: its value is text, one entry per line or |
| 484 | + // comma-separated. Each value here is not text. The saving address, |
| 485 | + // 10.1.2.3, is one each of them spells, so only the value's form can refuse it. |
| 486 | + const NOT_TEXT: unknown[] = [['10.1.2.3'], ['10.0.0.0/8', '10.1.2.3'], [], 167838211, true, { ranges: '10.1.2.3' }]; |
| 487 | + const SAVER = { remoteAddress: '10.1.2.3' }; |
| 488 | + |
| 489 | + it('the console save refuses it and stores nothing', async () => { |
| 490 | + const { svc, save } = await boot(); |
| 491 | + for (const value of NOT_TEXT) { |
| 492 | + const res = await save({ allowed_ip_ranges: value }, SAVER); |
| 493 | + expect(res.status, JSON.stringify(value)).toBe(400); |
| 494 | + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); |
| 495 | + const field = res.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges'); |
| 496 | + expect(field.code).toBe('invalid_value'); |
| 497 | + expect(field.constraint).toEqual({ type: 'textarea' }); |
| 498 | + expect(field.message).toContain('not text'); |
| 499 | + expect(await stored(svc)).toBeNull(); |
| 500 | + } |
| 501 | + }); |
| 502 | + |
| 503 | + it('it is refused whatever address it is saved from', async () => { |
| 504 | + const { save } = await boot(); |
| 505 | + for (const transport of [{}, { remoteAddress: '198.51.100.1' }]) { |
| 506 | + const res = await save({ allowed_ip_ranges: ['203.0.113.0/24'] }, transport); |
| 507 | + expect(res.status).toBe(400); |
| 508 | + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); |
| 509 | + expect(res.body.error.details.fields[0].code).toBe('invalid_value'); |
| 510 | + expect(res.body.error.details.fields[0].constraint).toEqual({ type: 'textarea' }); |
| 511 | + } |
| 512 | + }); |
| 513 | + |
| 514 | + it('an in-process write of it is refused too', async () => { |
| 515 | + const { svc } = await boot(); |
| 516 | + for (const value of NOT_TEXT) { |
| 517 | + const err: any = await svc.set('auth', 'allowed_ip_ranges', value, {}).then(() => null, (e) => e); |
| 518 | + expect(err?.code, JSON.stringify(value)).toBe('SETTINGS_VALIDATION'); |
| 519 | + expect(err.fields).toEqual([ |
| 520 | + expect.objectContaining({ field: 'allowed_ip_ranges', code: 'invalid_value', constraint: { type: 'textarea' } }), |
| 521 | + ]); |
| 522 | + expect(await stored(svc)).toBeNull(); |
| 523 | + } |
| 524 | + }); |
| 525 | + |
| 526 | + it('a value stored before the save check admits no client, the boot names it, and saving the list as text replaces it', async () => { |
| 527 | + for (const value of [['10.1.2.3'], 167838211, { ranges: '10.1.2.3' }]) { |
| 528 | + const svc = new SettingsService({ env: {} }); |
| 529 | + svc.registerManifest(authManifest); |
| 530 | + // Written before the plugin registers its save check: the shape of a value stored by an earlier release. |
| 531 | + await svc.set('auth', 'allowed_ip_ranges', value, {}); |
| 532 | + const { manager, logger, save } = await boot({ svc }); |
| 533 | + expect(manager.isClientIpAllowed('10.1.2.3'), JSON.stringify(value)).toBe(false); |
| 534 | + expect(manager.isClientIpAllowed('8.8.8.8')).toBe(false); |
| 535 | + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/allowed_ip_ranges.*not text/)); |
| 536 | + const res = await save({ allowed_ip_ranges: '10.1.2.3' }, SAVER); |
| 537 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 538 | + await vi.waitFor(() => expect(manager.isClientIpAllowed('10.1.2.3')).toBe(true)); |
| 539 | + expect(manager.isClientIpAllowed('8.8.8.8')).toBe(false); |
| 540 | + } |
| 541 | + }); |
| 542 | +}); |
| 543 | + |
393 | 544 | describe('#30385 the env recovery clears the stored list, so lifting the override does not lock the caller out again', () => { |
394 | 545 | it('set the override empty, sign in, clear the stored list through the settings door, remove the override: the caller is admitted', async () => { |
395 | 546 | const env: Record<string, string | undefined> = {}; |
|
0 commit comments