#!/usr/bin/env node /** * K-Vault storage regression helper * * Usage example: * BASE_URL=http://localhost:8080 \ * BASIC_USER=admin BASIC_PASS=yourpass \ * SMOKE_STORAGE_TYPE=webdav \ * SMOKE_STORAGE_CONFIG_JSON='{"baseUrl":"https://dav.example.com","username":"u","password":"p"}' \ * node scripts/storage-regression.js */ const BASE_URL = String(process.env.BASE_URL || 'http://localhost:8080').replace(/\/+$/, ''); const BASIC_USER = String(process.env.BASIC_USER || ''); const BASIC_PASS = String(process.env.BASIC_PASS || ''); const SMOKE_STORAGE_TYPE = String(process.env.SMOKE_STORAGE_TYPE || '').trim().toLowerCase(); const SMOKE_STORAGE_CONFIG_JSON = String(process.env.SMOKE_STORAGE_CONFIG_JSON || '').trim(); const TEST_ONLY_TYPES = String(process.env.TEST_ONLY_TYPES || '') .split(',') .map((v) => v.trim().toLowerCase()) .filter(Boolean); const state = { cookies: new Map(), }; function setCookieFromHeaders(headers) { const cookieHeaders = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : []; for (const cookieLine of cookieHeaders) { const first = String(cookieLine || '').split(';')[0].trim(); if (!first.includes('=')) continue; const [name, ...rest] = first.split('='); state.cookies.set(name, rest.join('=')); } } function cookieHeader() { if (!state.cookies.size) return ''; return [...state.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } function buildUrl(path) { if (/^https?:\/\//i.test(path)) return path; return `${BASE_URL}${path}`; } async function request(path, { method = 'GET', headers = {}, body = null, allowError = false } = {}) { const url = buildUrl(path); const cookie = cookieHeader(); const finalHeaders = { ...headers }; if (cookie) finalHeaders.Cookie = cookie; const response = await fetch(url, { method, headers: finalHeaders, body, redirect: 'follow', }); setCookieFromHeaders(response.headers); const contentType = response.headers.get('content-type') || ''; const payload = contentType.includes('application/json') ? await response.json().catch(() => ({})) : await response.text().catch(() => ''); if (!allowError && !response.ok) { const message = typeof payload === 'string' ? payload : payload.error || payload.message || `HTTP ${response.status}`; throw new Error(`${method} ${path} failed: ${message}`); } return { response, payload }; } function logStep(title) { process.stdout.write(`\n[STEP] ${title}\n`); } function logOk(message) { process.stdout.write(` ✔ ${message}\n`); } async function ensureLoginIfNeeded() { logStep('Auth check + login'); const authCheck = await request('/api/auth/check'); const authRequired = Boolean(authCheck.payload?.authRequired); logOk(`authRequired = ${authRequired}`); if (!authRequired) { logOk('Auth disabled, skip login payload checks'); return; } if (!BASIC_USER || !BASIC_PASS) { throw new Error('Auth is required. Set BASIC_USER and BASIC_PASS for regression script.'); } const loginNew = await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: BASIC_USER, password: BASIC_PASS }), }); if (!loginNew.payload?.success) { throw new Error('Login with {username,password} did not succeed.'); } logOk('login payload {username,password} passed'); const loginCompat = await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: BASIC_USER, pass: BASIC_PASS }), }); if (!loginCompat.payload?.success) { throw new Error('Login with {user,pass} did not succeed.'); } logOk('login payload {user,pass} passed'); } async function checkHealthAndStatus() { logStep('Health + Status'); const health = await request('/api/health'); if (!health.payload?.ok) throw new Error('/api/health did not return ok=true'); logOk('/api/health ok'); const status = await request('/api/status'); const storageKeys = ['telegram', 'r2', 's3', 'discord', 'huggingface', 'webdav', 'github']; for (const key of storageKeys) { if (!Object.prototype.hasOwnProperty.call(status.payload || {}, key)) { throw new Error(`/api/status missing key: ${key}`); } } logOk('/api/status includes all storage keys'); } function parseSmokeConfig() { if (!SMOKE_STORAGE_CONFIG_JSON) return null; try { return JSON.parse(SMOKE_STORAGE_CONFIG_JSON); } catch (error) { throw new Error(`SMOKE_STORAGE_CONFIG_JSON is not valid JSON: ${error.message}`); } } async function storageCrudAndSelection() { logStep('Storage list / create / update / test / default'); const listed = await request('/api/storage/list'); const initialItems = listed.payload?.items || []; logOk(`initial storage configs: ${initialItems.length}`); const originalDefault = initialItems.find((item) => item.isDefault)?.id || null; let created = null; const smokeConfig = parseSmokeConfig(); if (SMOKE_STORAGE_TYPE && smokeConfig) { const createRes = await request('/api/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `smoke-${SMOKE_STORAGE_TYPE}-${Date.now()}`, type: SMOKE_STORAGE_TYPE, enabled: true, isDefault: false, config: smokeConfig, }), }); created = createRes.payload?.item; if (!created?.id) throw new Error('Storage create succeeded without item.id'); logOk(`created storage config: ${created.id}`); const updateRes = await request(`/api/storage/${encodeURIComponent(created.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `${created.name}-updated`, }), }); if (!updateRes.payload?.item?.name?.endsWith('-updated')) { throw new Error('Storage update did not apply expected name suffix.'); } logOk('update storage config passed'); const createdTest = await request(`/api/storage/${encodeURIComponent(created.id)}/test`, { method: 'POST', allowError: true, }); const testBody = createdTest.payload || {}; const testResult = testBody.result || testBody; logOk(`created storage test -> connected=${Boolean(testResult.connected)} status=${testResult.status || 'n/a'}`); await request(`/api/storage/default/${encodeURIComponent(created.id)}`, { method: 'POST', }); logOk('set created config as default passed'); } else { logOk('create/update skipped (set SMOKE_STORAGE_TYPE + SMOKE_STORAGE_CONFIG_JSON to enable)'); } return { createdId: created?.id || null, originalDefault }; } async function uploadDownloadDeleteForEnabledStorages() { logStep('Upload / Download / Delete by storage'); const listed = await request('/api/storage/list'); const allItems = (listed.payload?.items || []).filter((item) => item.enabled); const items = TEST_ONLY_TYPES.length ? allItems.filter((item) => TEST_ONLY_TYPES.includes(String(item.type || '').toLowerCase())) : allItems; if (!items.length) { logOk('no enabled storage configs found, skip file IO checks'); return; } for (const item of items) { const marker = `smoke-${item.type}-${Date.now()}-${Math.random().toString(16).slice(2)}`; const fileName = `smoke-${item.type}.txt`; const file = new File([marker], fileName, { type: 'text/plain' }); const body = new FormData(); body.append('file', file); body.append('storageId', item.id); const uploadRes = await request('/upload', { method: 'POST', body, }); const src = Array.isArray(uploadRes.payload) ? uploadRes.payload[0]?.src : uploadRes.payload?.src; if (!src) { throw new Error(`upload for storage=${item.type} did not return src`); } logOk(`${item.type}: upload passed`); const downloadRes = await request(src, { allowError: true }); if (downloadRes.response.status >= 400) { throw new Error(`${item.type}: download failed with status ${downloadRes.response.status}`); } const text = typeof downloadRes.payload === 'string' ? downloadRes.payload : ''; if (!text.includes(marker)) { throw new Error(`${item.type}: downloaded content mismatch`); } logOk(`${item.type}: download passed`); const fileId = decodeURIComponent(String(src).split('/file/')[1] || ''); if (!fileId) { throw new Error(`${item.type}: cannot parse fileId from src=${src}`); } const deleteRes = await request(`/api/manage/delete/${encodeURIComponent(fileId)}`, { method: 'GET', allowError: true, }); if (!deleteRes.payload?.success) { throw new Error(`${item.type}: delete failed: ${deleteRes.payload?.error || 'unknown error'}`); } logOk(`${item.type}: delete passed`); } } async function cleanupStorage(createdId, originalDefaultId) { logStep('Cleanup'); if (originalDefaultId) { await request(`/api/storage/default/${encodeURIComponent(originalDefaultId)}`, { method: 'POST', allowError: true, }); logOk('restored original default storage (best effort)'); } if (createdId) { await request(`/api/storage/${encodeURIComponent(createdId)}`, { method: 'DELETE', allowError: true, }); logOk('deleted created smoke storage (best effort)'); } } async function main() { process.stdout.write(`K-Vault storage regression start\nBASE_URL=${BASE_URL}\n`); await checkHealthAndStatus(); await ensureLoginIfNeeded(); const { createdId, originalDefault } = await storageCrudAndSelection(); await uploadDownloadDeleteForEnabledStorages(); await cleanupStorage(createdId, originalDefault); process.stdout.write('\nAll regression checks completed.\n'); } main().catch((error) => { process.stderr.write(`\nRegression failed: ${error.message}\n`); process.exit(1); });