|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #8152 — a NEW turso datasource authored with a BOUND secret authenticates. |
| 5 | + * |
| 6 | + * ## The defect, and why nothing caught it |
| 7 | + * |
| 8 | + * #7990/#8078 closed the inline door: `config.authToken` is `z.never()` at every |
| 9 | + * authoring door, exactly like the SQL drivers' `config.password`. The route an |
| 10 | + * author is diverted TO — bind the credential, keep only |
| 11 | + * `external.credentialsRef` on the record — resolves the cleartext into |
| 12 | + * `spec.secret` at connect time. Nothing on the turso path read it. So after |
| 13 | + * #8078 a new turso remote datasource had no working credential route at all: |
| 14 | + * refused inline, dropped when bound. |
| 15 | + * |
| 16 | + * It stayed invisible because it broke nothing that already worked. A stored row |
| 17 | + * carrying inline `config.authToken` bypasses the parse and still connects, and |
| 18 | + * the host boot paths translate `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` |
| 19 | + * into a `config` they construct themselves, which never meets the authoring |
| 20 | + * schema. Only NEW authoring was dead. |
| 21 | + * |
| 22 | + * ## What each pin reads on `origin/main` (reverse verification) |
| 23 | + * |
| 24 | + * ⚠️ The vacuity trap this file is written around: any case that starts from an |
| 25 | + * ALREADY-EXISTING turso datasource is green on `main` too, because stored config |
| 26 | + * bypasses the parse. Every red pin below therefore starts at |
| 27 | + * `createDatasource()` — the authoring door — and carries its credential the only |
| 28 | + * way that door permits. |
| 29 | + * |
| 30 | + * RED on main (carries the defect this card fixes): |
| 31 | + * - "a datasource created with a bound secret reaches the driver with an |
| 32 | + * authToken" — on main the builder returned `{ url }` for the spec the connect |
| 33 | + * path hands it, so the assertion on `authToken` failed. Measured on |
| 34 | + * `origin/main`, not inferred. |
| 35 | + * - "the bound secret is the ONLY credential a newly authored datasource can |
| 36 | + * carry" — pins both halves at once: the door refuses the inline key AND the |
| 37 | + * bound one arrives. On main the first half passed and the second failed, |
| 38 | + * which is the dead end stated above. |
| 39 | + * - "binds the secret to authToken alone, leaving the encryptionKey question |
| 40 | + * open" — red on main via its `authToken` half. The `encryptionKey` half is |
| 41 | + * forward-facing and is asserted BESIDE that one rather than alone, because |
| 42 | + * alone it is green on main for the useless reason (main emitted neither key). |
| 43 | + * |
| 44 | + * GREEN on main (guards behaviour that must NOT change): |
| 45 | + * - "#8078's refusal still fires at create and at update" — guards the spec half. |
| 46 | + * This card restores the alternative route; it must not reopen the inline one. |
| 47 | + * Passed on main and must keep passing. |
| 48 | + * - "the refusal still names both mechanisms it diverts to" — the guidance is |
| 49 | + * what makes the refusal actionable, and it now points at a route that |
| 50 | + * actually works. Passed on main. |
| 51 | + * - "postgres / mysql / mongodb still read `spec.secret` as the password" — |
| 52 | + * guards the sibling arms this change takes its shape FROM. Untouched by the |
| 53 | + * diff, unpinned before it. Passed on main and must keep passing. |
| 54 | + * |
| 55 | + * ## The one seam, stated rather than hidden |
| 56 | + * |
| 57 | + * The factory's real `turso` arm cannot run here: `@objectstack/driver-turso` is |
| 58 | + * deliberately not resolvable from this package (that is what "optional" means, |
| 59 | + * and the missing-package arm's own pin depends on it). So the red pins capture |
| 60 | + * the spec the connect path actually hands `factory.create()` — real authoring |
| 61 | + * door, real secret binder, real credential resolution — and run the real |
| 62 | + * `buildTursoDriverConfig` on exactly that spec, which is the line the factory |
| 63 | + * arm itself executes (`default-datasource-driver-factory.ts`, `kind === 'turso'`). |
| 64 | + * Both halves are production code; only the `new TursoDriver(...)` call is absent. |
| 65 | + */ |
| 66 | + |
| 67 | +import { describe, it, expect } from 'vitest'; |
| 68 | +import { validateDriverConfig } from '@objectstack/spec/data'; |
| 69 | +import { |
| 70 | + DatasourceAdminService, |
| 71 | + type DatasourceAdminServiceConfig, |
| 72 | + type StoredDatasource, |
| 73 | +} from '../datasource-admin-service.js'; |
| 74 | +import { |
| 75 | + DatasourceConnectionService, |
| 76 | + type ConnectableDatasource, |
| 77 | + type ConnectionEngineLike, |
| 78 | +} from '../datasource-connection-service.js'; |
| 79 | +import { buildTursoDriverConfig, resolveTursoUrl } from '../turso-driver-config.js'; |
| 80 | +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; |
| 81 | +import type { |
| 82 | + DatasourceConnectionSpec, |
| 83 | + IDatasourceDriverFactory, |
| 84 | +} from '../contracts/datasource-driver-factory.js'; |
| 85 | + |
| 86 | +const THE_BOUND_JWT = 'eyJhbGciOiJFZERTQSJ9.THE-BOUND-JWT'; |
| 87 | +const TURSO_URL = 'libsql://my-db.turso.io'; |
| 88 | + |
| 89 | +/** |
| 90 | + * An admin service over an in-memory record store and an in-memory secret store, |
| 91 | + * with the two joined the way a real host joins them: `writeSecret` returns an |
| 92 | + * opaque ref and keeps the cleartext where only a resolver can reach it. The |
| 93 | + * cleartext must never appear on the record — asserted below rather than assumed. |
| 94 | + */ |
| 95 | +function makeAuthoringDoor() { |
| 96 | + const records: StoredDatasource[] = []; |
| 97 | + const secrets = new Map<string, string>(); |
| 98 | + let n = 0; |
| 99 | + const cfg: DatasourceAdminServiceConfig = { |
| 100 | + probe: async () => ({ ok: true }), |
| 101 | + listDatasourceRecords: async () => records, |
| 102 | + getDatasourceRecord: async (name) => records.find((r) => r.name === name), |
| 103 | + putDatasourceRecord: async (rec) => { |
| 104 | + const i = records.findIndex((r) => r.name === rec.name); |
| 105 | + if (i >= 0) records[i] = rec; |
| 106 | + else records.push(rec); |
| 107 | + }, |
| 108 | + deleteDatasourceRecord: async () => {}, |
| 109 | + writeSecret: async (input) => { |
| 110 | + const ref = `sys_secret:ds-${++n}`; |
| 111 | + secrets.set(ref, input.value); |
| 112 | + return ref; |
| 113 | + }, |
| 114 | + countBoundObjects: async () => 0, |
| 115 | + }; |
| 116 | + return { records, secrets, service: new DatasourceAdminService(cfg) }; |
| 117 | +} |
| 118 | + |
| 119 | +/** The minimum engine the connect path needs to register a driver. */ |
| 120 | +function stubEngine(): ConnectionEngineLike { |
| 121 | + const drivers = new Map<string, { name?: string }>(); |
| 122 | + return { |
| 123 | + registerDriver: (driver: any) => { |
| 124 | + drivers.set(driver.name, driver); |
| 125 | + }, |
| 126 | + registerDatasourceDef: () => {}, |
| 127 | + getDriverByName: (name) => drivers.get(name), |
| 128 | + syncObjectSchema: async () => {}, |
| 129 | + markDatasourceUnavailable: () => {}, |
| 130 | + clearDatasourceUnavailable: () => {}, |
| 131 | + } as ConnectionEngineLike; |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * A factory standing exactly where the real `turso` arm stands, recording the |
| 136 | + * spec it is handed. This is the seam described in the header — everything |
| 137 | + * upstream of it (door, binder, resolver) is production code. |
| 138 | + */ |
| 139 | +function capturingFactory() { |
| 140 | + const seen: DatasourceConnectionSpec[] = []; |
| 141 | + const factory: IDatasourceDriverFactory = { |
| 142 | + supports: () => true, |
| 143 | + create: async (spec) => { |
| 144 | + seen.push(spec); |
| 145 | + return { driver: { name: spec.name ?? 'default' } } as never; |
| 146 | + }, |
| 147 | + }; |
| 148 | + return { seen, factory }; |
| 149 | +} |
| 150 | + |
| 151 | +/** Author a turso datasource through the real door, then connect it. */ |
| 152 | +async function authorThenConnect(secretValue: string) { |
| 153 | + const { records, secrets, service } = makeAuthoringDoor(); |
| 154 | + await service.createDatasource( |
| 155 | + { name: 'warehouse', driver: 'turso', schemaMode: 'external', config: { url: TURSO_URL } }, |
| 156 | + { value: secretValue }, |
| 157 | + ); |
| 158 | + |
| 159 | + const record = records[0]!; |
| 160 | + const { seen, factory } = capturingFactory(); |
| 161 | + const connection = new DatasourceConnectionService({ |
| 162 | + factory: () => factory, |
| 163 | + engine: () => stubEngine(), |
| 164 | + secrets: { resolve: async (ref) => secrets.get(ref) }, |
| 165 | + }); |
| 166 | + const result = await connection.connect(record as ConnectableDatasource); |
| 167 | + return { record, seen, result }; |
| 168 | +} |
| 169 | + |
| 170 | +describe('#8152 — the credential route a NEW turso datasource has left', () => { |
| 171 | + it('RED ON MAIN — a datasource created with a bound secret reaches the driver with an authToken', async () => { |
| 172 | + const { record, seen, result } = await authorThenConnect(THE_BOUND_JWT); |
| 173 | + |
| 174 | + // The door did its job: an opaque ref on the record, no cleartext anywhere in |
| 175 | + // it. If this half ever fails the test below is measuring the wrong thing. |
| 176 | + expect(result.status).toBe('connected'); |
| 177 | + expect(record.external?.credentialsRef).toMatch(/^sys_secret:/); |
| 178 | + expect(JSON.stringify(record)).not.toContain(THE_BOUND_JWT); |
| 179 | + |
| 180 | + // The connect path resolved the ref and handed the cleartext to the factory… |
| 181 | + expect(seen).toHaveLength(1); |
| 182 | + const spec = seen[0]!; |
| 183 | + expect(spec.secret).toBe(THE_BOUND_JWT); |
| 184 | + |
| 185 | + // …and the builder the turso arm calls puts it in the slot libSQL reads. |
| 186 | + // RED on main: this returned `{ url: 'libsql://my-db.turso.io' }`. |
| 187 | + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))).toMatchObject({ |
| 188 | + url: TURSO_URL, |
| 189 | + authToken: THE_BOUND_JWT, |
| 190 | + }); |
| 191 | + }); |
| 192 | + |
| 193 | + it('RED ON MAIN — the bound secret is the ONLY credential a newly authored datasource can carry', async () => { |
| 194 | + const { service } = makeAuthoringDoor(); |
| 195 | + |
| 196 | + // Half one: the inline key is refused at the door (GREEN on main — #8078). |
| 197 | + await expect( |
| 198 | + service.createDatasource({ |
| 199 | + name: 'inline', driver: 'turso', |
| 200 | + config: { url: TURSO_URL, authToken: THE_BOUND_JWT }, |
| 201 | + } as never), |
| 202 | + ).rejects.toThrow(/is a credential and is not accepted inline/); |
| 203 | + |
| 204 | + // Half two: so the bound route must work, or there is none. RED on main. |
| 205 | + const { seen } = await authorThenConnect(THE_BOUND_JWT); |
| 206 | + const spec = seen[0]!; |
| 207 | + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec)).authToken).toBe(THE_BOUND_JWT); |
| 208 | + }); |
| 209 | + |
| 210 | + // ⛔ Step 1 is one slot: the primary credential. `encryptionKey` is a different |
| 211 | + // secret (an AES-256 key for a local file, not a bearer token for a remote) and |
| 212 | + // whether the binder needs a second slot for it is deliberately undecided — |
| 213 | + // #8126's read-time redaction of it grants and removes no slot. This pin fails |
| 214 | + // if a later change reads the one bound secret as though it were both. |
| 215 | + // |
| 216 | + // Both halves asserted together on purpose: `not.toHaveProperty` alone is |
| 217 | + // green on main for the useless reason (main emitted neither key), which is |
| 218 | + // exactly the vacuity this file is written to avoid. RED on main. |
| 219 | + it('RED ON MAIN — binds the secret to authToken alone, leaving the encryptionKey question open', async () => { |
| 220 | + const { seen } = await authorThenConnect(THE_BOUND_JWT); |
| 221 | + const config = buildTursoDriverConfig(seen[0]!, resolveTursoUrl(seen[0]!)); |
| 222 | + expect(config.authToken).toBe(THE_BOUND_JWT); |
| 223 | + expect(config).not.toHaveProperty('encryptionKey'); |
| 224 | + }); |
| 225 | +}); |
| 226 | + |
| 227 | +describe('GREEN ON MAIN — #8078 is not reopened by the route this card restores', () => { |
| 228 | + it('the inline refusal still fires at create and at update', async () => { |
| 229 | + const { service } = makeAuthoringDoor(); |
| 230 | + await expect( |
| 231 | + service.createDatasource({ |
| 232 | + name: 'inline', driver: 'turso', config: { url: TURSO_URL, authToken: 'jwt' }, |
| 233 | + } as never), |
| 234 | + ).rejects.toThrow(/is a credential and is not accepted inline/); |
| 235 | + |
| 236 | + // An existing row is not a licence to type the key back in. `updateDatasource` |
| 237 | + // judges the MERGED config, so a patch that reintroduces it is refused too. |
| 238 | + await service.createDatasource( |
| 239 | + { name: 'warehouse', driver: 'turso', config: { url: TURSO_URL } }, |
| 240 | + { value: THE_BOUND_JWT }, |
| 241 | + ); |
| 242 | + await expect( |
| 243 | + service.updateDatasource('warehouse', { |
| 244 | + config: { url: TURSO_URL, authToken: 'jwt' }, |
| 245 | + } as never), |
| 246 | + ).rejects.toThrow(/is a credential and is not accepted inline/); |
| 247 | + }); |
| 248 | + |
| 249 | + it('the refusal still names both mechanisms it diverts to', () => { |
| 250 | + // The guidance is what makes the refusal actionable — and as of this card the |
| 251 | + // route it names is one that actually delivers the credential. A refusal that |
| 252 | + // said only "not allowed" would leave the author with no next move. |
| 253 | + const verdict = validateDriverConfig('turso', { url: TURSO_URL, authToken: 'jwt' }); |
| 254 | + expect(verdict).toMatchObject({ known: true }); |
| 255 | + const message = (verdict as { issues: Array<{ message: string }> }).issues[0]!.message; |
| 256 | + expect(message).toContain('external.credentialsRef'); |
| 257 | + expect(message).toContain('secret binder'); |
| 258 | + }); |
| 259 | + |
| 260 | + it('a turso config with no credential at all is still accepted', () => { |
| 261 | + // The url-only shape is what a bound datasource stores. It must stay legal: |
| 262 | + // if this went red, binding would be unreachable for a different reason. |
| 263 | + expect(validateDriverConfig('turso', { url: TURSO_URL })).toEqual({ known: true, issues: [] }); |
| 264 | + }); |
| 265 | +}); |
| 266 | + |
| 267 | +describe('GREEN ON MAIN — the sibling arms this change takes its shape from are unchanged', () => { |
| 268 | + /** The knex config a constructed SqlDriver was built from. */ |
| 269 | + function knexConfigOf(driver: any): any { |
| 270 | + return driver?.config ?? driver?.knexConfig ?? driver?.options ?? {}; |
| 271 | + } |
| 272 | + |
| 273 | + const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); |
| 274 | + |
| 275 | + it('postgres still reads spec.secret as the connection password', async () => { |
| 276 | + const handle: any = await factory().create({ |
| 277 | + driver: 'postgres', |
| 278 | + config: { host: 'db.internal', database: 'analytics', username: 'admin' }, |
| 279 | + secret: 'hunter2', |
| 280 | + }); |
| 281 | + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' }); |
| 282 | + try { await handle.disconnect?.(); } catch { /* pool never opened */ } |
| 283 | + }); |
| 284 | + |
| 285 | + it('postgres still lets the bound secret win over an inline config.password', async () => { |
| 286 | + const handle: any = await factory().create({ |
| 287 | + driver: 'postgres', |
| 288 | + config: { host: 'db.internal', database: 'analytics', username: 'admin', password: 'stale' }, |
| 289 | + secret: 'hunter2', |
| 290 | + }); |
| 291 | + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' }); |
| 292 | + try { await handle.disconnect?.(); } catch { /* pool never opened */ } |
| 293 | + }); |
| 294 | + |
| 295 | + it('mysql still reads spec.secret as the connection password', async () => { |
| 296 | + const handle: any = await factory().create({ |
| 297 | + driver: 'mysql', |
| 298 | + config: { host: 'db.internal', database: 'analytics', username: 'admin' }, |
| 299 | + secret: 'hunter2', |
| 300 | + }); |
| 301 | + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ password: 'hunter2' }); |
| 302 | + try { await handle.disconnect?.(); } catch { /* pool never opened */ } |
| 303 | + }); |
| 304 | + |
| 305 | + it('mongodb still reads spec.secret into the connection url', async () => { |
| 306 | + const handle: any = await factory().create({ |
| 307 | + driver: 'mongodb', |
| 308 | + config: { host: 'db.internal', port: 27017, database: 'analytics', username: 'admin' }, |
| 309 | + secret: 'hunter2', |
| 310 | + }); |
| 311 | + const driver: any = handle.driver ?? handle; |
| 312 | + const url = driver?.config?.url ?? driver?.options?.url ?? driver?.url; |
| 313 | + expect(url).toBe('mongodb://admin:hunter2@db.internal:27017/analytics'); |
| 314 | + try { await handle.disconnect?.(); } catch { /* client never opened */ } |
| 315 | + }); |
| 316 | +}); |
0 commit comments