|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23214] The `${prefix}/*` catch-all hands `dispatch()` a REPEATED query name |
| 5 | + * as an array, and a single-valued one as the plain string it always handed. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * The catch-all copied `new URL(c.req.url).searchParams.forEach` into an |
| 10 | + * object, so `?package=a&package=b` reached `dispatch()` as `'b'`: the first |
| 11 | + * value was dropped without a word, and every arity rule the dispatcher |
| 12 | + * declares was unreachable on a `createHonoApp` host. It now reads the query as |
| 13 | + * `plugin-hono-server`'s `readQuery` does (`readDispatchQuery`). |
| 14 | + * |
| 15 | + * ## What is pinned here |
| 16 | + * |
| 17 | + * 1. THE SHAPE: a repeated name arrives as the array of every value, in order. |
| 18 | + * 2. THE CONTROL: every single-valued name arrives as the string the old line |
| 19 | + * produced — checked against a reference Hono app that runs that exact |
| 20 | + * line, so "unchanged" is a comparison, not a recollection. |
| 21 | + * 3. `__proto__` is an OWN key and no prototype moves. The old dynamic write |
| 22 | + * dropped the name silently; `Object.fromEntries` keeps it as data. |
| 23 | + * |
| 24 | + * The dispatcher is a recording fixture: this package's config aliases |
| 25 | + * `@objectstack/runtime` to a stub for the whole suite. That the REAL |
| 26 | + * dispatcher then refuses the repeat — `/meta`, `/packages`, `?keepData` — is |
| 27 | + * pinned through a real `createHonoApp` in |
| 28 | + * `packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts`. |
| 29 | + */ |
| 30 | + |
| 31 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 32 | +import { Hono } from 'hono'; |
| 33 | + |
| 34 | +const seen: unknown[] = []; |
| 35 | + |
| 36 | +const mockDispatcher = { |
| 37 | + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), |
| 38 | + handleAuth: vi.fn(async () => ({ handled: true, response: { status: 200, body: { ok: true } } })), |
| 39 | + dispatch: vi.fn(async (_method: string, _path: string, _body: unknown, query: unknown) => { |
| 40 | + seen.push(query); |
| 41 | + return { handled: true, response: { status: 200, body: { ok: true } } }; |
| 42 | + }), |
| 43 | +}; |
| 44 | + |
| 45 | +vi.mock('@objectstack/runtime', () => ({ |
| 46 | + HttpDispatcher: function HttpDispatcher() { return mockDispatcher; }, |
| 47 | +})); |
| 48 | + |
| 49 | +import { createHonoApp } from './index'; |
| 50 | + |
| 51 | +const PREFIX = '/api/v1'; |
| 52 | +const app = createHonoApp({ kernel: { name: 'test-kernel' } as any, prefix: PREFIX, cors: false }); |
| 53 | + |
| 54 | +/** What `dispatch()` was handed as its query for one request through the catch-all. */ |
| 55 | +async function dispatchedQuery(search: string): Promise<any> { |
| 56 | + seen.length = 0; |
| 57 | + const res = await app.request(`http://localhost${PREFIX}/meta/app/crm${search}`); |
| 58 | + expect(res.status).toBe(200); |
| 59 | + expect(seen).toHaveLength(1); |
| 60 | + return seen[0]; |
| 61 | +} |
| 62 | + |
| 63 | +/** The catch-all's query line as it was before #23214 — the reference for "unchanged". */ |
| 64 | +async function referenceQuery(search: string): Promise<Record<string, any>> { |
| 65 | + let out: Record<string, any> = {}; |
| 66 | + const ref = new Hono(); |
| 67 | + ref.get('/*', (c) => { |
| 68 | + const queryParams: Record<string, any> = {}; |
| 69 | + const url = new URL(c.req.url); |
| 70 | + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); |
| 71 | + out = queryParams; |
| 72 | + return c.text('ok'); |
| 73 | + }); |
| 74 | + await ref.request(`http://localhost/x${search}`); |
| 75 | + return out; |
| 76 | +} |
| 77 | + |
| 78 | +beforeEach(() => { seen.length = 0; }); |
| 79 | + |
| 80 | +describe('[#23214] a repeated query name reaches dispatch() as an array', () => { |
| 81 | + it.each([ |
| 82 | + ['?package=a&package=b', { package: ['a', 'b'] }], |
| 83 | + ['?package=a&package=b&package=c', { package: ['a', 'b', 'c'] }], |
| 84 | + ['?keepData=true&keepData=true&layers=1', { keepData: ['true', 'true'], layers: '1' }], |
| 85 | + ['?status=a&type=x&status=b', { status: ['a', 'b'], type: 'x' }], |
| 86 | + ])('%s', async (search, expected) => { |
| 87 | + expect(await dispatchedQuery(search)).toEqual(expected); |
| 88 | + }); |
| 89 | +}); |
| 90 | + |
| 91 | +describe('[#23214] a single-valued name arrives exactly as the old line produced it', () => { |
| 92 | + it.each([ |
| 93 | + [''], |
| 94 | + ['?package=crm'], |
| 95 | + ['?package=crm&preview=draft&locale=fr'], |
| 96 | + ['?q=hello+world'], |
| 97 | + ['?q=hello%20world&x=%E4%B8%AD'], |
| 98 | + ['?na%6De=v'], |
| 99 | + ['?empty='], |
| 100 | + ['?flag'], |
| 101 | + ['?select=id,name'], |
| 102 | + ])('%j', async (search) => { |
| 103 | + const got = await dispatchedQuery(search); |
| 104 | + expect(got).toEqual(await referenceQuery(search)); |
| 105 | + for (const value of Object.values(got)) expect(typeof value).toBe('string'); |
| 106 | + }); |
| 107 | +}); |
| 108 | + |
| 109 | +describe('[#23214] `__proto__` is an own key, and no prototype moves', () => { |
| 110 | + it('keeps one `__proto__` as an own data property', async () => { |
| 111 | + const got = await dispatchedQuery('?__proto__=x&polluted=1'); |
| 112 | + expect(Object.prototype.hasOwnProperty.call(got, '__proto__')).toBe(true); |
| 113 | + expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toBe('x'); |
| 114 | + expect(Object.getPrototypeOf(got)).toBe(Object.prototype); |
| 115 | + expect(({} as any).polluted).toBeUndefined(); |
| 116 | + expect(got.polluted).toBe('1'); |
| 117 | + }); |
| 118 | + |
| 119 | + it('keeps a repeated `__proto__` as an own array', async () => { |
| 120 | + const got = await dispatchedQuery('?__proto__=x&__proto__=y'); |
| 121 | + expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toEqual(['x', 'y']); |
| 122 | + expect(Object.getPrototypeOf(got)).toBe(Object.prototype); |
| 123 | + }); |
| 124 | +}); |
0 commit comments