|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#16019] `POST /api/v1/packages/publish` and `DELETE /api/v1/packages/:id` |
| 5 | + * — the wire `code` a raw-exec driver fault answers moved, and this file pins |
| 6 | + * the flip at the door. |
| 7 | + * |
| 8 | + * ## The flip |
| 9 | + * |
| 10 | + * `PackageService.publish` / `delete` (`service-package/src/index.ts`) wrap |
| 11 | + * `objectql.execute(...)` in a catch whose branch ② re-throws any error that |
| 12 | + * `declaresHttpAnswer` — a numeric `status` or `statusCode` — and whose branch |
| 13 | + * ③ swallows everything else as a driver fault, returning `{ success: false }` |
| 14 | + * for the door's `sendError` to answer `500 PACKAGE_PUBLISH_FAILED` / |
| 15 | + * `500 PACKAGE_DELETE_FAILED`. |
| 16 | + * |
| 17 | + * Before #16019 a raw-exec driver fault carried no `status` (knex's error |
| 18 | + * object: `code: 'SQLITE_ERROR'`, message `STATEMENT - DIAGNOSTIC`) → branch |
| 19 | + * ③. Since #16019 `SqlDriver.execute()` declares it — `code: DATABASE_ERROR`, |
| 20 | + * `status: 500`, a composed message, the dialect error under a non-enumerable |
| 21 | + * `cause` — → branch ② re-throws it → this door's catch-all `sendThrownError` |
| 22 | + * → `500 DATABASE_ERROR`, the composed sentence as the message (it trips no |
| 23 | + * phrasing heuristic, so it is not replaced by `INTERNAL_ERROR_MESSAGE`; it |
| 24 | + * carries no dialect word to withhold). Same status band, no disclosure |
| 25 | + * either way; the ledgered `code` on two published doors moves. |
| 26 | + * |
| 27 | + * The catch's own half — that the declared fault propagates UNCHANGED and the |
| 28 | + * undeclared ancestor still takes branch ③ — is pinned where the catch lives, |
| 29 | + * in `service-package`'s `publish-driver-fault.test.ts` / |
| 30 | + * `delete-driver-fault.test.ts` (`[#16019]` blocks, identity-asserted). This |
| 31 | + * file takes the re-thrown object from there and pins what the DOOR answers, |
| 32 | + * with a `PackageService` double that throws it — the shape every |
| 33 | + * `packageService.publish throws` case in `package-door-5xx-message-sanitization.test.ts` |
| 34 | + * uses — so `@objectstack/service-package` is not imported into this package's |
| 35 | + * test layer (it is not in `rest`'s unaliased-import ledger). |
| 36 | + * |
| 37 | + * ⛔ Not a re-judgement of either catch: `declaresHttpAnswer`'s docblock |
| 38 | + * already says a declared 5xx is re-thrown too. The contract review of PR |
| 39 | + * #16650 required the consequence to be NAMED and PINNED, nothing else. |
| 40 | + */ |
| 41 | + |
| 42 | +import { describe, it, expect, vi } from 'vitest'; |
| 43 | +import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; |
| 44 | +import type { RouteHandler } from '@objectstack/spec/contracts'; |
| 45 | +import { INTERNAL_ERROR_MESSAGE, looksLikeInternalErrorLeak } from '@objectstack/types'; |
| 46 | +import { registerPackageRoutes } from './package-routes.js'; |
| 47 | + |
| 48 | +const PKGS = '/api/v1/packages'; |
| 49 | +const MANIFEST = { id: 'com.acme.crm', version: '1.0.0' }; |
| 50 | + |
| 51 | +/** A caller holding every capability these routes gate on. */ |
| 52 | +const CLEARS_THE_GATE = async () => ({ |
| 53 | + userId: 'u_pkg', |
| 54 | + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], |
| 55 | +}); |
| 56 | + |
| 57 | +interface Captured { |
| 58 | + status: number; |
| 59 | + body: any; |
| 60 | +} |
| 61 | + |
| 62 | +function mount(svc: Record<string, unknown>) { |
| 63 | + const routes = new Map<string, RouteHandler>(); |
| 64 | + const server = { |
| 65 | + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, |
| 66 | + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, |
| 67 | + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, |
| 68 | + delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); }, |
| 69 | + patch: () => {}, |
| 70 | + use: () => {}, |
| 71 | + listen: async () => {}, |
| 72 | + close: async () => {}, |
| 73 | + } as any; |
| 74 | + registerPackageRoutes(server, () => svc as any, '/api/v1', { |
| 75 | + resolveExecutionContext: CLEARS_THE_GATE, |
| 76 | + } as any); |
| 77 | + return routes; |
| 78 | +} |
| 79 | + |
| 80 | +async function drive( |
| 81 | + routes: Map<string, RouteHandler>, |
| 82 | + method: string, |
| 83 | + path: string, |
| 84 | + req: Record<string, any> = {}, |
| 85 | +): Promise<Captured> { |
| 86 | + const handler = routes.get(`${method}:${path}`); |
| 87 | + if (!handler) throw new Error(`no handler for ${method} ${path}`); |
| 88 | + const captured: Captured = { status: 0, body: undefined }; |
| 89 | + const res: any = { |
| 90 | + json(data: any) { captured.body = data; }, |
| 91 | + send() {}, |
| 92 | + status(code: number) { captured.status = code; return res; }, |
| 93 | + header() { return res; }, |
| 94 | + }; |
| 95 | + await handler( |
| 96 | + { params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as any, |
| 97 | + res, |
| 98 | + ); |
| 99 | + return captured; |
| 100 | +} |
| 101 | + |
| 102 | +/** The wire contract, imported rather than restated. */ |
| 103 | +function expectDeclaredEnvelope(captured: Captured): any { |
| 104 | + expect(BaseResponseSchema.safeParse(captured.body).success).toBe(true); |
| 105 | + expect(envelopeViolations(captured.body)).toEqual([]); |
| 106 | + expect(captured.body?.success).toBe(false); |
| 107 | + const parsed = ApiErrorSchema.safeParse(captured.body?.error); |
| 108 | + expect(parsed.error?.issues ?? []).toEqual([]); |
| 109 | + expect(parsed.success).toBe(true); |
| 110 | + return captured.body.error; |
| 111 | +} |
| 112 | + |
| 113 | +const DIALECT_LINE = 'insert into `sys_packages` (`id`, …) values (…) - no such table: sys_packages'; |
| 114 | +const COMPOSED = |
| 115 | + 'The database refused to run a raw statement. The driver could not attribute the failure ' + |
| 116 | + 'to any part of the request, so no verdict about the statement is claimed here. The ' + |
| 117 | + "backend's own diagnostic and the statement were written to the server log for an " + |
| 118 | + 'operator to read.'; |
| 119 | + |
| 120 | +/** What `SqlDriver.execute()` raises since #16019, and what the service's branch ② re-throws. */ |
| 121 | +function rawStatementFault(): Error { |
| 122 | + const err = Object.assign(new Error(COMPOSED), { code: 'DATABASE_ERROR', status: 500 }); |
| 123 | + Object.defineProperty(err, 'cause', { |
| 124 | + value: Object.assign(new Error(DIALECT_LINE), { code: 'SQLITE_ERROR' }), |
| 125 | + enumerable: false, writable: true, configurable: true, |
| 126 | + }); |
| 127 | + return err; |
| 128 | +} |
| 129 | + |
| 130 | +async function publishWith(svc: Record<string, unknown>): Promise<Captured> { |
| 131 | + return drive(mount(svc), 'POST', `${PKGS}/publish`, { |
| 132 | + body: { manifest: MANIFEST, metadata: { author: 'acme' } }, |
| 133 | + }); |
| 134 | +} |
| 135 | + |
| 136 | +async function deleteWith(svc: Record<string, unknown>): Promise<Captured> { |
| 137 | + return drive(mount(svc), 'DELETE', `${PKGS}/:id`, { params: { id: 'com.acme.crm' } }); |
| 138 | +} |
| 139 | + |
| 140 | +describe('[#16019] a raw-exec driver fault under sys_packages answers the producer\'s code on both package doors', () => { |
| 141 | + // The control that makes the assertions below about the DECLARATION and not |
| 142 | + // about the heuristic: the composed sentence trips nothing. |
| 143 | + it('the composed sentence is not a phrase the door\'s withhold heuristic knows', () => { |
| 144 | + expect(looksLikeInternalErrorLeak(COMPOSED)).toBe(false); |
| 145 | + expect(looksLikeInternalErrorLeak(DIALECT_LINE)).toBe(true); |
| 146 | + }); |
| 147 | + |
| 148 | + it('POST /packages/publish — AFTER #16019: the re-thrown declared fault → 500 DATABASE_ERROR, composed sentence, no dialect word', async () => { |
| 149 | + const publish = vi.fn(async () => { throw rawStatementFault(); }); |
| 150 | + const captured = await publishWith({ publish }); |
| 151 | + |
| 152 | + expect(publish).toHaveBeenCalledTimes(1); |
| 153 | + expect(captured.status).toBe(500); |
| 154 | + const error = expectDeclaredEnvelope(captured); |
| 155 | + expect(error.code).toBe('DATABASE_ERROR'); |
| 156 | + expect(error.code).not.toBe('PACKAGE_PUBLISH_FAILED'); |
| 157 | + expect(error.message).toBe(COMPOSED); |
| 158 | + expect(JSON.stringify(captured.body)).not.toMatch(/sys_packages|no such table|insert into/i); |
| 159 | + }); |
| 160 | + |
| 161 | + it('POST /packages/publish — BEFORE #16019: the swallowed driver fault → 500 PACKAGE_PUBLISH_FAILED (the control, still what an undeclared fault answers)', async () => { |
| 162 | + // Branch ③'s return shape, verbatim from the service. |
| 163 | + const publish = vi.fn(async () => ({ success: false, driverFault: { message: 'The package was not persisted.' } })); |
| 164 | + const captured = await publishWith({ publish }); |
| 165 | + |
| 166 | + expect(captured.status).toBe(500); |
| 167 | + const error = expectDeclaredEnvelope(captured); |
| 168 | + expect(error.code).toBe('PACKAGE_PUBLISH_FAILED'); |
| 169 | + }); |
| 170 | + |
| 171 | + it('DELETE /packages/:id — AFTER #16019: the re-thrown declared fault → 500 DATABASE_ERROR, composed sentence, no dialect word', async () => { |
| 172 | + const del = vi.fn(async () => { throw rawStatementFault(); }); |
| 173 | + const captured = await deleteWith({ delete: del }); |
| 174 | + |
| 175 | + expect(del).toHaveBeenCalledTimes(1); |
| 176 | + expect(captured.status).toBe(500); |
| 177 | + const error = expectDeclaredEnvelope(captured); |
| 178 | + expect(error.code).toBe('DATABASE_ERROR'); |
| 179 | + expect(error.code).not.toBe('PACKAGE_DELETE_FAILED'); |
| 180 | + expect(error.message).toBe(COMPOSED); |
| 181 | + expect(JSON.stringify(captured.body)).not.toMatch(/sys_packages|no such table|insert into/i); |
| 182 | + }); |
| 183 | + |
| 184 | + it('DELETE /packages/:id — BEFORE #16019: the swallowed driver fault → 500 PACKAGE_DELETE_FAILED (the control)', async () => { |
| 185 | + const del = vi.fn(async () => ({ success: false })); |
| 186 | + const captured = await deleteWith({ delete: del }); |
| 187 | + |
| 188 | + expect(captured.status).toBe(500); |
| 189 | + const error = expectDeclaredEnvelope(captured); |
| 190 | + expect(error.code).toBe('PACKAGE_DELETE_FAILED'); |
| 191 | + }); |
| 192 | + |
| 193 | + it('the withhold is untouched: a DECLARED fault whose message DOES carry dialect text is still replaced at this door', async () => { |
| 194 | + // Beside the flip, the invariant #8086 pinned: `sendThrownError` withholds |
| 195 | + // a leaky 5xx message whatever the code — so a producer that declared but |
| 196 | + // let dialect text into its message would still not disclose it here. |
| 197 | + const leaky = Object.assign(new Error(DIALECT_LINE), { code: 'DATABASE_ERROR', status: 500 }); |
| 198 | + const publish = vi.fn(async () => { throw leaky; }); |
| 199 | + const captured = await publishWith({ publish }); |
| 200 | + |
| 201 | + expect(captured.status).toBe(500); |
| 202 | + const error = expectDeclaredEnvelope(captured); |
| 203 | + expect(error.code).toBe('DATABASE_ERROR'); |
| 204 | + expect(error.message).toBe(INTERNAL_ERROR_MESSAGE); |
| 205 | + }); |
| 206 | +}); |
0 commit comments