| 1 | // End-to-end smoke test. Requires a running server and git in PATH. |
| 2 | // Creates test user + repo, exercises every route, cleans up. |
| 3 | // Run: node scripts/smoke.js |
| 4 | |
| 5 | 'use strict'; |
| 6 | |
| 7 | const http = require('http'); |
| 8 | const https = require('https'); |
| 9 | const { spawnSync } = require('child_process'); |
| 10 | const fs = require('fs'); |
| 11 | const path = require('path'); |
| 12 | const os = require('os'); |
| 13 | |
| 14 | const config = require('../lib/config'); |
| 15 | const { JsonStore } = require('../lib/store'); |
| 16 | |
| 17 | const BASE = config.get('site.publicUrl').replace(/\/$/, ''); |
| 18 | const USE_HTTPS = BASE.startsWith('https'); |
| 19 | const GIT = config.get('git.binPath') || 'git'; |
| 20 | const TEST_USER = 'smoketestuser99'; |
| 21 | const TEST_PASS = 'smoketestpass123'; |
| 22 | const TEST_REPO = 'smoketestrepo99'; |
| 23 | |
| 24 | let passed = 0; |
| 25 | let failed = 0; |
| 26 | let cookie = ''; |
| 27 | |
| 28 | function assert(label, condition, detail) { |
| 29 | if (condition) { |
| 30 | console.log(' PASS', label); |
| 31 | passed++; |
| 32 | } else { |
| 33 | console.error(' FAIL', label, detail || ''); |
| 34 | failed++; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | function req(method, urlPath, body, extraHeaders) { |
| 39 | return new Promise((resolve, reject) => { |
| 40 | const url = new URL(BASE + urlPath); |
| 41 | const opts = { |
| 42 | method, |
| 43 | hostname: url.hostname, |
| 44 | port: url.port || (USE_HTTPS ? 443 : 80), |
| 45 | path: url.pathname + url.search, |
| 46 | headers: { |
| 47 | 'Content-Type': 'application/json', |
| 48 | ...(cookie ? { Cookie: cookie } : {}), |
| 49 | ...(extraHeaders || {}) |
| 50 | } |
| 51 | }; |
| 52 | const transport = USE_HTTPS ? https : http; |
| 53 | const r = transport.request(opts, res => { |
| 54 | const chunks = []; |
| 55 | res.on('data', d => chunks.push(d)); |
| 56 | res.on('end', () => { |
| 57 | const raw = Buffer.concat(chunks).toString(); |
| 58 | let json = null; |
| 59 | try { json = JSON.parse(raw); } catch {} |
| 60 | const setCookie = res.headers['set-cookie']; |
| 61 | if (setCookie) { |
| 62 | const match = setCookie.join(';').match(/otgit_sid=[^;]+/); |
| 63 | if (match) cookie = match[0]; |
| 64 | } |
| 65 | resolve({ status: res.statusCode, json, raw, headers: res.headers }); |
| 66 | }); |
| 67 | }); |
| 68 | r.on('error', reject); |
| 69 | if (body) r.write(JSON.stringify(body)); |
| 70 | r.end(); |
| 71 | }); |
| 72 | } |
| 73 | |
| 74 | function gitCmd(args, cwd) { |
| 75 | const r = spawnSync(GIT, args, { cwd, encoding: 'utf8', timeout: 15000 }); |
| 76 | return { ok: r.status === 0, stdout: r.stdout, stderr: r.stderr }; |
| 77 | } |
| 78 | |
| 79 | function cleanup() { |
| 80 | const dataDir = path.join(__dirname, '..', 'data'); |
| 81 | const usersPath = path.join(dataDir, 'users.json'); |
| 82 | const reposPath = path.join(dataDir, 'repos.json'); |
| 83 | const repoDir = path.join(dataDir, 'repos', TEST_USER); |
| 84 | |
| 85 | try { |
| 86 | const users = JSON.parse(fs.readFileSync(usersPath, 'utf8')); |
| 87 | delete users[TEST_USER]; |
| 88 | fs.writeFileSync(usersPath, JSON.stringify(users)); |
| 89 | } catch {} |
| 90 | |
| 91 | try { |
| 92 | const repos = JSON.parse(fs.readFileSync(reposPath, 'utf8')); |
| 93 | delete repos[`${TEST_USER}/${TEST_REPO}`]; |
| 94 | fs.writeFileSync(reposPath, JSON.stringify(repos)); |
| 95 | } catch {} |
| 96 | |
| 97 | try { |
| 98 | fs.rmSync(repoDir, { recursive: true, force: true }); |
| 99 | } catch {} |
| 100 | } |
| 101 | |
| 102 | async function run() { |
| 103 | console.log('otgit smoke test'); |
| 104 | console.log('target:', BASE); |
| 105 | console.log(''); |
| 106 | |
| 107 | cleanup(); |
| 108 | cookie = ''; |
| 109 | |
| 110 | console.log('[health]'); |
| 111 | const health = await req('GET', '/health'); |
| 112 | assert('GET /health = 200', health.status === 200); |
| 113 | assert('GET /health ok:true', health.json && health.json.ok === true); |
| 114 | |
| 115 | console.log('[static]'); |
| 116 | const css = await req('GET', '/style.css'); |
| 117 | assert('GET /style.css = 200', css.status === 200); |
| 118 | |
| 119 | console.log('[home]'); |
| 120 | const home = await req('GET', '/'); |
| 121 | assert('GET / = 200', home.status === 200); |
| 122 | |
| 123 | console.log('[signup page]'); |
| 124 | const signupPage = await req('GET', '/signup'); |
| 125 | assert('GET /signup = 200', signupPage.status === 200); |
| 126 | |
| 127 | console.log('[auth api]'); |
| 128 | const signup = await req('POST', '/api/signup', { username: TEST_USER, password: TEST_PASS }); |
| 129 | assert('POST /api/signup = 201', signup.status === 201, signup.json); |
| 130 | assert('POST /api/signup ok', signup.json && signup.json.ok); |
| 131 | |
| 132 | const me = await req('GET', '/api/me'); |
| 133 | assert('GET /api/me returns user', me.json && me.json.user && me.json.user.username === TEST_USER); |
| 134 | |
| 135 | const loginOther = await req('POST', '/api/login', { username: TEST_USER, password: 'wrong' }); |
| 136 | assert('POST /api/login wrong password = 401', loginOther.status === 401); |
| 137 | |
| 138 | const login = await req('POST', '/api/login', { username: TEST_USER, password: TEST_PASS }); |
| 139 | assert('POST /api/login = 200', login.status === 200); |
| 140 | |
| 141 | console.log('[repo create]'); |
| 142 | const create = await req('POST', '/api/repo/create', { name: TEST_REPO, description: 'smoke test', visibility: 'public' }); |
| 143 | assert('POST /api/repo/create = 201', create.status === 201, create.json); |
| 144 | |
| 145 | console.log('[repo pages (empty)]'); |
| 146 | const repoHome = await req('GET', `/${TEST_USER}/${TEST_REPO}`); |
| 147 | assert(`GET /${TEST_USER}/${TEST_REPO} = 200`, repoHome.status === 200); |
| 148 | |
| 149 | console.log('[git push]'); |
| 150 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otgit-smoke-')); |
| 151 | const repoUrl = BASE + `/${TEST_USER}/${TEST_REPO}.git`; |
| 152 | const authUrl = repoUrl.replace('://', `://${TEST_USER}:${TEST_PASS}@`); |
| 153 | |
| 154 | const cloneResult = gitCmd(['clone', authUrl, tmpDir], os.tmpdir()); |
| 155 | assert('git clone (empty)', cloneResult.ok, cloneResult.stderr); |
| 156 | |
| 157 | if (cloneResult.ok) { |
| 158 | fs.writeFileSync(path.join(tmpDir, 'README.md'), `# ${TEST_REPO}\n\nSmoke test repo.\n`); |
| 159 | fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world\n'); |
| 160 | gitCmd(['config', 'user.email', 'smoke@test.local'], tmpDir); |
| 161 | gitCmd(['config', 'user.name', 'Smoke Test'], tmpDir); |
| 162 | gitCmd(['add', '.'], tmpDir); |
| 163 | gitCmd(['commit', '-m', 'initial commit'], tmpDir); |
| 164 | const push = gitCmd(['push', 'origin', 'HEAD:main'], tmpDir); |
| 165 | assert('git push', push.ok, push.stderr); |
| 166 | } |
| 167 | |
| 168 | console.log('[repo pages (with content)]'); |
| 169 | const repoHome2 = await req('GET', `/${TEST_USER}/${TEST_REPO}`); |
| 170 | assert(`GET /${TEST_USER}/${TEST_REPO} after push = 200`, repoHome2.status === 200); |
| 171 | |
| 172 | const blobPage = await req('GET', `/${TEST_USER}/${TEST_REPO}/blob/main/README.md`); |
| 173 | assert('GET blob/main/README.md = 200', blobPage.status === 200); |
| 174 | |
| 175 | const rawFile = await req('GET', `/${TEST_USER}/${TEST_REPO}/raw/main/README.md`); |
| 176 | assert('GET raw/main/README.md = 200', rawFile.status === 200); |
| 177 | assert('raw file has content', rawFile.raw.includes('Smoke test repo')); |
| 178 | |
| 179 | const history = await req('GET', `/${TEST_USER}/${TEST_REPO}/history`); |
| 180 | assert('GET history = 200', history.status === 200); |
| 181 | |
| 182 | console.log('[git clone public]'); |
| 183 | const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'otgit-smoke2-')); |
| 184 | const clone2 = gitCmd(['clone', repoUrl, tmpDir2], os.tmpdir()); |
| 185 | assert('git clone public (no auth)', clone2.ok, clone2.stderr); |
| 186 | |
| 187 | console.log('[repo settings]'); |
| 188 | const settingsPage = await req('GET', `/${TEST_USER}/${TEST_REPO}/settings`); |
| 189 | assert('GET settings page = 200', settingsPage.status === 200); |
| 190 | |
| 191 | const updateDesc = await req('POST', `/api/repo/${TEST_USER}/${TEST_REPO}/settings`, { description: 'updated' }); |
| 192 | assert('POST settings (description) = 200', updateDesc.status === 200, updateDesc.json); |
| 193 | |
| 194 | const rename = await req('POST', `/api/repo/${TEST_USER}/${TEST_REPO}/rename`, { name: TEST_REPO + 'v2' }); |
| 195 | assert('POST rename = 200', rename.status === 200, rename.json); |
| 196 | |
| 197 | const delRepo = await req('POST', `/api/repo/${TEST_USER}/${TEST_REPO + 'v2'}/delete`, {}); |
| 198 | assert('POST delete = 200', delRepo.status === 200, delRepo.json); |
| 199 | |
| 200 | console.log('[user profile]'); |
| 201 | const profile = await req('GET', `/${TEST_USER}`); |
| 202 | assert(`GET /${TEST_USER} = 200`, profile.status === 200); |
| 203 | |
| 204 | console.log('[logout]'); |
| 205 | const logout = await req('POST', '/api/logout'); |
| 206 | assert('POST /api/logout = 200', logout.status === 200); |
| 207 | |
| 208 | const meAfter = await req('GET', '/api/me'); |
| 209 | assert('GET /api/me after logout = null user', meAfter.json && meAfter.json.user === null); |
| 210 | |
| 211 | console.log('[not found]'); |
| 212 | const nf = await req('GET', '/no-such-page-here'); |
| 213 | assert('unknown route = 404', nf.status === 404); |
| 214 | |
| 215 | console.log(''); |
| 216 | cleanup(); |
| 217 | try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} |
| 218 | try { fs.rmSync(tmpDir2 || '', { recursive: true, force: true }); } catch {} |
| 219 | |
| 220 | console.log(`Results: ${passed} passed, ${failed} failed`); |
| 221 | if (failed > 0) process.exit(1); |
| 222 | } |
| 223 | |
| 224 | run().catch(e => { |
| 225 | console.error('smoke test error:', e.message); |
| 226 | process.exit(1); |
| 227 | }); |
| 228 | |