diff --git a/chat/public/admin-accounts.html b/chat/public/admin-accounts.html index f058ab6..d90dac5 100644 --- a/chat/public/admin-accounts.html +++ b/chat/public/admin-accounts.html @@ -33,6 +33,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -94,4 +95,4 @@ - \ No newline at end of file + diff --git a/chat/public/admin-affiliates.html b/chat/public/admin-affiliates.html index 9209830..d0feff0 100644 --- a/chat/public/admin-affiliates.html +++ b/chat/public/admin-affiliates.html @@ -33,6 +33,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -94,3 +95,4 @@ + diff --git a/chat/public/admin-blogs.html b/chat/public/admin-blogs.html index 1f94f59..aeb575d 100644 --- a/chat/public/admin-blogs.html +++ b/chat/public/admin-blogs.html @@ -245,6 +245,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -427,3 +428,4 @@ + diff --git a/chat/public/admin-contact-messages.html b/chat/public/admin-contact-messages.html index 435cfbd..079f38f 100644 --- a/chat/public/admin-contact-messages.html +++ b/chat/public/admin-contact-messages.html @@ -138,6 +138,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -307,3 +308,4 @@ + diff --git a/chat/public/admin-external-testing.html b/chat/public/admin-external-testing.html index 2c2d6f8..e630a2a 100644 --- a/chat/public/admin-external-testing.html +++ b/chat/public/admin-external-testing.html @@ -31,6 +31,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -80,3 +81,4 @@ + diff --git a/chat/public/admin-feature-requests.html b/chat/public/admin-feature-requests.html index 1913d2e..f7c80ac 100644 --- a/chat/public/admin-feature-requests.html +++ b/chat/public/admin-feature-requests.html @@ -226,6 +226,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -535,3 +536,4 @@ + diff --git a/chat/public/admin-plan.html b/chat/public/admin-plan.html index b9ef621..5322ffa 100644 --- a/chat/public/admin-plan.html +++ b/chat/public/admin-plan.html @@ -31,6 +31,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -145,3 +146,4 @@ + diff --git a/chat/public/admin-plans.html b/chat/public/admin-plans.html index e3b5090..c2795f5 100644 --- a/chat/public/admin-plans.html +++ b/chat/public/admin-plans.html @@ -31,6 +31,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -97,4 +98,4 @@ - \ No newline at end of file + diff --git a/chat/public/admin-resources.html b/chat/public/admin-resources.html index 23b6c1e..67f99cc 100644 --- a/chat/public/admin-resources.html +++ b/chat/public/admin-resources.html @@ -224,9 +224,10 @@ Withdrawals Tracking Resources - External Testing - Contact Messages - Feature Requests + System Tests + External Testing + Contact Messages + Feature Requests Login @@ -962,3 +963,4 @@ + diff --git a/chat/public/admin-system-tests.html b/chat/public/admin-system-tests.html new file mode 100644 index 0000000..50c54cf --- /dev/null +++ b/chat/public/admin-system-tests.html @@ -0,0 +1,79 @@ + + + + + + Admin Panel - System Tests + + + + + + + +
+ +
+
+
+ +
+
Admin
+
System Tests
+
Run end-to-end checks for database, accounts, and payments.
+
+
+ + +
+
+ +
+
+

Full system self-test

+
Diagnostics
+
+

+ Runs database/encryption checks, creates a temporary test account, and verifies payment configuration. + If Dodo is configured, it will create a test checkout session (no charge is completed). +

+
+ +
+
+
+
+
+
+
+ + + diff --git a/chat/public/admin-tracking.html b/chat/public/admin-tracking.html index b7ead99..0e3aac8 100644 --- a/chat/public/admin-tracking.html +++ b/chat/public/admin-tracking.html @@ -184,6 +184,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -990,3 +991,4 @@ + diff --git a/chat/public/admin-withdrawals.html b/chat/public/admin-withdrawals.html index 1c087c1..a16a177 100644 --- a/chat/public/admin-withdrawals.html +++ b/chat/public/admin-withdrawals.html @@ -33,6 +33,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -93,3 +94,4 @@ + diff --git a/chat/public/admin.html b/chat/public/admin.html index 4b95a71..11b17e4 100644 --- a/chat/public/admin.html +++ b/chat/public/admin.html @@ -43,6 +43,7 @@ Withdrawals Tracking Resources + System Tests External Testing Contact Messages Feature Requests @@ -322,3 +323,4 @@ + diff --git a/chat/public/admin.js b/chat/public/admin.js index b866863..7e8acfb 100644 --- a/chat/public/admin.js +++ b/chat/public/admin.js @@ -125,6 +125,9 @@ ollamaTestRun: document.getElementById('ollama-test-run'), ollamaTestStatus: document.getElementById('ollama-test-status'), ollamaTestOutput: document.getElementById('ollama-test-output'), + systemTestsRun: document.getElementById('system-tests-run'), + systemTestsStatus: document.getElementById('system-tests-status'), + systemTestsOutput: document.getElementById('system-tests-output'), }; function ensureAvailableDatalist() { @@ -221,6 +224,12 @@ el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } + function setSystemTestsStatus(msg, isError = false) { + if (!el.systemTestsStatus) return; + el.systemTestsStatus.textContent = msg || ''; + el.systemTestsStatus.style.color = isError ? 'var(--danger)' : 'inherit'; + } + function renderExternalTestingConfig(config) { if (!el.externalTestingConfig) return; el.externalTestingConfig.innerHTML = ''; @@ -337,6 +346,66 @@ renderExternalTestingConfig(data.config || {}); } + function renderSystemTestsOutput(data) { + if (!el.systemTestsOutput) return; + el.systemTestsOutput.innerHTML = ''; + if (!data) return; + + const summary = data.summary || { total: 0, passed: 0, failed: 0, skipped: 0 }; + const summaryRow = document.createElement('div'); + summaryRow.className = 'admin-row'; + const summaryLabel = document.createElement('div'); + summaryLabel.style.minWidth = '180px'; + summaryLabel.style.color = 'var(--muted)'; + summaryLabel.textContent = 'Summary'; + const summaryValue = document.createElement('div'); + summaryValue.textContent = `${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped (${data.durationMs || 0}ms)`; + summaryRow.appendChild(summaryLabel); + summaryRow.appendChild(summaryValue); + el.systemTestsOutput.appendChild(summaryRow); + + const results = Array.isArray(data.results) ? data.results : []; + results.forEach((result) => { + const row = document.createElement('div'); + row.className = 'admin-row'; + const labelWrap = document.createElement('div'); + labelWrap.style.minWidth = '180px'; + const strong = document.createElement('strong'); + strong.textContent = result.name || 'Check'; + labelWrap.appendChild(strong); + + const valueWrap = document.createElement('div'); + const status = document.createElement('span'); + const statusText = (result.status || 'unknown').toUpperCase(); + status.textContent = statusText; + if (result.status === 'passed') status.style.color = 'var(--shopify-green)'; + else if (result.status === 'failed') status.style.color = 'var(--danger)'; + else status.style.color = 'var(--muted)'; + + valueWrap.appendChild(status); + if (result.details) { + const sep = document.createElement('span'); + sep.textContent = ' - '; + valueWrap.appendChild(sep); + const details = document.createElement('span'); + details.textContent = result.details; + valueWrap.appendChild(details); + } + + if (typeof result.durationMs === 'number') { + const timing = document.createElement('span'); + timing.textContent = ` (${result.durationMs}ms)`; + timing.style.color = 'var(--muted)'; + timing.style.marginLeft = '4px'; + valueWrap.appendChild(timing); + } + + row.appendChild(labelWrap); + row.appendChild(valueWrap); + el.systemTestsOutput.appendChild(row); + }); + } + // --- Ollama Test UI --- function setOllamaTestStatus(msg, isError = false) { if (!el.ollamaTestStatus) return; @@ -2822,6 +2891,23 @@ }); } + if (el.systemTestsRun) { + el.systemTestsRun.addEventListener('click', async () => { + el.systemTestsRun.disabled = true; + setSystemTestsStatus('Running system tests...'); + if (el.systemTestsOutput) el.systemTestsOutput.innerHTML = ''; + try { + const data = await api('/api/admin/system-tests', { method: 'POST' }); + renderSystemTestsOutput(data || null); + setSystemTestsStatus(data && data.ok ? 'System tests passed.' : 'System tests completed with failures.', !data || !data.ok); + } catch (err) { + setSystemTestsStatus(err.message || 'System tests failed.', true); + } finally { + el.systemTestsRun.disabled = false; + } + }); + } + // Ollama Test button handler if (el.ollamaTestRun) { el.ollamaTestRun.addEventListener('click', async () => { diff --git a/chat/server.js b/chat/server.js index 1673807..800055c 100644 --- a/chat/server.js +++ b/chat/server.js @@ -20,7 +20,7 @@ const blogSystem = require('./blog-system'); const versionManager = require('./src/utils/versionManager'); const { DATA_ROOT, STATE_DIR, DB_PATH, KEY_FILE } = require('./src/database/config'); const { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection'); -const { initEncryption, encrypt } = require('./src/utils/encryption'); +const { initEncryption, encrypt, decrypt, isEncryptionInitialized } = require('./src/utils/encryption'); let sharp = null; try { @@ -17279,6 +17279,259 @@ async function handleAdminOllamaTest(req, res) { } } +async function handleAdminSystemTests(req, res) { + const adminSession = requireAdminAuth(req, res); + if (!adminSession) return; + + const startedAt = Date.now(); + const results = []; + + const pushResult = (name, status, details, meta, durationMs) => { + results.push({ + name, + status, + details: details || '', + meta: meta || null, + durationMs: durationMs || 0, + }); + }; + + const runCheck = async (name, fn) => { + const start = Date.now(); + try { + const outcome = await fn(); + const status = outcome?.status || 'passed'; + pushResult(name, status, outcome?.details || '', outcome?.meta || null, Date.now() - start); + } catch (error) { + pushResult( + name, + 'failed', + error?.message || String(error), + { error: String(error) }, + Date.now() - start + ); + } + }; + + await runCheck('database.enabled', async () => { + const db = getDatabase(); + if (!databaseEnabled || !db || !db.open) { + return { + status: 'failed', + details: `Database disabled (${databaseFallbackReason || 'unknown'})`, + meta: { + dbPath: DB_PATH, + enabled: databaseEnabled, + fallbackReason: databaseFallbackReason || null, + }, + }; + } + return { + status: 'passed', + details: `Database enabled (${DB_PATH})`, + meta: { + dbPath: DB_PATH, + sqlcipher: DATABASE_USE_SQLCIPHER, + walMode: DATABASE_WAL_MODE, + }, + }; + }); + + await runCheck('database.schema', async () => { + const db = getDatabase(); + if (!databaseEnabled || !db || !db.open) { + return { status: 'skipped', details: 'Database not enabled' }; + } + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); + const tableNames = new Set(tables.map((t) => t.name)); + const missing = []; + + if (!tableNames.has('users')) missing.push('users'); + if (!tableNames.has('affiliate_accounts')) missing.push('affiliate_accounts'); + if (!tableNames.has('sessions')) missing.push('sessions'); + + const userColumns = db.prepare("PRAGMA table_info('users')").all(); + const userColumnNames = new Set(userColumns.map((c) => c.name)); + if (!userColumnNames.has('data')) missing.push('users.data'); + + if (missing.length) { + return { + status: 'failed', + details: `Missing schema: ${missing.join(', ')}`, + meta: { tables: Array.from(tableNames).sort().slice(0, 40) }, + }; + } + return { status: 'passed', details: 'Schema looks healthy' }; + }); + + await runCheck('encryption.roundtrip', async () => { + if (!databaseEnabled) { + return { status: 'skipped', details: 'Database disabled; encryption not initialized' }; + } + if (!isEncryptionInitialized()) { + return { status: 'failed', details: 'Encryption not initialized' }; + } + const sample = `self-test-${randomUUID()}`; + const encrypted = encrypt(sample); + const decrypted = decrypt(encrypted); + if (decrypted !== sample) { + return { status: 'failed', details: 'Encryption roundtrip mismatch' }; + } + return { status: 'passed', details: 'Encryption roundtrip ok' }; + }); + + await runCheck('account.create', async () => { + const testEmail = `admin-selftest-${Date.now()}@example.com`; + const testPassword = `SelfTest1!XyZ${String(Date.now()).slice(-4)}`; + const passwordValidation = validatePassword(testPassword); + if (!passwordValidation.valid) { + return { status: 'failed', details: `Generated password failed validation: ${passwordValidation.errors.join(', ')}` }; + } + + let testUser = null; + try { + testUser = await createUser(testEmail, testPassword); + const found = findUserByEmail(testEmail); + if (!found || found.id !== testUser.id) { + return { status: 'failed', details: 'User not found after creation' }; + } + + const loginUser = await verifyUserPassword(testEmail, testPassword); + if (!loginUser || loginUser.id !== testUser.id) { + return { status: 'failed', details: 'Password verification failed' }; + } + + if (databaseEnabled) { + const db = getDatabase(); + if (!db) return { status: 'failed', details: 'Database not initialized during account test' }; + const row = db.prepare('SELECT email, email_encrypted, password_hash, data FROM users WHERE id = ?').get(testUser.id); + if (!row) { + return { status: 'failed', details: 'User row missing from database' }; + } + if (row.email !== testEmail) { + return { status: 'failed', details: 'Database email mismatch after creation' }; + } + if (!row.password_hash) { + return { status: 'failed', details: 'Database password hash missing' }; + } + if (!row.email_encrypted) { + return { status: 'failed', details: 'Encrypted email missing in database' }; + } + } + + return { status: 'passed', details: 'Account created and verified' }; + } finally { + if (testUser) { + const index = usersDb.findIndex((u) => u && u.id === testUser.id); + if (index >= 0) { + usersDb.splice(index, 1); + await persistUsersDb(); + } + if (databaseEnabled) { + const db = getDatabase(); + if (db) { + try { + db.prepare('DELETE FROM users WHERE id = ?').run(testUser.id); + } catch (_) { } + } + } + } + } + }); + + await runCheck('payments.topups.config', async () => { + if (!DODO_ENABLED) { + return { status: 'skipped', details: 'Dodo Payments disabled' }; + } + const pack = resolveTopupPack('topup_1', 'usd'); + const baseAmount = getTopupPrice(pack.tier, pack.currency); + if (!pack.productId || !pack.tokens || !baseAmount) { + return { + status: 'failed', + details: 'Top-up configuration missing (productId, tokens, or price)', + meta: { pack, baseAmount }, + }; + } + return { + status: 'passed', + details: `Top-up configured (${pack.tokens} tokens for ${pack.currency.toUpperCase()} ${baseAmount})`, + meta: { productId: pack.productId }, + }; + }); + + await runCheck('payments.topups.checkout', async () => { + if (!DODO_ENABLED) { + return { status: 'skipped', details: 'Dodo Payments disabled' }; + } + const { pack, baseAmount } = await fetchTopupProduct('topup_1', 'usd'); + const unitAmount = applyTopupDiscount(baseAmount, 0); + const orderId = `admin_self_test_${randomUUID()}`; + const isValidEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || '')); + const adminEmail = isValidEmail(ADMIN_USER) ? ADMIN_USER : 'admin@example.com'; + const checkoutBody = { + product_cart: [{ + product_id: pack.productId, + quantity: 1, + amount: unitAmount, + }], + customer: { + email: adminEmail, + name: ADMIN_USER || 'Admin', + }, + metadata: { + type: 'admin_self_test', + orderId, + admin: 'true', + tokens: String(pack.tokens), + tier: String(pack.tier), + currency: String(pack.currency), + amount: String(unitAmount), + }, + settings: { + redirect_immediately: false, + }, + return_url: `${resolveBaseUrl(req)}/test-checkout`, + }; + + const checkoutSession = await dodoRequest('/checkouts', { + method: 'POST', + body: checkoutBody, + }); + + const sessionId = checkoutSession?.session_id || checkoutSession?.id || ''; + if (!sessionId || !checkoutSession?.checkout_url) { + return { status: 'failed', details: 'Dodo checkout did not return a checkout URL' }; + } + return { + status: 'passed', + details: 'Dodo checkout created successfully', + meta: { sessionId }, + }; + }); + + const durationMs = Date.now() - startedAt; + const summary = results.reduce( + (acc, r) => { + acc.total += 1; + if (r.status === 'passed') acc.passed += 1; + else if (r.status === 'failed') acc.failed += 1; + else acc.skipped += 1; + return acc; + }, + { total: 0, passed: 0, failed: 0, skipped: 0 } + ); + const ok = summary.failed === 0; + + sendJson(res, 200, { + ok, + startedAt: new Date(startedAt).toISOString(), + finishedAt: new Date().toISOString(), + durationMs, + summary, + results, + }); +} + // Get detailed resource usage breakdown by session for admin panel async function handleAdminResources(req, res) { const adminSession = requireAdminAuth(req, res); @@ -19551,6 +19804,7 @@ async function routeInternal(req, res, url, pathname) { if (req.method === 'GET' && pathname === '/api/admin/resources') return handleAdminResources(req, res); if (req.method === 'GET' && pathname === '/api/admin/external-testing-status') return handleAdminExternalTestingStatus(req, res); if (req.method === 'POST' && pathname === '/api/admin/external-testing-self-test') return handleAdminExternalTestingSelfTest(req, res); + if (req.method === 'POST' && pathname === '/api/admin/system-tests') return handleAdminSystemTests(req, res); if (req.method === 'POST' && pathname === '/api/upgrade-popup-tracking') return handleUpgradePopupTracking(req, res); if (req.method === 'POST' && pathname === '/api/admin/cancel-messages') return handleAdminCancelMessages(req, res); if (req.method === 'POST' && pathname === '/api/admin/ollama-test') return handleAdminOllamaTest(req, res); @@ -19748,6 +20002,11 @@ async function routeInternal(req, res, url, pathname) { if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html'); return serveFile(res, safeStaticPath('admin-resources.html'), 'text/html'); } + if (pathname === '/admin/system-tests') { + const session = getAdminSession(req); + if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html'); + return serveFile(res, safeStaticPath('admin-system-tests.html'), 'text/html'); + } if (pathname === '/admin/external-testing') { const session = getAdminSession(req); if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');