diff options
Diffstat (limited to 'tests/integration/multi-mint.mjs')
| -rw-r--r-- | tests/integration/multi-mint.mjs | 193 |
1 files changed, 193 insertions, 0 deletions
diff --git a/tests/integration/multi-mint.mjs b/tests/integration/multi-mint.mjs new file mode 100644 index 0000000..1b36aa0 --- /dev/null +++ b/tests/integration/multi-mint.mjs | |||
| @@ -0,0 +1,193 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const API_PORT = 2121; | ||
| 5 | const BASE = `http://${IP}:${API_PORT}`; | ||
| 6 | const MINTS_EXPECTED = [ | ||
| 7 | 'https://mint.minibits.cash/Bitcoin', | ||
| 8 | 'https://mint.coinos.io', | ||
| 9 | 'https://21mint.me', | ||
| 10 | 'https://mint.lnvoltz.com', | ||
| 11 | ]; | ||
| 12 | let passed = 0, failed = 0, skipped = 0; | ||
| 13 | |||
| 14 | function assert(condition, test) { | ||
| 15 | if (condition) { console.log(` \u2713 ${test}`); passed++; } | ||
| 16 | else { console.log(` \u2717 ${test}`); failed++; } | ||
| 17 | } | ||
| 18 | function skip(test, reason) { | ||
| 19 | console.log(` \u25CB ${test} (SKIPPED: ${reason})`); skipped++; | ||
| 20 | } | ||
| 21 | function run(cmd) { | ||
| 22 | try { return execSync(cmd, { encoding: 'utf8', timeout: 30000 }); } | ||
| 23 | catch (e) { return e.stdout || null; } | ||
| 24 | } | ||
| 25 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 26 | |||
| 27 | console.log(`\n========================================`); | ||
| 28 | console.log(` Multi-Mint Integration Test`); | ||
| 29 | console.log(` Target: ${IP}:${API_PORT}`); | ||
| 30 | console.log(`========================================\n`); | ||
| 31 | |||
| 32 | // ===== Pre-flight: wait for board to be ready ===== | ||
| 33 | console.log('--- Pre-flight: Board Readiness ---'); | ||
| 34 | let discovery = null; | ||
| 35 | for (let i = 0; i < 10; i++) { | ||
| 36 | const out = run(`curl -s --connect-timeout 3 ${BASE}/`); | ||
| 37 | if (out) { try { discovery = JSON.parse(out); } catch {} } | ||
| 38 | if (discovery) break; | ||
| 39 | if (i < 9) execSync('sleep 3'); | ||
| 40 | } | ||
| 41 | if (!discovery) { | ||
| 42 | console.log(' FATAL: Board not responding after 10 retries. Aborting.'); | ||
| 43 | process.exit(2); | ||
| 44 | } | ||
| 45 | console.log(' Board is responding!'); | ||
| 46 | |||
| 47 | // ===== BURST FETCH: grab everything in one go ===== | ||
| 48 | console.log(' Burst-fetching all endpoints...'); | ||
| 49 | |||
| 50 | const mintsRaw = run(`curl -s --connect-timeout 5 ${BASE}/mints`); | ||
| 51 | const walletRaw = run(`curl -s --connect-timeout 5 ${BASE}/wallet`); | ||
| 52 | const usageRaw = run(`curl -s --connect-timeout 5 ${BASE}/usage`); | ||
| 53 | const whoamiRaw = run(`curl -s --connect-timeout 5 ${BASE}/whoami`); | ||
| 54 | const portalRaw = run(`curl -s --connect-timeout 10 http://${IP}/`); | ||
| 55 | |||
| 56 | const badTokenRaw = run(`curl -s --connect-timeout 5 -X POST -d "cashuAtest123" ${BASE}/`); | ||
| 57 | const emptyBodyRaw = run(`curl -s --connect-timeout 5 -X POST -d "" ${BASE}/`); | ||
| 58 | const noPrefixRaw = run(`curl -s --connect-timeout 5 -X POST -d "not_a_cashu_token" ${BASE}/`); | ||
| 59 | |||
| 60 | const fakeV3Token = 'cashuA' + Buffer.from(JSON.stringify({ | ||
| 61 | token: [{ mint: 'https://mint.minibits.cash/Bitcoin', proofs: [{ amount: 1, id: 'fake', secret: 'fake', C: 'fake' }] }] | ||
| 62 | })).toString('base64url'); | ||
| 63 | const fakeTokenRaw = run(`curl -s --connect-timeout 5 -X POST -d "${fakeV3Token}" ${BASE}/`); | ||
| 64 | |||
| 65 | const badMintToken = 'cashuA' + Buffer.from(JSON.stringify({ | ||
| 66 | token: [{ mint: 'https://evil-mint.example.com', proofs: [{ amount: 1, id: 'fake', secret: 'fake', C: 'fake' }] }] | ||
| 67 | })).toString('base64url'); | ||
| 68 | const badMintRaw = run(`curl -s --connect-timeout 5 -X POST -d "${badMintToken}" ${BASE}/`); | ||
| 69 | |||
| 70 | let mints = null, wallet = null, usage = null; | ||
| 71 | try { mints = mintsRaw ? JSON.parse(mintsRaw) : null; } catch {} | ||
| 72 | try { wallet = walletRaw ? JSON.parse(walletRaw) : null; } catch {} | ||
| 73 | try { usage = usageRaw ? JSON.parse(usageRaw) : null; } catch {} | ||
| 74 | |||
| 75 | const boardHasInternet = mints && mints.some(m => m.reachable === true); | ||
| 76 | |||
| 77 | console.log(` Got: discovery=${!!discovery} mints=${!!mints} wallet=${!!wallet} usage=${!!usage} whoami=${!!whoamiRaw} portal=${!!portalRaw}`); | ||
| 78 | console.log(''); | ||
| 79 | |||
| 80 | // ===== SECTION 1: Configuration ===== | ||
| 81 | console.log('--- Section 1: Configuration ---'); | ||
| 82 | assert(discovery && discovery.kind === 10021, 'Discovery has kind=10021'); | ||
| 83 | assert(discovery && discovery.tags && discovery.tags.some(t => t[0] === 'metric' && t[1] === 'milliseconds'), 'Metric is milliseconds'); | ||
| 84 | const priceTag = discovery && discovery.tags && discovery.tags.find(t => t[0] === 'price_per_step'); | ||
| 85 | assert(priceTag && priceTag[1] === 'cashu', 'Price tag uses cashu unit'); | ||
| 86 | assert(priceTag && priceTag[2] === '1', 'Price is 1 sat'); | ||
| 87 | assert(priceTag && priceTag[5] === '1', 'Price step count is 1'); | ||
| 88 | |||
| 89 | // ===== SECTION 2: Mint List ===== | ||
| 90 | console.log('\n--- Section 2: Mint List ---'); | ||
| 91 | assert(mints !== null, 'GET /mints returns valid JSON'); | ||
| 92 | assert(Array.isArray(mints), '/mints returns an array'); | ||
| 93 | assert(mints && mints.length === MINTS_EXPECTED.length, `/mints has ${MINTS_EXPECTED.length} entries (got ${mints ? mints.length : 0})`); | ||
| 94 | |||
| 95 | if (mints && mints.length > 0) { | ||
| 96 | for (const expectedUrl of MINTS_EXPECTED) { | ||
| 97 | const found = mints.find(m => m.url === expectedUrl); | ||
| 98 | assert(found !== undefined, `Mint list contains ${expectedUrl}`); | ||
| 99 | if (found) { | ||
| 100 | assert(typeof found.reachable === 'boolean', `${expectedUrl.split('//')[1]} has boolean reachable field`); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | // ===== SECTION 3: Health Status ===== | ||
| 106 | console.log('\n--- Section 3: Health Status ---'); | ||
| 107 | if (!boardHasInternet) { | ||
| 108 | skip('Mint reachability probes', 'Board has no internet'); | ||
| 109 | skip('Reachable mint transitions', 'Board has no internet'); | ||
| 110 | if (mints && mints.length > 0) { | ||
| 111 | const allUnreachable = mints.every(m => m.reachable === false); | ||
| 112 | assert(allUnreachable, 'All mints show reachable=false without internet'); | ||
| 113 | } | ||
| 114 | } else { | ||
| 115 | const reachableMints = mints.filter(m => m.reachable); | ||
| 116 | console.log(` Reachable: ${reachableMints.length}/${mints.length}`); | ||
| 117 | assert(reachableMints.length > 0, `At least 1 mint is reachable`); | ||
| 118 | for (const m of reachableMints) console.log(` \u2713 ${m.url}`); | ||
| 119 | for (const m of mints.filter(m => !m.reachable)) console.log(` \u2717 ${m.url}`); | ||
| 120 | } | ||
| 121 | |||
| 122 | // ===== SECTION 4: Payment Routing ===== | ||
| 123 | console.log('\n--- Section 4: Payment Routing ---'); | ||
| 124 | assert(badTokenRaw !== null, 'POST / with bad token returns response'); | ||
| 125 | assert(badTokenRaw && badTokenRaw.includes('payment-error-invalid'), 'Bad token rejected with payment-error-invalid'); | ||
| 126 | assert(emptyBodyRaw && emptyBodyRaw.includes('payment-error-invalid'), 'Empty body rejected'); | ||
| 127 | assert(noPrefixRaw && noPrefixRaw.includes('payment-error-invalid'), 'Non-cashu body rejected'); | ||
| 128 | |||
| 129 | if (fakeTokenRaw) { | ||
| 130 | try { | ||
| 131 | const parsed = JSON.parse(fakeTokenRaw); | ||
| 132 | if (parsed.tags && parsed.tags.some(t => t[0] === 'code')) { | ||
| 133 | const code = parsed.tags.find(t => t[0] === 'code')[1]; | ||
| 134 | if (boardHasInternet) { | ||
| 135 | assert(code === 'payment-error-verification' || code === 'payment-error-token-spent', | ||
| 136 | 'Fake V3 token rejected by mint verification'); | ||
| 137 | } else { | ||
| 138 | assert(code === 'payment-error-mint-not-accepted' || code === 'payment-error-verification', | ||
| 139 | 'Fake V3 token rejected (unreachable or verification failed)'); | ||
| 140 | } | ||
| 141 | } else { skip('Fake V3 token code check', 'Unexpected response format'); } | ||
| 142 | } catch { skip('Fake V3 token parse', 'Non-JSON response'); } | ||
| 143 | } | ||
| 144 | |||
| 145 | assert(badMintRaw && badMintRaw.includes('payment-error-mint-not-accepted'), | ||
| 146 | 'Token from non-accepted mint rejected'); | ||
| 147 | |||
| 148 | // ===== SECTION 5: Wallet Status ===== | ||
| 149 | console.log('\n--- Section 5: Wallet Status ---'); | ||
| 150 | assert(wallet !== null, 'GET /wallet returns valid JSON'); | ||
| 151 | assert(wallet && typeof wallet.balance === 'number', 'Wallet has balance field'); | ||
| 152 | assert(wallet && typeof wallet.proof_count === 'number', 'Wallet has proof_count field'); | ||
| 153 | assert(wallet && Array.isArray(wallet.proofs), 'Wallet has proofs array'); | ||
| 154 | assert(wallet && wallet.balance >= 0, 'Balance is non-negative'); | ||
| 155 | assert(wallet && wallet.proof_count >= 0, 'Proof count is non-negative'); | ||
| 156 | |||
| 157 | // ===== SECTION 6: Session / Usage ===== | ||
| 158 | console.log('\n--- Section 6: Session / Usage ---'); | ||
| 159 | assert(usage !== null, 'GET /usage returns valid JSON'); | ||
| 160 | assert(whoamiRaw !== null, 'GET /whoami returns response'); | ||
| 161 | assert(whoamiRaw && whoamiRaw.includes('mac='), '/whoami returns mac=...'); | ||
| 162 | |||
| 163 | // ===== SECTION 7: Dynamic Mint Status ===== | ||
| 164 | console.log('\n--- Section 7: Dynamic Mint Status Transitions ---'); | ||
| 165 | if (!boardHasInternet) { | ||
| 166 | skip('Reachable->unreachable transition', 'No internet'); | ||
| 167 | skip('Unreachable->reachable recovery', 'No internet'); | ||
| 168 | skip('Mint status callback triggers', 'No internet'); | ||
| 169 | skip('Payment rejection for unreachable mints', 'No internet'); | ||
| 170 | } else { | ||
| 171 | console.log(' Board has internet. Checking health probe results...'); | ||
| 172 | console.log(' (Waiting 60s for probe cycle would exceed board uptime; skipping dynamic test)'); | ||
| 173 | skip('Dynamic transition test', 'Board uptime too short for 300s probe interval'); | ||
| 174 | } | ||
| 175 | |||
| 176 | // ===== SECTION 8: Portal Multi-Mint UI ===== | ||
| 177 | console.log('\n--- Section 8: Portal Multi-Mint UI ---'); | ||
| 178 | assert(portalRaw && portalRaw.includes('TollGate'), 'Portal HTML contains TollGate'); | ||
| 179 | assert(portalRaw && (portalRaw.includes('SUPPORTED MINTS') || portalRaw.includes('mint-list')), 'Portal has mint list section'); | ||
| 180 | |||
| 181 | for (const mintUrl of MINTS_EXPECTED) { | ||
| 182 | const shortUrl = mintUrl.replace('https://', ''); | ||
| 183 | assert(portalRaw && portalRaw.includes(shortUrl), `Portal lists ${shortUrl}`); | ||
| 184 | } | ||
| 185 | |||
| 186 | assert(portalRaw && portalRaw.includes('mint-dot'), 'Portal has mint status dots'); | ||
| 187 | assert(portalRaw && portalRaw.includes(':2121/mints'), 'Portal JS fetches mints from API server'); | ||
| 188 | |||
| 189 | // ===== Summary ===== | ||
| 190 | console.log(`\n========================================`); | ||
| 191 | console.log(` Results: ${passed} passed, ${failed} failed, ${skipped} skipped`); | ||
| 192 | console.log(`========================================\n`); | ||
| 193 | process.exit(failed > 0 ? 1 : 0); | ||