Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {});
});

it('GET /api/v1/datasources/:name returns the credential-stripped detail (404 when unknown)', async () => {
const getDatasource = vi.fn(async (name: string) =>
name === 'demo_ext'
? { name: 'demo_ext', driver: 'sqlite', schemaMode: 'managed', config: { filename: '/tmp/x.db' }, active: true, origin: 'runtime', hasSecret: false }
: undefined,
);
const app = mount({ getDatasource });
const ok = await app.fetch(json('/api/v1/datasources/demo_ext'));
expect(ok.status).toBe(200);
expect((await ok.json()).datasource.config.filename).toBe('/tmp/x.db');
const missing = await app.fetch(json('/api/v1/datasources/nope'));
expect(missing.status).toBe(404);
expect(getDatasource).toHaveBeenCalledWith('demo_ext');
});

it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => {
const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 });
const app = mount({ testConnection });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,33 @@ describe('removeDatasource', () => {
await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i);
});
});

describe('getDatasource', () => {
it('returns config + hasSecret with the credentialsRef stripped', async () => {
const { service } = makeHarness({
seed: [
{
name: 'pg',
driver: 'postgres',
origin: 'runtime',
config: { host: 'db', port: 5432, database: 'app' },
external: { credentialsRef: 'sys_secret://datasource/pg#1' },
},
],
});
const ds = await service.getDatasource('pg');
expect(ds).toMatchObject({ name: 'pg', driver: 'postgres', origin: 'runtime', hasSecret: true });
expect(ds!.config).toEqual({ host: 'db', port: 5432, database: 'app' });
// The opaque credential handle must never be returned.
expect(JSON.stringify(ds)).not.toContain('sys_secret');
expect(JSON.stringify(ds)).not.toContain('credentialsRef');
});

it('reports hasSecret:false and returns undefined for unknown names', async () => {
const { service } = makeHarness({
seed: [{ name: 'lite', driver: 'sqlite', origin: 'runtime', config: { filename: '/tmp/a.db' } }],
});
expect((await service.getDatasource('lite'))!.hasSecret).toBe(false);
expect(await service.getDatasource('missing')).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions packages/services/service-datasource/src/admin-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ export function registerDatasourceAdminRoutes(
// `datasource` `test_connection` action). Distinct from `POST /datasources/test`
// which probes an unsaved draft carried inline. Registered before the generic
// `:name` mutation routes.
// Read one datasource's full detail for the edit form (credential stripped;
// `config` is non-sensitive, plus a `hasSecret` flag). Registered after the
// static `/drivers` route so that literal segment is never captured as a name.
server.get(`${root}/:name`, async (req: any, res: any) => {
const svc = adminService();
if (!svc?.getDatasource) return unavailable(res);
try {
const datasource = await svc.getDatasource(req.params.name);
if (!datasource) return res.status(404).json({ error: 'not_found' });
res.json({ datasource });
} catch (err) {
badRequest(res, err);
}
});

server.post(`${root}/:name/test`, async (req: any, res: any) => {
const svc = externalService();
if (!svc?.testConnection) return unavailable(res);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,36 @@ export class DatasourceAdminService implements IDatasourceAdminService {
return summaries;
}

/**
* Read one datasource's full detail for editing, with the credential stripped.
* Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
* config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
* keep" without ever receiving the `credentialsRef` or any cleartext. Returns
* `undefined` when the name is unknown.
*/
async getDatasource(name: string): Promise<
| (Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {
origin: 'code' | 'runtime';
hasSecret: boolean;
})
| undefined
> {
const rec = await this.config.getDatasourceRecord(name);
if (!rec) return undefined;
const hasSecret = Boolean(rec.external?.credentialsRef);
return {
name: rec.name,
label: rec.label,
driver: rec.driver,
schemaMode: rec.schemaMode ?? 'managed',
config: rec.config ?? {},
active: rec.active ?? true,
origin: rec.origin === 'runtime' ? 'runtime' : 'code',
hasSecret,
...(rec.definedIn ? { definedIn: rec.definedIn } : {}),
};
}

async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {
if (!input?.driver) {
return { ok: false, error: 'A driver is required to test a connection.' };
Expand Down
Loading