|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#7541] The two producers of "can this host search" must answer the same |
| 4 | +// question the same way. |
| 5 | +// |
| 6 | +// The defect: `capabilities.search` came from a registered `search` SERVICE |
| 7 | +// SLOT, while `GET {basePath}/search` refused on something else entirely — |
| 8 | +// `typeof protocol.searchAll !== 'function'`. Nothing in either repository |
| 9 | +// registers that slot and the protocol implements `searchAll` unconditionally, |
| 10 | +// so every REST host advertised `capabilities.search = {enabled:false}` while |
| 11 | +// serving 200s with real hits. A client that trusts the discovery document — |
| 12 | +// which is the document's only purpose — skipped a working surface. Prime |
| 13 | +// Directive #10 inverted: not an advertised endpoint that 404s, but a live |
| 14 | +// endpoint no conforming client will ever call. |
| 15 | +// |
| 16 | +// WHAT THIS FILE ASSERTS, and why it is shaped this way: it does NOT assert |
| 17 | +// `enabled === true`. That assertion passes again the day someone hardcodes the |
| 18 | +// bit, which is the same class of defect one layer over. It asserts AGREEMENT — |
| 19 | +// `declared === served` — with both sides MEASURED from the real producers in |
| 20 | +// the same test: `capabilities.search` off the real `getDiscovery()` through |
| 21 | +// the real `/discovery` handler, and the served status off the real |
| 22 | +// `registerSearchEndpoints` handler. Three hosts that genuinely differ (below) |
| 23 | +// keep the agreement from holding vacuously. |
| 24 | + |
| 25 | +import { describe, it, expect, vi } from 'vitest'; |
| 26 | +import type { IHttpRequest } from '@objectstack/spec/contracts'; |
| 27 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 28 | +import { RestServer } from './rest-server.js'; |
| 29 | + |
| 30 | +/** |
| 31 | + * A complete `IHttpRequest`, typed against the contract rather than cast to |
| 32 | + * `any`. Not ceremony: `enforceAuth` — which runs before either predicate under |
| 33 | + * test — reads `method` and `path`, so a partial literal would exercise the |
| 34 | + * gate with `undefined` on both. Building the real shape is what makes the |
| 35 | + * measured statuses below the statuses a real caller gets. |
| 36 | + */ |
| 37 | +function request(path: string, query: Record<string, string> = {}): IHttpRequest { |
| 38 | + return { params: {}, query, headers: {}, method: 'GET', path }; |
| 39 | +} |
| 40 | + |
| 41 | +function createMockServer() { |
| 42 | + return { |
| 43 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), |
| 44 | + use: vi.fn(), |
| 45 | + listen: vi.fn().mockResolvedValue(undefined), |
| 46 | + close: vi.fn().mockResolvedValue(undefined), |
| 47 | + }; |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * An engine carrying ONE searchable object with ONE matching row, so the |
| 52 | + * served path is a real 200-with-hits rather than a 200-with-nothing — the |
| 53 | + * exact reproduction in the issue (`?q=audit` → 200, real hits). |
| 54 | + */ |
| 55 | +function createEngine() { |
| 56 | + const widget = { |
| 57 | + name: 'widget', |
| 58 | + fields: [{ name: 'title', type: 'text', searchable: true }], |
| 59 | + }; |
| 60 | + return { |
| 61 | + registry: { |
| 62 | + getObject: (n: string) => (n === 'widget' ? widget : undefined), |
| 63 | + getAllObjects: () => [widget], |
| 64 | + getRegisteredTypes: () => [], |
| 65 | + }, |
| 66 | + find: async () => [{ id: 'w1', title: 'audit trail' }], |
| 67 | + }; |
| 68 | +} |
| 69 | + |
| 70 | +type Host = { |
| 71 | + /** `capabilities.search.enabled` as the composed `/discovery` body reports it. */ |
| 72 | + declared: boolean; |
| 73 | + /** HTTP status `GET {basePath}/search?q=audit` actually answers. */ |
| 74 | + status: number; |
| 75 | + /** Hit count when it answered 200. */ |
| 76 | + hits: number; |
| 77 | +}; |
| 78 | + |
| 79 | +/** |
| 80 | + * Boot a REST server over the REAL protocol and read BOTH producers off it. |
| 81 | + * |
| 82 | + * `enableSearch` selects whether this server mounts the route at all; |
| 83 | + * `withSearchAll: false` removes the protocol's own implementation, which is |
| 84 | + * the input the route's 501 branch exists for. |
| 85 | + */ |
| 86 | +async function measure(opts: { |
| 87 | + enableSearch?: boolean; |
| 88 | + withSearchAll?: boolean; |
| 89 | +} = {}): Promise<Host> { |
| 90 | + const protocol: any = new ObjectStackProtocolImplementation( |
| 91 | + createEngine() as any, |
| 92 | + () => new Map(), |
| 93 | + ); |
| 94 | + if (opts.withSearchAll === false) { |
| 95 | + // Shadow the prototype method on the instance. Both predicates read the |
| 96 | + // same property off the same object, so this single override is what makes |
| 97 | + // the "protocol cannot search" host measurable at all — and it is why the |
| 98 | + // test cannot pass by two independent predicates coincidentally agreeing. |
| 99 | + Object.defineProperty(protocol, 'searchAll', { value: undefined, configurable: true }); |
| 100 | + } |
| 101 | + |
| 102 | + const config: any = { |
| 103 | + api: { |
| 104 | + requireAuth: false, |
| 105 | + ...(opts.enableSearch === false ? { enableSearch: false } : {}), |
| 106 | + }, |
| 107 | + }; |
| 108 | + const rest = new RestServer(createMockServer() as any, protocol as any, config); |
| 109 | + // Authenticated caller — step 1 of the issue's reproduction. `enforceAuth` |
| 110 | + // runs BEFORE the `searchAll` probe, so an anonymous request 401s and never |
| 111 | + // reaches either predicate; this is the house stub the other rest tests use |
| 112 | + // for authed handlers, and it is upstream of everything under test here. |
| 113 | + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); |
| 114 | + rest.registerRoutes(); |
| 115 | + const routes = rest.getRouteManager(); |
| 116 | + |
| 117 | + const discoveryEntry = routes.get('GET', '/api/v1/discovery'); |
| 118 | + if (!discoveryEntry) throw new Error('discovery route not registered'); |
| 119 | + let discoveryBody: any; |
| 120 | + const discoveryRes: any = { |
| 121 | + json: (b: any) => { discoveryBody = b; }, |
| 122 | + status: () => discoveryRes, |
| 123 | + }; |
| 124 | + await discoveryEntry.handler(request('/api/v1/discovery'), discoveryRes); |
| 125 | + const declared = discoveryBody?.capabilities?.search?.enabled; |
| 126 | + |
| 127 | + const searchEntry = routes.get('GET', '/api/v1/search'); |
| 128 | + if (!searchEntry) { |
| 129 | + // Not mounted — a client calling it gets the router's 404. That IS the |
| 130 | + // served answer for this host. |
| 131 | + return { declared, status: 404, hits: 0 }; |
| 132 | + } |
| 133 | + let status = 200; |
| 134 | + let searchBody: any; |
| 135 | + const searchRes: any = { |
| 136 | + status: (s: number) => { status = s; return searchRes; }, |
| 137 | + json: (b: any) => { searchBody = b; }, |
| 138 | + }; |
| 139 | + await searchEntry.handler(request('/api/v1/search', { q: 'audit' }), searchRes); |
| 140 | + return { declared, status, hits: searchBody?.hits?.length ?? 0 }; |
| 141 | +} |
| 142 | + |
| 143 | +/** Served ⇔ a caller can get search results out of this host. */ |
| 144 | +const isServed = (h: Host) => h.status !== 404 && h.status !== 501; |
| 145 | + |
| 146 | +describe('[#7541] `capabilities.search` and the /search route answer one question', () => { |
| 147 | + it('agrees on the ordinary host — where the document used to contradict the endpoint', async () => { |
| 148 | + const host = await measure(); |
| 149 | + |
| 150 | + // The symptom, measured: the endpoint really does serve real hits here. |
| 151 | + expect(host.status).toBe(200); |
| 152 | + expect(host.hits).toBeGreaterThan(0); |
| 153 | + |
| 154 | + // The pin: whatever the endpoint does, the document says the same thing. |
| 155 | + // Before the fix `declared` was false against a 200 — the inversion. |
| 156 | + expect(host.declared).toBe(isServed(host)); |
| 157 | + }); |
| 158 | + |
| 159 | + it('agrees on a host that does not mount the route (`api.enableSearch: false`)', async () => { |
| 160 | + const host = await measure({ enableSearch: false }); |
| 161 | + |
| 162 | + expect(host.status).toBe(404); |
| 163 | + expect(host.declared).toBe(isServed(host)); |
| 164 | + }); |
| 165 | + |
| 166 | + it('agrees on a protocol that cannot search — both ends refuse on the same predicate', async () => { |
| 167 | + const host = await measure({ withSearchAll: false }); |
| 168 | + |
| 169 | + // The route's own 501 branch, reached through the real handler. |
| 170 | + expect(host.status).toBe(501); |
| 171 | + expect(host.declared).toBe(isServed(host)); |
| 172 | + }); |
| 173 | + |
| 174 | + it('anti-vacuity: the three hosts are genuinely discriminated, in both directions', async () => { |
| 175 | + const [served, unmounted, unimplemented] = await Promise.all([ |
| 176 | + measure(), |
| 177 | + measure({ enableSearch: false }), |
| 178 | + measure({ withSearchAll: false }), |
| 179 | + ]); |
| 180 | + |
| 181 | + // Without this, `declared === served` would hold for the empty reason if |
| 182 | + // some future edit pinned the bit — or the route — to one constant. |
| 183 | + expect([served.declared, unmounted.declared, unimplemented.declared]) |
| 184 | + .toEqual([true, false, false]); |
| 185 | + expect([served.status, unmounted.status, unimplemented.status]) |
| 186 | + .toEqual([200, 404, 501]); |
| 187 | + }); |
| 188 | +}); |
0 commit comments