diff --git a/.gitignore b/.gitignore index 402d0ce..91b6de1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ .idea/ .vscode/ /package-lock.json + +# Python bytecode cache +__pycache__/ +*.pyc diff --git a/README.md b/README.md index fcd9ebf..c45d400 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,34 @@ npm run build # → dist/sql.html (single file) npm run dev # build + serve dist/ at http://localhost:8900 ``` +### Run locally against your own ClickHouse + +`npm run local` builds the SPA and serves it as a static page on localhost: + +```bash +npm run local # build + serve → open http://localhost:8900/sql +``` + +The app is a thin client — queries go straight from the browser to the chosen +ClickHouse — so the local server only serves the page plus a generated +`config.json`. It reads your **`~/.clickhouse-client/config.xml`** connections and +offers them as a **Saved connection** dropdown on the login screen: + +- A plain connection (`hostname`/`user`/`password`) → prefills the credentials + form (cross-origin HTTP Basic to that host). +- A connection carrying clickhouse-client's OAuth keys (`oauth-url`, + `oauth-client-id`, `oauth-audience`) → an OAuth sign-in against that cluster. + +You can also ignore the picker and type a host/user/password by hand (host: include +the scheme, e.g. `http://localhost:8123`; a bare host defaults to +`https://:8443`). + +The target ClickHouse must allow cross-origin requests — ClickHouse's HTTP +interface sends `Access-Control-Allow-Origin` for requests with an `Origin` header +by default, so a stock server works. For an **OAuth** connection you also register +`http://localhost:8900/sql` as a redirect URI with the IdP. Override the serve port +with `PORT` and the config path with `LOCAL_CH_CONFIG`. Ctrl-C stops it. + ## Installing on any ClickHouse cluster ```bash diff --git a/build/local.py b/build/local.py new file mode 100644 index 0000000..7771291 --- /dev/null +++ b/build/local.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Serve the built SQL browser locally, with a host picker from clickhouse-client. + +The app is a thin client: in *credentials* mode its login form takes a ClickHouse +host and queries it directly (cross-origin); in OAuth mode it signs in via an IdP +and sends the bearer to the chosen cluster. So this server only serves the SPA + +a generated config.json — there's nothing to proxy and no ClickHouse to run here. + +It reads your `~/.clickhouse-client/config.xml` connections and offers them as a +**Saved connection** dropdown on the login screen: + • a plain connection (hostname/user/password) → prefills the credentials form. + • a connection carrying clickhouse-client's OAuth keys (`oauth-url`, + `oauth-client-id`, optional `oauth-client-secret` for a Web client like Google, + `oauth-audience`) → an OAuth sign-in against that cluster. + + npm run local # build + serve, then open http://localhost:8900/sql + +For OAuth connections you also register `http://localhost:8900/sql` as a redirect +URI with the IdP and allow CORS from localhost on the cluster (see README). + +Env: PORT (default 8900) · LOCAL_CH_CONFIG (default ~/.clickhouse-client/config.xml). +""" +import json +import os +import sys +import xml.etree.ElementTree as ET +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SPA = os.path.join(ROOT, "dist", "sql.html") +PORT = int(os.environ.get("PORT", "8900")) +CH_CONFIG = os.environ.get("LOCAL_CH_CONFIG") or os.path.expanduser("~/.clickhouse-client/config.xml") + + +def _text(conn, *names): + """First non-empty child text among `names` (dash/underscore variants).""" + for n in names: + el = conn.find(n) + if el is not None and el.text and el.text.strip(): + return el.text.strip() + return "" + + +def build_config(): + """Generate config.json from the clickhouse-client connections (best-effort).""" + idps, hosts, seen = [], [], set() + try: + root = ET.parse(CH_CONFIG).getroot() + except (OSError, ET.ParseError): + root = None + for conn in (root.iter("connection") if root is not None else []): + name = _text(conn, "name") + hostname = _text(conn, "hostname") + if not name or not hostname: + continue + secure = _text(conn, "secure").lower() in ("1", "true", "yes") + http_port = _text(conn, "http_port", "http-port") + scheme = "https" if secure else "http" + url = f"{scheme}://{hostname}:{http_port}" if http_port else f"{scheme}://{hostname}" + oauth_url = _text(conn, "oauth-url", "oauth_url") + oauth_client = _text(conn, "oauth-client-id", "oauth_client_id") + oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret") + oauth_aud = _text(conn, "oauth-audience", "oauth_audience") + if oauth_url and oauth_client: + if name not in seen: + idps.append({ + "id": name, "label": name, "issuer": oauth_url, "client_id": oauth_client, + # Optional: a Web-client secret (e.g. Google) for the code exchange. + # Empty → public PKCE. clickhouse-client has no such flag, so this is + # a local-only convenience key read from the same connection. + "client_secret": oauth_secret, "audience": oauth_aud, + "bearer": "access_token" if oauth_aud else "id_token", + }) + seen.add(name) + hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name}) + else: + hosts.append({"label": name, "url": url, "auth": "basic", + "user": _text(conn, "user"), "password": _text(conn, "password")}) + return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode() + + +CONFIG = build_config() + + +class Handler(BaseHTTPRequestHandler): + def _send(self, body, ctype, code=200): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path.endswith("/config.json"): + self._send(CONFIG, "application/json; charset=utf-8") + return + if path.rstrip("/") in ("", "/sql", "/sql.html"): + try: + with open(SPA, "rb") as f: + html = f.read() + except FileNotFoundError: + self._send(b"dist/sql.html missing - run `npm run build`.\n", "text/plain", 500) + return + self._send(html, "text/html; charset=utf-8") + return + self._send(b"not found\n", "text/plain", 404) + + def log_message(self, *_a): # keep the console quiet + pass + + +def main(): + if not os.path.exists(SPA): + sys.exit("dist/sql.html not found - run `npm run build` first (or `npm run local`).") + n = json.loads(CONFIG)["hosts"] + print( + f"\n Altinity SQL Browser - local static server\n" + f" ▸ open http://localhost:{PORT}/sql\n" + f" ▸ {len(n)} saved connection(s) from {CH_CONFIG}\n" + f" ▸ Ctrl-C to stop\n" + ) + try: + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json index d5a33c4..636853d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run --coverage --config tests/vitest.config.ts", "test:watch": "vitest --config tests/vitest.config.ts", "test:e2e": "playwright test", - "dev": "node build/build.mjs && python3 -m http.server -d dist 8900" + "dev": "node build/build.mjs && python3 -m http.server -d dist 8900", + "local": "node build/build.mjs && python3 build/local.py" }, "devDependencies": { "@playwright/test": "^1.48.0", diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 190ff9f..2bdf12a 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -104,7 +104,8 @@ export async function loadServerVersion(ctx) { } /** - * Load the table list grouped by database (excludes system schemas). + * Load the table list grouped by database. `system` is included (handy for + * dashboards/diagnostics); the redundant INFORMATION_SCHEMA views stay filtered. * Returns [{ db, expanded, tables: [{name,total_rows,total_bytes,comment,columns:null}] }]. */ export async function loadSchema(ctx) { @@ -112,7 +113,7 @@ export async function loadSchema(ctx) { 'SELECT database, name, toUInt64(total_rows) AS total_rows, ' + 'toUInt64(total_bytes) AS total_bytes, comment\n' + 'FROM system.tables\n' + - "WHERE database NOT IN ('INFORMATION_SCHEMA','information_schema','system')\n" + + "WHERE database NOT IN ('INFORMATION_SCHEMA','information_schema')\n" + 'ORDER BY database, name\n' + 'FORMAT JSON'; const json = await queryJson(ctx, sql); diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js index 004d0e2..31b1677 100644 --- a/src/net/oauth-config.js +++ b/src/net/oauth-config.js @@ -68,7 +68,25 @@ function normalizeEntry(e) { } /** - * Fetch config.json and normalize to `{ idps: [descriptor, ...], basicLogin }`. + * Map one raw `hosts[]` entry to a saved-connection descriptor for the login + * picker: `{ label, url, auth, user, password, idp }`. `auth` is 'oauth' (sign in + * via the named `idp`, querying `url` cross-origin) or 'basic' (prefill the + * credentials form with `url`/`user`/`password`). + */ +function normalizeHost(h) { + const e = h || {}; + return { + label: e.label || e.url || '', + url: e.url || '', + auth: e.auth === 'oauth' ? 'oauth' : 'basic', + user: e.user || '', + password: e.password || '', + idp: e.idp || '', + }; +} + +/** + * Fetch config.json and normalize to `{ idps: [descriptor, ...], basicLogin, hosts }`. * Accepts a list (`{ idps: [...] }`) or a single bare object (legacy) wrapped * into one entry. An IdP-less config (no `idps`, no `issuer`) is valid — it * describes a credentials-only deployment, so `idps` comes back empty rather @@ -86,7 +104,13 @@ export async function loadConfigDoc(fetchFn, basePath = '') { const list = Array.isArray(cfg.idps) ? cfg.idps : (cfg.issuer || cfg.client_id) ? [cfg] : []; - return { idps: list.map(normalizeEntry), basicLogin: cfg.basic_login !== false }; + return { + idps: list.map(normalizeEntry), + basicLogin: cfg.basic_login !== false, + // Optional saved-connection list for the login host picker (npm run local + // fills it from ~/.clickhouse-client/config.xml). Empty when absent. + hosts: Array.isArray(cfg.hosts) ? cfg.hosts.map(normalizeHost) : [], + }; } /** diff --git a/src/styles.css b/src/styles.css index 4a544a3..3f33b3e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -154,6 +154,13 @@ body { font-family: inherit; transition: border-color .12s, box-shadow .12s; } .login-input.mono { font-family: var(--mono); font-size: 12.5px; } +.login-picker { + width: 100%; height: 38px; padding: 0 11px; + background: var(--bg-input); border: 1px solid var(--border); + border-radius: 8px; color: var(--fg); font-size: 12.5px; outline: none; + font-family: var(--mono); cursor: pointer; transition: border-color .12s, box-shadow .12s; +} +.login-picker:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); } .login-input::placeholder { color: var(--fg-faint); } .login-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); } .login-input-wrap { position: relative; } diff --git a/src/ui/app.js b/src/ui/app.js index c160f8f..c015862 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -141,7 +141,7 @@ export function createApp(env = {}) { app.authMode = 'oauth'; chCtx.origin = loc.origin; chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again - ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', + ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', 'oauth_origin', 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k)); } app.setTokens = setTokens; @@ -152,8 +152,13 @@ export function createApp(env = {}) { app.showLogin = (msg) => renderLogin(app, msg); // --- OAuth ------------------------------------------------------------- - async function login(idpId) { + async function login(idpId, targetOrigin) { if (idpId) selectIdp(idpId); + // A picked saved-connection can target another cluster: stash its origin so + // the rebuilt chCtx (after the redirect reload) POSTs the bearer there. + // Survives the redirect like oauth_state/oauth_idp; cleared for serving-host SSO. + if (targetOrigin) ss.setItem('oauth_origin', targetOrigin); + else ss.removeItem('oauth_origin'); const cfg = await resolveConfig(); const { verifier, challenge } = await generatePKCE(cryptoObj); const state = randomState(cryptoObj); @@ -207,7 +212,9 @@ export function createApp(env = {}) { fetch: fetchFn, // Where queries POST: the serving origin for OAuth, or the (possibly // cross-origin) target chosen at credential sign-in for basic mode. - origin: app.authMode === 'basic' ? (ss.getItem('ch_basic_origin') || loc.origin) : loc.origin, + origin: app.authMode === 'basic' + ? (ss.getItem('ch_basic_origin') || loc.origin) + : (ss.getItem('oauth_origin') || loc.origin), // Flips true after the first 2xx; gates whether a later 401/403 is treated // as a sign-in failure (only before auth is confirmed) or a query error. authConfirmed: false, @@ -779,7 +786,7 @@ export function createApp(env = {}) { selectTab: (id) => selectTab(app, id), closeTab: (id) => closeTab(app, id), loadIntoNewTab: (name, sql, savedId, chart) => loadIntoNewTab(app, name, sql, savedId, chart), - login: (idpId) => login(idpId), + login: (idpId, targetOrigin) => login(idpId, targetOrigin), connect, share, copyResult, diff --git a/src/ui/login.js b/src/ui/login.js index bc5df02..56bd927 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -99,6 +99,13 @@ export function renderLogin(app, errorMsg) { h('span', { style: { flex: '1' } }), targetAsEl)); + // --- saved-connection picker (populated async; shown only when config lists hosts) --- + let pickHosts = []; + const hostPicker = h('select', { class: 'login-picker mono', onchange: onPickHost }); + const pickerSection = h('div', { class: 'login-field login-picker-field', style: { display: 'none' } }, + h('label', { class: 'login-lbl' }, 'Saved connection'), + hostPicker); + // Footer tag adapts to which methods are available (set by applyChrome once // the IdP list / basic_login flag resolve). The brand block is heading enough, // so there's no separate "Sign in" title or subtitle. @@ -110,6 +117,7 @@ export function renderLogin(app, errorMsg) { h('div', { class: 'login-brand-text' }, h('div', { class: 'login-brand-name' }, 'Altinity SQL Browser'), h('div', { class: 'login-brand-sub mono' }, 'ClickHouse query console'))), + pickerSection, ssoSection, credSection, errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null, @@ -127,9 +135,10 @@ export function renderLogin(app, errorMsg) { // Resolve the configured IdPs (and the basic_login flag) and reconcile which // sections are shown. On failure keep credentials visible (fail-open — OAuth // can't work without config anyway) and show no SSO. - app.loadIdps().then(({ idps, basicLogin }) => { + app.loadIdps().then(({ idps, basicLogin, hosts }) => { const credsShown = basicLogin !== false; if (!credsShown) credSection.remove(); + populateHosts(hosts); populateSso(idps); applyChrome(ssoBtns.length > 0, credsShown); update(); @@ -144,7 +153,11 @@ export function renderLogin(app, errorMsg) { function populateSso(idps) { ssoBtns = []; - if (!idps || !idps.length) return; + // An IdP referenced by a saved connection is signed into via the picker (which + // targets that host's origin); don't also offer it as a serving-host SSO button — + // that would query the serving origin (e.g. localhost), not the chosen cluster. + const standalone = (idps || []).filter((i) => !pickHosts.some((hh) => hh.auth === 'oauth' && hh.idp === i.id)); + if (!standalone.length) return; const mk = (idpId, label) => { const b = h('button', { class: 'login-btn btn-primary', onclick: () => doSso(idpId, b) }, Icon.shield(), h('span', null, label)); @@ -153,13 +166,49 @@ export function renderLogin(app, errorMsg) { }; // Always label the button with the IdP — "Continue with Google" reads // better than a generic "SSO", and disambiguates when several are configured. - const btns = idps.map((i) => mk(i.id, 'Continue with ' + i.label)); + const btns = standalone.map((i) => mk(i.id, 'Continue with ' + i.label)); ssoSection.replaceChildren( ...btns, h('div', { class: 'login-sso-note' }, Icon.server(), h('span', null, 'Authenticates on '), h('span', { class: 'mono' }, cur))); } + // Fill the picker from config.json's `hosts` (npm run local supplies them from + // ~/.clickhouse-client/config.xml). Hidden when none are configured. + function populateHosts(hosts) { + pickHosts = hosts || []; + if (!pickHosts.length) return; + hostPicker.replaceChildren( + h('option', { value: '' }, 'Choose a connection…'), + ...pickHosts.map((hh, i) => h('option', { value: String(i) }, hh.label + (hh.auth === 'oauth' ? ' (OAuth)' : '')))); + pickerSection.style.display = ''; + } + + // Pick a saved connection: a basic one prefills the credentials form (+ reveals + // the host); an oauth one starts the SSO flow against that cluster. + function onPickHost() { + if (hostPicker.value === '') return; + const hh = pickHosts[Number(hostPicker.value)]; + if (hh.auth === 'oauth') { pickOAuth(hh); return; } + hostInput.value = hh.url; + userInput.value = hh.user; + passInput.value = hh.password; + advOpen = true; advField.style.display = ''; advChev.style.transform = 'rotate(0deg)'; + update(); + } + + async function pickOAuth(hh) { + busy = 'sso'; + hostPicker.disabled = true; + try { + await app.actions.login(hh.idp, hh.url); + } catch (err) { + busy = null; + hostPicker.disabled = false; + app.showLogin(String((err && err.message) || err)); + } + } + // Keep the primary/secondary swap, Connect enablement, and target row in sync // with the field values — updated in place so focus/caret are preserved. function update() { diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 598cc38..dd306f8 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -602,6 +602,26 @@ describe('auth flows', () => { app.chCtx.onSignedOut(); expect(app.root.querySelector('.login-error').textContent).toContain('session expired'); }); + it('login(idp, origin) stashes oauth_origin for a cross-origin cluster; sign-out clears it', async () => { + const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' }; + const e = env({ + location: loc, + sessionStorage: memSession({}), + fetch: makeFetch([ + [(u) => /config\.json/.test(u), resp({ json: { idps: [{ id: 'google', issuer: 'https://accounts.google.com', client_id: 'g' }] } })], + [(u) => /openid-configuration/.test(u), resp({ json: { authorization_endpoint: 'https://accounts.google.com/auth', token_endpoint: 'https://t' } })], + ]), + }); + const app = createApp(e); + await app.actions.login('google', 'https://antalya.demo.altinity.cloud'); + expect(e.sessionStorage.getItem('oauth_origin')).toBe('https://antalya.demo.altinity.cloud'); + app.signOut(); + expect(e.sessionStorage.getItem('oauth_origin')).toBeNull(); + }); + it('oauth mode posts queries to the stashed oauth_origin (cross-origin)', () => { + const e = env({ sessionStorage: memSession({ oauth_id_token: validToken, oauth_origin: 'https://antalya.demo.altinity.cloud' }) }); + expect(createApp(e).chCtx.origin).toBe('https://antalya.demo.altinity.cloud'); + }); }); describe('credentials (basic) sign-in', () => { diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index 8038425..68835fc 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -8,6 +8,11 @@ function type(input, value) { input.value = value; input.dispatchEvent(new Event('input')); } +function selectHost(root, value) { + const sel = root.querySelector('.login-picker'); + sel.value = value; + sel.dispatchEvent(new Event('change')); +} // makeApp defaults loadIdps → { idps: [], basicLogin: true }. Override per test. function appWith(over = {}) { const base = makeApp(); @@ -40,6 +45,71 @@ describe('renderLogin — structure', () => { }); }); +describe('renderLogin — host picker', () => { + const hosts = [ + { label: 'demo', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '' }, + { label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google' }, + ]; + const withHosts = (over = {}) => appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts }), ...over }); + + it('is hidden when no hosts are configured', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [] }) }); + renderLogin(app); await tick(); + expect(app.root.querySelector('.login-picker-field').style.display).toBe('none'); + }); + it('lists configured hosts (OAuth tagged) when present', async () => { + const app = withHosts(); + renderLogin(app); await tick(); + expect(app.root.querySelector('.login-picker-field').style.display).toBe(''); + expect([...app.root.querySelector('.login-picker').options].map((o) => o.textContent)) + .toEqual(['Choose a connection…', 'demo', 'antalya (OAuth)']); + }); + it('selecting a basic host prefills host/user/password and opens Advanced', async () => { + const app = withHosts(); + renderLogin(app); await tick(); + selectHost(app.root, '0'); + const [user, pass, host] = app.root.querySelectorAll('.login-input'); + expect([host.value, user.value, pass.value]).toEqual(['http://localhost:8123', 'default', 'pw']); + expect(app.root.querySelector('.login-adv-field').style.display).toBe(''); + }); + it('selecting an OAuth host starts SSO against that cluster', async () => { + const login = vi.fn(async () => {}); + const app = withHosts({ actions: { login } }); + renderLogin(app); await tick(); + selectHost(app.root, '1'); + expect(login).toHaveBeenCalledWith('google', 'https://antalya.demo.altinity.cloud'); + }); + it('does not show a standalone SSO button for an IdP a host references (picker-only)', async () => { + const app = appWith({ loadIdps: async () => ({ + idps: [{ id: 'antalya-oauth', label: 'antalya-oauth' }, { id: 'google', label: 'Google' }], + basicLogin: true, + hosts: [{ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', idp: 'antalya-oauth', user: '', password: '' }], + }) }); + renderLogin(app); await tick(); + const labels = [...app.root.querySelectorAll('.login-sso .login-btn')].map((b) => b.textContent); + expect(labels.some((l) => /antalya-oauth/.test(l))).toBe(false); // reached via the picker, not a serving-host button + expect(labels.some((l) => /Google/.test(l))).toBe(true); // an unreferenced IdP still shows standalone + }); + it('the placeholder option is a no-op', async () => { + const login = vi.fn(); + const app = withHosts({ actions: { login } }); + renderLogin(app); await tick(); + selectHost(app.root, ''); + expect(login).not.toHaveBeenCalled(); + }); + it('re-enables the picker and surfaces an error when OAuth sign-in fails', async () => { + const login = vi.fn(async () => { throw new Error('redirect blocked'); }); + const app = withHosts({ actions: { login } }); + app.showLogin = vi.fn(); + renderLogin(app); await tick(); + selectHost(app.root, '1'); + await tick(); + expect(login).toHaveBeenCalled(); + expect(app.showLogin).toHaveBeenCalled(); + expect(app.root.querySelector('.login-picker').disabled).toBe(false); + }); +}); + describe('renderLogin — SSO section', () => { it('no IdPs → no SSO button, divider hidden, credentials present', async () => { const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true }) }); diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js index fba6e37..ad0c54c 100644 --- a/tests/unit/oauth-config.test.js +++ b/tests/unit/oauth-config.test.js @@ -112,6 +112,31 @@ describe('loadConfigDoc', () => { }); }); +describe('loadConfigDoc hosts', () => { + const load = (body) => loadConfigDoc(fetcher([[/config\.json$/, resp(true, body)]]), '/sql'); + + it('returns [] when no hosts are configured', async () => { + expect((await load({ idps: [] })).hosts).toEqual([]); + }); + + it('normalizes basic and oauth host entries (defaults + auth)', async () => { + const { hosts } = await load({ + idps: [], + hosts: [ + { label: 'demo', url: 'http://localhost:8123', user: 'default', password: 'pw' }, + { label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', idp: 'google' }, + ], + }); + expect(hosts[0]).toEqual({ label: 'demo', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '' }); + expect(hosts[1]).toEqual({ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google' }); + }); + + it('falls back the label to the url and defaults missing fields', async () => { + const { hosts } = await load({ idps: [], hosts: [{ url: 'http://h:8123' }] }); + expect(hosts[0]).toEqual({ label: 'http://h:8123', url: 'http://h:8123', auth: 'basic', user: '', password: '', idp: '' }); + }); +}); + describe('resolveIdp', () => { const idp = { id: 'i', label: 'I', issuer: 'https://i', clientId: 'c', clientSecret: '',