|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 4 | +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; |
| 5 | +import { tmpdir } from 'node:os'; |
| 6 | +import { join } from 'node:path'; |
| 7 | +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; |
| 8 | +import type { IDatasourceAdminService } from '../contracts/index.js'; |
| 9 | +import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js'; |
| 10 | +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; |
| 11 | +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; |
| 12 | +import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; |
| 13 | + |
| 14 | +// [#10126] Pay the first transform of the dist-resolved workspace deps at module |
| 15 | +// load, not inside a clocked `it()` (see `scripts/check-test-source-alias.mjs`). |
| 16 | +import '@objectstack/spec/kernel'; |
| 17 | +import '@objectstack/driver-sql'; |
| 18 | + |
| 19 | +/** |
| 20 | + * [#30331] A SQLite `config.filename` that is neither exactly `:memory:` nor a |
| 21 | + * usable file path is refused by Test connection and by Save, with a message |
| 22 | + * that names the value and says why. |
| 23 | + * |
| 24 | + * The value the card measured is `:memory:/…/parts.db`: the dialog's |
| 25 | + * `:memory:` with a typed path after it. Test connection answered `ok: true`. |
| 26 | + * Every layer reads a `:`-prefixed name as fileless, so the draft probe's |
| 27 | + * missing-file refusal skipped it; better-sqlite3 then opened it as a relative |
| 28 | + * path whose directory does not exist (every query fails, the health check |
| 29 | + * answers `false`), and sqlite-wasm opened an ephemeral database that loses |
| 30 | + * everything at the next connect. |
| 31 | + * |
| 32 | + * Driven through the real door: the real `HonoHttpServer`, the real admin |
| 33 | + * routes, the real plugin (its probe and its service) and the default driver |
| 34 | + * factory. The metadata service is a map, so a Save is observable. |
| 35 | + */ |
| 36 | + |
| 37 | +const authService = createSessionAuthService(); |
| 38 | +const grantsEngine = createGrantsEngine(); |
| 39 | + |
| 40 | +async function mountDoor() { |
| 41 | + let admin: IDatasourceAdminService | undefined; |
| 42 | + const rows = new Map<string, unknown>(); |
| 43 | + const metadata = { |
| 44 | + get: async (_type: string, name: string) => rows.get(name), |
| 45 | + list: async () => [...rows.values()], |
| 46 | + register: async (_type: string, name: string, record: unknown) => { |
| 47 | + rows.set(name, record); |
| 48 | + }, |
| 49 | + unregister: async (_type: string, name: string) => { |
| 50 | + rows.delete(name); |
| 51 | + }, |
| 52 | + }; |
| 53 | + const ctx: any = { |
| 54 | + getService: (name: string) => { |
| 55 | + if (name === 'metadata') return metadata; |
| 56 | + throw new Error(`no service ${name}`); |
| 57 | + }, |
| 58 | + registerService: (name: string, svc: unknown) => { |
| 59 | + if (name === 'datasource-admin') admin = svc as IDatasourceAdminService; |
| 60 | + }, |
| 61 | + trigger: async () => {}, |
| 62 | + logger: { warn() {}, info() {} }, |
| 63 | + }; |
| 64 | + await new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }).init(ctx); |
| 65 | + |
| 66 | + const server = new HonoHttpServer(0); |
| 67 | + registerDatasourceAdminRoutes( |
| 68 | + server, |
| 69 | + { |
| 70 | + getService: vi.fn((name: string) => |
| 71 | + name === 'auth' |
| 72 | + ? authService |
| 73 | + : name === 'objectql' || name === 'data' |
| 74 | + ? grantsEngine |
| 75 | + : name === 'datasource-admin' |
| 76 | + ? admin |
| 77 | + : undefined, |
| 78 | + ), |
| 79 | + } as any, |
| 80 | + '/api/v1', |
| 81 | + ); |
| 82 | + const app = server.getRawApp(); |
| 83 | + const send = async (method: string, path: string, body: unknown) => { |
| 84 | + const res = await app.fetch( |
| 85 | + new Request(`http://local/api/v1/datasources${path}`, { |
| 86 | + method, |
| 87 | + headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL }, |
| 88 | + body: JSON.stringify(body), |
| 89 | + }), |
| 90 | + ); |
| 91 | + return { status: res.status, body: (await res.json()) as any }; |
| 92 | + }; |
| 93 | + return { |
| 94 | + rows, |
| 95 | + test: async (draft: Record<string, unknown>) => { |
| 96 | + const res = await send('POST', '/test', { name: 'draft', ...draft }); |
| 97 | + expect(res.status).toBe(200); |
| 98 | + return res.body.data.result as { ok: boolean; error?: string }; |
| 99 | + }, |
| 100 | + create: (draft: Record<string, unknown>) => send('POST', '', draft), |
| 101 | + update: (name: string, patch: Record<string, unknown>) => send('PATCH', `/${name}`, patch), |
| 102 | + }; |
| 103 | +} |
| 104 | + |
| 105 | +let root: string; |
| 106 | +beforeEach(() => { |
| 107 | + root = mkdtempSync(join(tmpdir(), 'ds-30331-')); |
| 108 | +}); |
| 109 | +afterEach(() => { |
| 110 | + rmSync(root, { recursive: true, force: true }); |
| 111 | +}); |
| 112 | + |
| 113 | +/** Both drivers that open `config.filename`. */ |
| 114 | +const DRIVERS = ['sqlite', 'sqlite-wasm'] as const; |
| 115 | + |
| 116 | +function expectRefusal(res: { status: number; body: any }, value: string, why: RegExp) { |
| 117 | + expect(res.status).toBe(400); |
| 118 | + expect(res.body.success).toBe(false); |
| 119 | + // The envelope and code every bad config already answers with (#4410). |
| 120 | + expect(res.body.error.code).toBe('DATASOURCE_ADMIN_ERROR'); |
| 121 | + expect(res.body.error.message).toContain('Invalid configuration for driver'); |
| 122 | + expect(res.body.error.message).toContain(value); |
| 123 | + expect(res.body.error.message).toMatch(why); |
| 124 | +} |
| 125 | + |
| 126 | +describe('#30331 — a filename that is neither `:memory:` nor a usable path is refused', () => { |
| 127 | + for (const driver of DRIVERS) { |
| 128 | + describe(driver, () => { |
| 129 | + it('`:memory:/…` is refused by Test connection, naming the value and why', async () => { |
| 130 | + const door = await mountDoor(); |
| 131 | + const value = `:memory:${join(root, 'parts.db')}`; |
| 132 | + |
| 133 | + const result = await door.test({ driver, config: { filename: value } }); |
| 134 | + |
| 135 | + expect(result.ok).toBe(false); |
| 136 | + expect(result.error).toContain(value); |
| 137 | + expect(result.error).toMatch(/neither ':memory:' nor a file path/); |
| 138 | + expect(readdirSync(root)).toEqual([]); |
| 139 | + }); |
| 140 | + |
| 141 | + it('`:memory:/…` is refused by Save (create and update), and nothing is stored', async () => { |
| 142 | + const door = await mountDoor(); |
| 143 | + const value = `:memory:${join(root, 'parts.db')}`; |
| 144 | + |
| 145 | + expectRefusal(await door.create({ name: 'parts', driver, config: { filename: value } }), value, /neither ':memory:' nor a file path/); |
| 146 | + expect(door.rows.has('parts')).toBe(false); |
| 147 | + |
| 148 | + const created = await door.create({ name: 'parts', driver, config: { filename: ':memory:' } }); |
| 149 | + expect(created.status).toBe(201); |
| 150 | + expectRefusal(await door.update('parts', { config: { filename: value } }), value, /neither ':memory:' nor a file path/); |
| 151 | + expect((door.rows.get('parts') as any).config.filename).toBe(':memory:'); |
| 152 | + }); |
| 153 | + |
| 154 | + it('an unusable path is refused by Test connection and by Save', async () => { |
| 155 | + const door = await mountDoor(); |
| 156 | + writeFileSync(join(root, 'blocker'), 'a regular file'); |
| 157 | + const value = join(root, 'blocker/parts.db'); |
| 158 | + |
| 159 | + const result = await door.test({ driver, config: { filename: value } }); |
| 160 | + expect(result.ok).toBe(false); |
| 161 | + expect(result.error).toContain(value); |
| 162 | + expect(result.error).toMatch(/cannot be/); |
| 163 | + |
| 164 | + expectRefusal(await door.create({ name: 'parts', driver, config: { filename: value } }), value, /cannot be/); |
| 165 | + expect(door.rows.has('parts')).toBe(false); |
| 166 | + expect(readdirSync(root)).toEqual(['blocker']); |
| 167 | + }); |
| 168 | + |
| 169 | + it('an empty filename is refused by Test connection and by Save', async () => { |
| 170 | + const door = await mountDoor(); |
| 171 | + const result = await door.test({ driver, config: { filename: '' } }); |
| 172 | + expect(result.ok).toBe(false); |
| 173 | + expect(result.error).toMatch(/'' is empty and names no database file/); |
| 174 | + expectRefusal(await door.create({ name: 'parts', driver, config: { filename: '' } }), "''", /is empty/); |
| 175 | + }); |
| 176 | + |
| 177 | + // Controls: what was right stays right. |
| 178 | + it('control: exactly `:memory:` still tests ok and saves', async () => { |
| 179 | + const door = await mountDoor(); |
| 180 | + expect((await door.test({ driver, config: { filename: ':memory:' } })).ok).toBe(true); |
| 181 | + expect((await door.create({ name: 'scratch', driver, config: { filename: ':memory:' } })).status).toBe(201); |
| 182 | + }); |
| 183 | + |
| 184 | + it('control: an existing database file still tests ok and saves', async () => { |
| 185 | + const door = await mountDoor(); |
| 186 | + const file = join(root, 'existing.db'); |
| 187 | + const { SqlDriver } = await import('@objectstack/driver-sql'); |
| 188 | + const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true } as any); |
| 189 | + await seed.connect(); |
| 190 | + await seed.disconnect(); |
| 191 | + |
| 192 | + expect((await door.test({ driver, config: { filename: file } })).ok).toBe(true); |
| 193 | + expect((await door.create({ name: 'existing', driver, config: { filename: file } })).status).toBe(201); |
| 194 | + }); |
| 195 | + |
| 196 | + it('control: a creatable path that does not exist keeps today\'s answers', async () => { |
| 197 | + const door = await mountDoor(); |
| 198 | + const file = join(root, 'missing/dir/new.db'); |
| 199 | + |
| 200 | + // Test connection: refused, and nothing created (#27512). |
| 201 | + const result = await door.test({ driver, config: { filename: file } }); |
| 202 | + expect(result.ok).toBe(false); |
| 203 | + expect(result.error).toContain('does not exist'); |
| 204 | + expect(existsSync(file)).toBe(false); |
| 205 | + |
| 206 | + // Save: accepted. |
| 207 | + expect((await door.create({ name: 'fresh', driver, config: { filename: file } })).status).toBe(201); |
| 208 | + expect((door.rows.get('fresh') as any).config.filename).toBe(file); |
| 209 | + }); |
| 210 | + }); |
| 211 | + } |
| 212 | +}); |
0 commit comments