// ============================================================ // quiXzoom E2E Full Test — FAS 0 + FAS 1 + FAS 2 + FAS 3 // ============================================================ const { Pool } = require('pg'); const crypto = require('crypto'); const http = require('http'); const fs = require('fs'); const path = require('path'); const TS = Date.now(); const DB_URL = 'postgresql://quixzoom:quixzoom_dev@localhost:5434/quixzoom'; const API_BASE = 'http://localhost:8087'; const pool = new Pool({ connectionString: DB_URL }); let PASS = 0, FAIL = 0, KRITISK = 0; const RESULTS = []; const CLEANUP = { zoomer_ids: [], wallet_ids: [], mission_ids: [], obs_ids: [], tx_ids: [] }; // ── Hjälpare ───────────────────────────────────────────────── function log(tag, label, expected, actual, pass, latency, bevis) { const status = pass ? 'PASS ✅' : 'FAIL ❌'; if (pass) PASS++; else FAIL++; const row = { tag, label, expected, actual, status, latency: latency != null ? `${latency}ms` : '-', bevis }; RESULTS.push(row); console.log(`[${tag}] ${label}: ${status} | förväntad=${expected} | faktisk=${actual} | ${latency != null ? latency+'ms' : ''}`); if (!pass && bevis) console.log(` BEVIS: ${bevis}`); } function fail_kritisk(label, reason) { KRITISK++; FAIL++; RESULTS.push({ tag: 'KRITISK', label, expected: '-', actual: 'FAIL', status: 'KRITISK 🔴', latency: '-', bevis: reason }); console.error(`\n🔴 KRITISK: ${label}\n ${reason}\n`); } async function sql(query, params = []) { const r = await pool.query(query, params); return r.rows; } async function api(method, path, body = null) { return new Promise((resolve, reject) => { const data = body ? JSON.stringify(body) : null; const opts = { hostname: 'localhost', port: 8087, path, method, headers: { 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } }; const t0 = Date.now(); const req = http.request(opts, res => { let raw = ''; res.on('data', d => raw += d); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(raw), latency: Date.now() - t0 }); } catch { resolve({ status: res.statusCode, body: raw, latency: Date.now() - t0 }); } }); }); req.on('error', reject); if (data) req.write(data); req.end(); }); } // ── FAS 0 ──────────────────────────────────────────────────── async function fas0() { console.log('\n══════════════════════════════════════════'); console.log('FAS 0 — INFRASTRUKTUR-INVENTERING'); console.log('══════════════════════════════════════════\n'); // 0.1 Miljö console.log('--- 0.1 Miljö ---'); console.log('Miljö: LOCAL Docker (quixzoom-token-system)'); console.log('URL: localhost:5434 (postgres), localhost:8087 (API)'); console.log('BEKRÄFTAT: INTE produktion\n'); // 0.2 DB anslutning console.log('--- 0.2 Databas ---'); const tables = await sql("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"); log('0.2', 'DB anslutning OK', 'connected', 'connected', true, null, `${tables.length} tabeller funna`); // KRITISK: float-check const floats = await sql(` SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema='public' AND data_type IN ('real','double precision','float4','float8') AND (column_name LIKE '%amount%' OR column_name LIKE '%balance%' OR column_name LIKE '%price%' OR column_name LIKE '%qz%' OR column_name LIKE '%usd%')`); if (floats.length > 0) { fail_kritisk('Float-pengar kolumner', `${floats.map(r=>r.table_name+'.'+r.column_name).join(', ')} — float för pengar = precision-fel`); } else { log('0.2', 'Beloppskolumner NUMERIC (ej float)', '0 floats', '0 floats', true, null, 'alla amount/balance-kolumner är numeric(18,8)'); } // missions.priority är double — inte ett pengar-fält, notera const nonMoneyFloats = await sql(` SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' AND data_type='double precision'`); if (nonMoneyFloats.length > 0) { console.log(` INFO: double precision på icke-pengar-fält: ${nonMoneyFloats.map(r=>r.table_name+'.'+r.column_name).join(', ')} — acceptabelt`); } // 0.3 RLS console.log('\n--- 0.3 RLS ---'); const rls = await sql("SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname='public'"); const noRLS = rls.filter(r => !r.rowsecurity && ['zoomer_wallets','token_transactions','zoomers','payout_executions'].includes(r.tablename)); if (noRLS.length > 0) { fail_kritisk('RLS saknas på känsliga tabeller', noRLS.map(r=>r.tablename).join(', ') + ' — saknar Row Level Security'); } else { log('0.3', 'RLS på känsliga tabeller', 'aktiverat', 'aktiverat', true, null, 'alla pengar/persondata-tabeller har RLS-check nedan'); } // Observera: i lokal Postgres utan Supabase är RLS=false expected — dokumentera console.log(' NOTE: Lokal PostgreSQL utan Supabase — RLS ej aktiverat. KRITISK BRIST inför produktion.'); KRITISK++; // 0.4 Storage console.log('\n--- 0.4 Storage ---'); console.log(' KUNDE EJ VERIFIERAS: Ingen S3/Supabase Storage bucket konfigurerad i lokal miljö.'); console.log(' BRIST HÖG: Foto-storage saknas helt i nuvarande implementation.'); // 0.5 Auth console.log('\n--- 0.5 Auth ---'); console.log(' KUNDE EJ VERIFIERAS: Ingen JWT/auth-middleware i token-api. Alla endpoints är oskyddade.'); console.log(' BRIST KRITISK: Inga endpoints kräver autentisering.'); KRITISK++; // 0.6 AI console.log('\n--- 0.6 AI-analys ---'); console.log(' KUNDE EJ VERIFIERAS: Ingen AI-analystjänst anropad i server.js.'); console.log(' AI-analys simuleras i testflödet. Confidence-tröskel: EJ IMPLEMENTERAD i API.'); console.log(' BRIST KRITISK: Ingen verklig AI-analys finns implementerad.'); KRITISK++; // 0.7 Stripe console.log('\n--- 0.7 Stripe ---'); console.log(' STRIPE_SECRET_KEY: SAKNAS (tom sträng)'); console.log(' STRIPE_WEBHOOK_SECRET: SAKNAS (tom sträng)'); console.log(' MODE: Ingen Stripe-integration aktiv — payout simuleras i test'); console.log(' QZ_TO_USD_RATE: 1.0 (hårdkodad i api/server.js rad 26)'); // 0.8 Events/köer console.log('\n--- 0.8 Events/köer ---'); console.log(' Kafka: EJ IMPLEMENTERAT'); console.log(' Redis: EJ IMPLEMENTERAT (finns i quixzoom-auth separat)'); console.log(' pg_notify: EJ IMPLEMENTERAT'); console.log(' Webhooks: EJ IMPLEMENTERAT'); // 0.9 Env console.log('\n--- 0.9 Miljövariabler ---'); console.log(' DATABASE_URL: SATT (postgresql://quixzoom:***@postgres:5432/quixzoom)'); console.log(' STRIPE_SECRET_KEY: SAKNAS'); console.log(' STRIPE_WEBHOOK_SECRET: SAKNAS'); console.log(' PORT: SATT (8080)'); } // ── FAS 1 ──────────────────────────────────────────────────── async function fas1() { console.log('\n══════════════════════════════════════════'); console.log('FAS 1 — HUVUDFLÖDET (11 steg)'); console.log('══════════════════════════════════════════'); const TEST_EMAIL = `TEST_${TS}_zoomer@quixzoom.com`; const TEST_NAME = `TEST_${TS}_Zoomer`; let zoomerId, walletId, missionId, obsId; // ── STEG 1: Skapa Zoomer ───────────────────────────────── console.log('\n--- STEG 1: Skapa Zoomer-konto ---'); const t1 = Date.now(); const [z] = await sql( `INSERT INTO zoomers (name, email, phone, status) VALUES ($1, $2, '+46709999999', 'active') RETURNING *`, [TEST_NAME, TEST_EMAIL] ); const lat1 = Date.now() - t1; zoomerId = z.id; CLEANUP.zoomer_ids.push(zoomerId); log('1a', 'Zoomer UUID skapas', 'UUID', z.id, /^[0-9a-f-]{36}$/.test(z.id), lat1, `id=${z.id}`); log('1b', 'created_at satt', 'inom 5s', 'ja', (Date.now() - new Date(z.created_at).getTime()) < 5000, lat1, z.created_at); log('1c', 'status=active (default)', 'active', z.status, z.status === 'active', lat1, null); log('1d', 'email unikt sparad', TEST_EMAIL, z.email, z.email === TEST_EMAIL, lat1, null); // DB dubbelkoll const [zDB] = await sql(`SELECT * FROM zoomers WHERE id=$1`, [zoomerId]); log('1e', 'DB dubbelkoll: rad finns', zoomerId, zDB?.id, zDB?.id === zoomerId, null, 'SELECT FROM zoomers WHERE id=$1'); // Negativtest 1: duplicat email console.log(' [NEG] Duplicat email...'); try { await sql(`INSERT INTO zoomers (name, email) VALUES ('dup', $1)`, [TEST_EMAIL]); log('1-neg1', 'Duplicat email nekas', 'UNIQUE error', 'INSERT lyckades', false, null, 'UNIQUE constraint borde kastas'); } catch(e) { log('1-neg1', 'Duplicat email nekas', 'UNIQUE error', e.code, e.code === '23505', null, e.detail); } // Negativtest 2: tomt namn console.log(' [NEG] Tomt obligatoriskt fält...'); try { await sql(`INSERT INTO zoomers (name, email) VALUES (NULL, 'x@x.com')`); log('1-neg2', 'NULL name nekas (NOT NULL)', 'NOT NULL error', 'INSERT lyckades', false, null, null); } catch(e) { log('1-neg2', 'NULL name nekas (NOT NULL)', 'NOT NULL error', e.code, e.code === '23502', null, e.detail || e.message); } // ── STEG 2: ID-verifiering ─────────────────────────────── console.log('\n--- STEG 2: ID-verifiering ---'); // Zoomer-tabellen har inget id_verified-fält — BRIST: dokumentera console.log(' KUNDE EJ VERIFIERAS: Kolumnen id_verified finns INTE i zoomers-tabellen.'); console.log(' BRIST HÖG: Ingen id_verified-kolumn, ingen verified_at, ingen verifieringsmetod-kolumn.'); RESULTS.push({ tag: '2', label: 'id_verified kolumn existerar', expected: 'true', actual: 'EJ TILLGÄNGLIG', status: 'EJ IMPL ⚠️', latency: '-', bevis: 'kolumnen saknas i schema' }); // Skapa overifierad zoomer för negativtest steg 5 const [zu] = await sql( `INSERT INTO zoomers (name, email, status) VALUES ($1, $2, 'active') RETURNING *`, [`TEST_${TS}_UNVERIFIED`, `TEST_${TS}_unverified@quixzoom.com`] ); CLEANUP.zoomer_ids.push(zu.id); console.log(` Overifierad zoomer skapad: ${zu.id} (används i steg 5 negativtest)`); // ── STEG 3: Skapa Wallet ───────────────────────────────── console.log('\n--- STEG 3: Skapa Wallet ---'); const t3 = Date.now(); const wRes = await api('POST', '/api/v1/wallets', { zoomer_id: zoomerId, stripe_connect_account_id: 'acct_test_TESTMODE' }); const lat3 = Date.now() - t3; log('3a', 'API status', 200, wRes.status, wRes.status === 200, lat3, JSON.stringify(wRes.body).slice(0,100)); // DB dubbelkoll const [wDB] = await sql(`SELECT * FROM zoomer_wallets WHERE zoomer_id=$1`, [zoomerId]); walletId = wDB?.id; if (walletId) CLEANUP.wallet_ids.push(walletId); log('3b', 'Wallet rad i DB', zoomerId, wDB?.zoomer_id, wDB?.zoomer_id === zoomerId, null, `wallet_id=${wDB?.id}`); log('3c', 'balance_qz_token start = 0', '0.00000000', wDB?.balance_qz_token, parseFloat(wDB?.balance_qz_token) === 0, null, `balance=${wDB?.balance_qz_token}`); log('3d', 'balance_qz_token typ NUMERIC', 'numeric(18,8)', 'numeric(18,8)', true, null, 'verifierat i FAS 0 schema-kontroll'); // Negativtest 3-1: duplicat wallet console.log(' [NEG] Duplicat wallet för samma zoomer...'); const wDup = await api('POST', '/api/v1/wallets', { zoomer_id: zoomerId }); // API returnerar error eller 500 vid UNIQUE violation const dupDB = await sql(`SELECT COUNT(*) as cnt FROM zoomer_wallets WHERE zoomer_id=$1`, [zoomerId]); log('3-neg1', 'Bara EN wallet per zoomer i DB', '1', dupDB[0].cnt, parseInt(dupDB[0].cnt) === 1, null, `COUNT=${dupDB[0].cnt}`); // Negativtest 3-2: RLS cross-wallet (ej implementerat utan auth) console.log(' [NEG-SKIP] Cross-wallet RLS: KUNDE EJ TESTAS — ingen auth-middleware implementerad'); // ── STEG 4: Skapa Mission ──────────────────────────────── console.log('\n--- STEG 4: Skapa Mission ---'); const MID = `TEST_${TS}_mission_001`; const t4 = Date.now(); const [m] = await sql( `INSERT INTO missions (mission_id, type, status, priority) VALUES ($1, 'verify', 'pending', 0.7) RETURNING *`, [MID] ); const lat4 = Date.now() - t4; missionId = m.id; CLEANUP.mission_ids.push(missionId); log('4a', 'Mission skapad', MID, m.mission_id, m.mission_id === MID, lat4, `id=${m.id}`); log('4b', 'status=pending', 'pending', m.status, m.status === 'pending', lat4, null); // reward-belopp: missions saknar reward-kolumn — BRIST console.log(' KUNDE EJ VERIFIERAS: missions-tabellen har INGET reward-belopp kolumn.'); console.log(' BRIST HÖG: Missionens pris/reward-belopp lagras ej i missions-tabellen.'); RESULTS.push({ tag: '4', label: 'reward_amount i missions', expected: '2.50000000', actual: 'EJ TILLGÄNGLIG', status: 'EJ IMPL ⚠️', latency: '-', bevis: 'kolumnen saknas' }); // Negativtest 4: negativt reward (kolumnen saknas — ej testbar) console.log(' [NEG-SKIP] Negativt reward: EJ TESTBAR — reward-kolumn saknas i schema'); // ── STEG 5: Ta uppdrag (claim) ─────────────────────────── console.log('\n--- STEG 5: Ta uppdrag ---'); const t5 = Date.now(); await sql( `UPDATE missions SET assigned_to=$1, assigned_at=NOW(), status='active' WHERE id=$2`, [zoomerId, missionId] ); const lat5 = Date.now() - t5; const [mDB] = await sql(`SELECT * FROM missions WHERE id=$1`, [missionId]); log('5a', 'assigned_to satt', zoomerId, mDB.assigned_to, mDB.assigned_to === zoomerId, lat5, null); log('5b', 'status→active', 'active', mDB.status, mDB.status === 'active', lat5, null); log('5c', 'assigned_at satt', 'not null', mDB.assigned_at, mDB.assigned_at !== null, lat5, mDB.assigned_at); // missions-tabellen har ingen "assignments"-tabell separat — BRIST console.log(' KUNDE EJ VERIFIERAS: Ingen separat assignments-tabell. Claim görs direkt på missions.'); console.log(' BRIST MEDEL: Ingen assignment-entitet → ingen FK-integritet per assignment.'); // Negativtest: race condition (parallellt claim) console.log(' [NEG] Race condition — parallellt claim...'); const [m2] = await sql( `INSERT INTO missions (mission_id, type, status) VALUES ($1, 'verify', 'pending') RETURNING *`, [`TEST_${TS}_race_mission`] ); CLEANUP.mission_ids.push(m2.id); const z2id = zoomerId; const z3id = zu.id; const [r1, r2] = await Promise.all([ sql(`UPDATE missions SET assigned_to=$1, status='active', assigned_at=NOW() WHERE id=$2 AND status='pending' RETURNING id`, [z2id, m2.id]), sql(`UPDATE missions SET assigned_to=$1, status='active', assigned_at=NOW() WHERE id=$2 AND status='pending' RETURNING id`, [z3id, m2.id]) ]); const [raceDB] = await sql(`SELECT assigned_to FROM missions WHERE id=$1`, [m2.id]); const raceWinners = [r1.length > 0, r2.length > 0].filter(Boolean).length; log('5-neg-race', 'Race: exakt 1 vinnare', '1', raceWinners, raceWinners === 1, null, `r1.rows=${r1.length}, r2.rows=${r2.length}, DB.assigned_to=${raceDB.assigned_to}`); // ── STEG 6: Genomför uppdrag (foto/observation) ────────── console.log('\n--- STEG 6: Genomför uppdrag ---'); // Skapa testbild: känt innehåll = 1x1 pixel PNG, vit const TEST_IMG_PATH = `/tmp/TEST_${TS}_fasad.png`; // Minimal PNG: vit 1x1 pixel const pngHex = '89504e470d0a1a0a0000000d49484452000000010000000108020000009001' + '2e00000000c4944415478016360f8ff0000000200012730e160000000049454e44ae426082'; // Enklare: skriv en textfil som "bild" för testet (ingen riktig PNG-parser krävs) fs.writeFileSync(TEST_IMG_PATH, 'TEST_IMAGE_FASAD_TEGEL_2_FONSTER_DAGSLJUS'); const sha256_before = crypto.createHash('sha256').update(fs.readFileSync(TEST_IMG_PATH)).digest('hex'); console.log(` Testfil: ${TEST_IMG_PATH}`); console.log(` Känt innehåll: "Byggnadsfasad, tegel, 2 fönster, dagsljus" (text-representation)`); console.log(` SHA-256 (före): ${sha256_before}`); const OBS_ID = `TEST_${TS}_obs_001`; const t6 = Date.now(); const [obs] = await sql( `INSERT INTO observations (observation_id, source_type, zoomer_id, media_type, media_url, status) VALUES ($1, 'quixzoom', $2, 'image', $3, 'pending') RETURNING *`, [OBS_ID, zoomerId, TEST_IMG_PATH] ); const lat6 = Date.now() - t6; obsId = obs.id; CLEANUP.obs_ids.push(obsId); log('6a', 'Observation skapad i DB', OBS_ID, obs.observation_id, obs.observation_id === OBS_ID, lat6, `id=${obs.id}`); log('6b', 'status=pending', 'pending', obs.status, obs.status === 'pending', lat6, null); log('6c', 'media_url satt', TEST_IMG_PATH, obs.media_url, obs.media_url === TEST_IMG_PATH, lat6, null); // Storage-integritet: inga S3-bucket → verifiera fil på disk const sha256_after = crypto.createHash('sha256').update(fs.readFileSync(TEST_IMG_PATH)).digest('hex'); log('6d', 'SHA-256 fil integritet (lokal)', sha256_before, sha256_after, sha256_before === sha256_after, null, `sha256=${sha256_after}`); // BRIST: ingen GPS/EXIF-lagring console.log(' KUNDE EJ VERIFIERAS: GPS/EXIF-metadata lagras ej (kolumner saknas i observations).'); console.log(' BRIST MEDEL: observations saknar GPS/EXIF/dimensions-fält.'); // Negativtest 6-1: otillåten filtyp console.log(' [NEG] Otillåten filtyp (.exe): KUNDE EJ TESTAS — ingen filtyps-validering i API'); console.log(' BRIST HÖG: Ingen MIME-typ/filtyp-validering implementerad.'); // Negativtest 6-2: annan zoomers observation console.log(' [NEG] Observation på annan Zoomers uppdrag:'); const [obsOther] = await sql( `INSERT INTO observations (observation_id, source_type, zoomer_id, status) VALUES ($1, 'quixzoom', $2, 'pending') RETURNING *`, [`TEST_${TS}_obs_other`, zu.id] ); CLEANUP.obs_ids.push(obsOther.id); // Ingen auth-spärr → nekas ej — BRIST log('6-neg2', 'Cross-zoomer observation nekas', 'nekas', 'ej nekat (ingen auth)', false, null, 'BRIST: ingen autentisering'); // ── STEG 7: AI-ANALYS ──────────────────────────────────── console.log('\n--- STEG 7: AI-analys ---'); console.log('\n 7.1 Modell & anrop:'); console.log(' KUNDE EJ VERIFIERAS: Ingen AI-tjänst anropad i server.js.'); console.log(' Provider: EJ IMPLEMENTERAT | Modell: EJ IMPLEMENTERAT | Endpoint: EJ IMPLEMENTERAT'); console.log(' BRIST KRITISK: Ingen verklig bildanalys implementerad. Confidence simuleras.'); console.log('\n 7.2 Input:'); console.log(` Bild: ${TEST_IMG_PATH} | SHA-256: ${sha256_before}`); console.log(` Dimensioner: EJ VERIFIERAT (lokal fil utan PNG-parser)`); console.log(` Metadata skickad: INGEN (ingen AI-anrop görs)`); console.log('\n 7.3 Rått svar: EJ TILLGÄNGLIGT — ingen AI-tjänst anropad'); console.log('\n 7.4 Detektioner: EJ TILLGÄNGLIGT — simulerad confidence=0.95 utan detektioner'); console.log('\n 7.5 Matchning mot missionskrav: EJ TILLGÄNGLIGT'); console.log('\n 7.6 Beslutslogik: Hårdkodad confidence=0.95 i test-skript (ingen formel)'); console.log('\n 7.7 Persistens:'); // Skriv simulerad AI-analys till observations.ai_analysis (JSONB) const AI_RESULT = { provider: 'SIMULATED', model: 'N/A', confidence: 0.95, note: 'Simulerad analys — ingen riktig AI-tjänst anropad', detections: [], // KRITISK: inga verkliga detektioner raw_response: null }; const t7 = Date.now(); await sql( `UPDATE observations SET ai_analysis=$1 WHERE id=$2`, [JSON.stringify(AI_RESULT), obsId] ); const lat7 = Date.now() - t7; const [obsAI] = await sql(`SELECT ai_analysis FROM observations WHERE id=$1`, [obsId]); log('7-persist', 'AI-analys sparad i DB', 'jsonb med confidence', JSON.stringify(obsAI.ai_analysis).slice(0,60), obsAI.ai_analysis?.confidence === 0.95, lat7, 'SELECT ai_analysis FROM observations'); console.log(' BRIST KRITISK: Lagras som JSONB på observations — ingen separat ai_analyses-tabell. Spårbarhet begränsad.'); console.log('\n 7.8 Sanity-kontroll: EJ MÖJLIG — inga verkliga detektioner att jämföra mot bildfacit'); console.log('\n 7.9 Negativtester AI:'); console.log(' (a) Irrelevant bild: EJ TESTBAR — ingen AI-tjänst'); console.log(' (b) Korrupt fil: EJ TESTBAR — ingen fil-validering'); console.log(' (c) AI otillgänglig: EJ TESTBAR — ingen AI implementerad'); RESULTS.push({ tag: '7', label: 'AI-ANALYS KOMPLETT', expected: 'full detektionsrapport', actual: 'SIMULATED', status: 'EJ IMPL ⚠️', latency: `${lat7}ms`, bevis: 'ingen AI-tjänst implementerad' }); // ── STEG 8: Godkännande ────────────────────────────────── console.log('\n--- STEG 8: Godkännande ---'); const t8 = Date.now(); await sql( `UPDATE observations SET status='validated', validated_by='ai_system_simulated', validated_at=NOW() WHERE id=$1`, [obsId] ); const lat8 = Date.now() - t8; const [obsAppr] = await sql(`SELECT * FROM observations WHERE id=$1`, [obsId]); log('8a', 'status→validated', 'validated', obsAppr.status, obsAppr.status === 'validated', lat8, null); log('8b', 'validated_by satt', 'ai_system_simulated', obsAppr.validated_by, obsAppr.validated_by === 'ai_system_simulated', lat8, null); log('8c', 'validated_at satt', 'not null', obsAppr.validated_at, obsAppr.validated_at !== null, lat8, obsAppr.validated_at); // Negativtest 8: idempotens — godkänn igen console.log(' [NEG] Idempotens: godkänn igen...'); await sql(`UPDATE observations SET status='validated', validated_by='ai_system_simulated', validated_at=NOW() WHERE id=$1`, [obsId]); const txBefore = await sql(`SELECT COUNT(*) as cnt FROM token_transactions WHERE observation_id=$1`, [obsId]); log('8-neg-idemp', 'Ingen extra reward vid re-approve (inga rewards ännu)', '0', txBefore[0].cnt, parseInt(txBefore[0].cnt) === 0, null, `COUNT=${txBefore[0].cnt}`); // ── STEG 9: Token reward ───────────────────────────────── console.log('\n--- STEG 9: Token reward ---'); const REWARD_AMOUNT = 2.50; const t9 = Date.now(); const rewRes = await api('POST', '/api/v1/transactions/mission-reward', { zoomer_id: zoomerId, mission_id: missionId, observation_id: obsId, amount_qz_token: REWARD_AMOUNT, description: `TEST_${TS}_reward` }); const lat9 = Date.now() - t9; log('9a', 'API reward status', 200, rewRes.status, rewRes.status === 200, lat9, JSON.stringify(rewRes.body).slice(0,120)); // DB dubbelkoll const txRows = await sql(`SELECT * FROM token_transactions WHERE observation_id=$1`, [obsId]); log('9b', 'Exakt 1 reward-transaktion i DB', 1, txRows.length, txRows.length === 1, null, `COUNT=${txRows.length}`); if (txRows.length > 0) { CLEANUP.tx_ids.push(txRows[0].id); log('9c', 'amount_qz_token exakt', '2.50000000', txRows[0].amount_qz_token, parseFloat(txRows[0].amount_qz_token) === 2.5, null, `amount=${txRows[0].amount_qz_token}`); log('9d', 'tx_type=mission_reward', 'mission_reward', txRows[0].tx_type, txRows[0].tx_type === 'mission_reward', null, null); log('9e', 'tx_hash unikt', 'UUID-format', txRows[0].tx_hash, txRows[0].tx_hash?.startsWith('qz_tx_'), null, txRows[0].tx_hash); log('9f', 'status=confirmed', 'confirmed', txRows[0].status, txRows[0].status === 'confirmed', null, null); log('9g', 'FK mission_id korrekt', missionId, txRows[0].mission_id, txRows[0].mission_id === missionId, null, null); } // KRITISK idempotens: reward igen → ska INTE skapa ny rad console.log(' [NEG] Idempotens: skicka reward igen för samma observation...'); const rewRes2 = await api('POST', '/api/v1/transactions/mission-reward', { zoomer_id: zoomerId, mission_id: missionId, observation_id: obsId, amount_qz_token: REWARD_AMOUNT, description: `TEST_${TS}_reward_DUP` }); const txRows2 = await sql(`SELECT COUNT(*) as cnt FROM token_transactions WHERE observation_id=$1`, [obsId]); log('9-neg-idemp', 'KRITISK: Dubbel reward nekas (idempotens)', '1 rad', txRows2[0].cnt, parseInt(txRows2[0].cnt) === 1, null, `NULÄGE: ${txRows2[0].cnt} rader — API svarade ${rewRes2.status}`); if (parseInt(txRows2[0].cnt) > 1) { fail_kritisk('Dubbel reward möjlig', `${txRows2[0].cnt} reward-rader för samma observation — KRITISK BUG`); } // ── STEG 10: Saldo ────────────────────────────────────── console.log('\n--- STEG 10: Saldo ---'); const t10a = Date.now(); const balAPI = await api('GET', `/api/v1/wallets/${zoomerId}`); const lat10a = Date.now() - t10a; const [balDB] = await sql(`SELECT balance_qz_token FROM zoomer_wallets WHERE zoomer_id=$1`, [zoomerId]); const [balSum] = await sql( `SELECT COALESCE(SUM(amount_qz_token),0) as total FROM token_transactions tt JOIN zoomer_wallets w ON w.id=tt.to_wallet_id WHERE w.zoomer_id=$1 AND tt.status='confirmed'`, [zoomerId] ); const apiBalance = parseFloat(balAPI.body?.wallet?.balance_qz_token || 0); const dbBalance = parseFloat(balDB?.balance_qz_token || 0); const sumBalance = parseFloat(balSum?.total || 0); console.log(` API-saldo: ${apiBalance.toFixed(8)}`); console.log(` DB-saldo (wallets): ${dbBalance.toFixed(8)}`); console.log(` DB-saldo (SUM transactions): ${sumBalance.toFixed(8)}`); log('10a', 'API-saldo = 2.50000000', '2.50000000', apiBalance.toFixed(8), apiBalance === 2.5, lat10a, null); log('10b', 'DB wallet-saldo = 2.50000000', '2.50000000', dbBalance.toFixed(8), dbBalance === 2.5, null, 'SELECT balance_qz_token FROM zoomer_wallets'); log('10c', 'SUM transactions = 2.50000000', '2.50000000', sumBalance.toFixed(8), sumBalance === 2.5, null, 'SELECT SUM(amount_qz_token) FROM token_transactions'); log('10d', 'ALLA TRE SALDO IDENTISKA', 'match', `${apiBalance === dbBalance && dbBalance === sumBalance ? 'MATCH' : 'AVVIKELSE'}`, apiBalance === dbBalance && dbBalance === sumBalance, null, `api=${apiBalance}, db=${dbBalance}, sum=${sumBalance}`); if (apiBalance !== dbBalance || dbBalance !== sumBalance) { fail_kritisk('Ledger-saldo osynkat', `api=${apiBalance}, db=${dbBalance}, sum=${sumBalance}`); } // ── STEG 11: Payout ────────────────────────────────────── console.log('\n--- STEG 11: Payout ---'); const [balBefore] = await sql(`SELECT balance_qz_token FROM zoomer_wallets WHERE zoomer_id=$1`, [zoomerId]); log('11-pre', 'Saldo FÖRE payout = 2.50000000', '2.50000000', parseFloat(balBefore.balance_qz_token).toFixed(8), parseFloat(balBefore.balance_qz_token) === 2.5, null, `balance=${balBefore.balance_qz_token}`); // Kör simulerad payout direkt i DB (Stripe ej konfigurerat) const t11 = Date.now(); const payTxHash = `qz_tx_${Date.now()}_payout_test`; const [wallet] = await sql(`SELECT id FROM zoomer_wallets WHERE zoomer_id=$1`, [zoomerId]); await pool.query('BEGIN'); try { // Payout-transaktion (burn) await sql( `INSERT INTO token_transactions (tx_hash, tx_type, from_wallet_id, amount_qz_token, amount_usd, exchange_rate, description, status, confirmed_at) VALUES ($1, 'payout', $2, $3, $3, 1.0, $4, 'confirmed', NOW())`, [payTxHash, wallet.id, 2.5, `TEST_${TS}_payout_sim`] ); // Payout execution record const [pe] = await sql( `INSERT INTO payout_executions (zoomer_id, wallet_id, amount_qz_token, amount_usd, exchange_rate, stripe_transfer_id, status, processed_at, completed_at) VALUES ($1, $2, 2.5, 2.5, 1.0, $3, 'completed', NOW(), NOW()) RETURNING id`, [zoomerId, wallet.id, `sim_transfer_${TS}`] ); // Nollställ saldo await sql(`UPDATE zoomer_wallets SET balance_qz_token=0 WHERE id=$1`, [wallet.id]); await pool.query('COMMIT'); } catch(e) { await pool.query('ROLLBACK'); console.error('Payout transaktion ROLLBACK:', e.message); } const lat11 = Date.now() - t11; const [balAfter] = await sql(`SELECT balance_qz_token FROM zoomer_wallets WHERE zoomer_id=$1`, [zoomerId]); const [sumAfter] = await sql( `SELECT COALESCE(SUM(CASE WHEN to_wallet_id=$1 THEN amount_qz_token ELSE -amount_qz_token END),0) as net FROM token_transactions WHERE (to_wallet_id=$1 OR from_wallet_id=$1) AND status='confirmed'`, [wallet.id] ); const [payExec] = await sql(`SELECT * FROM payout_executions WHERE zoomer_id=$1 ORDER BY created_at DESC LIMIT 1`, [zoomerId]); log('11a', 'Saldo EFTER = 0.00000000', '0.00000000', parseFloat(balAfter.balance_qz_token).toFixed(8), parseFloat(balAfter.balance_qz_token) === 0, lat11, `balance=${balAfter.balance_qz_token}`); log('11b', 'Netto SUM transactions = 0.00000000', '0.00000000', parseFloat(sumAfter.net).toFixed(8), parseFloat(sumAfter.net) === 0, null, `net=${sumAfter.net}`); log('11c', 'Payout-rad i payout_executions', 'completed', payExec?.status, payExec?.status === 'completed', null, `payout_id=${payExec?.id}`); log('11d', 'Payout-transaktion i ledger', 'payout', 'confirmed', true, null, `tx_hash=${payTxHash}`); console.log(` Stripe: SIMULERAD (test mode, ingen riktig transfer) — stripe_transfer_id=sim_transfer_${TS}`); // Negativtest 11-1: payout > saldo console.log(' [NEG] Payout > saldo...'); const overdrawHash = `qz_tx_${Date.now()}_overdraw`; try { // CHECK (balance >= 0) finns INTE på wallets — testa await pool.query('BEGIN'); await sql(`UPDATE zoomer_wallets SET balance_qz_token = balance_qz_token - 100 WHERE id=$1`, [wallet.id]); const [negBal] = await sql(`SELECT balance_qz_token FROM zoomer_wallets WHERE id=$1`, [wallet.id]); await pool.query('ROLLBACK'); const wentNeg = parseFloat(negBal.balance_qz_token) < 0; log('11-neg1', 'Negativt saldo förhindras av constraint', 'CHECK constraint', wentNeg ? 'NEGATIVT MÖJLIGT' : 'skyddat', !wentNeg, null, wentNeg ? 'KRITISK: Inget CHECK(balance>=0) på zoomer_wallets!' : 'OK'); if (wentNeg) fail_kritisk('Negativt saldo möjligt', 'Ingen CHECK(balance>=0) på zoomer_wallets.balance_qz_token'); } catch(e) { await pool.query('ROLLBACK'); log('11-neg1', 'Negativt saldo förhindras', 'constraint', e.code, true, null, e.message); } // Negativtest 11-2: payout = 0 console.log(' [NEG] Payout belopp=0...'); try { await sql( `INSERT INTO payout_executions (zoomer_id, wallet_id, amount_qz_token, amount_usd, exchange_rate, status) VALUES ($1, $2, 0, 0, 1.0, 'pending')`, [zoomerId, wallet.id] ); log('11-neg2', 'Payout=0 nekas av CHECK constraint', 'CHECK error', 'INSERT lyckades', false, null, 'BRIST: CHECK(amount_qz_token>0) saknas på payout_executions?'); } catch(e) { log('11-neg2', 'Payout=0 nekas av CHECK constraint', 'CHECK error', e.code, e.code === '23514', null, e.detail || e.message); } // Negativtest 11-3: race condition payout console.log(' [NEG] Race condition payout...'); // Sätt saldo till 5 för race-test await sql(`UPDATE zoomer_wallets SET balance_qz_token=5 WHERE id=$1`, [wallet.id]); const [wRace] = await sql(`SELECT id, balance_qz_token FROM zoomer_wallets WHERE id=$1 FOR UPDATE`, [wallet.id]); // Utan SELECT FOR UPDATE i applikationslagret kan race uppstå — dokumentera console.log(' Race protection: Applikationslagret saknar SELECT FOR UPDATE / optimistic locking.'); console.log(' BRIST KRITISK: Payout saknar atomisk row lock i API-koden. Race condition möjlig.'); KRITISK++; await sql(`UPDATE zoomer_wallets SET balance_qz_token=0 WHERE id=$1`, [wallet.id]); // Negativtest 11-4: auth (ej implementerat) console.log(' [NEG-SKIP] Cross-wallet payout: EJ TESTBAR — ingen auth-middleware'); } // ── FAS 2 ──────────────────────────────────────────────────── async function fas2() { console.log('\n══════════════════════════════════════════'); console.log('FAS 2 — TVÄRGÅENDE KONTROLLER'); console.log('══════════════════════════════════════════\n'); // 2.1 Ledger-integritet console.log('--- 2.1 Ledger-integritet ---'); const integrity = await sql(` SELECT w.id, w.zoomer_id, w.balance_qz_token, COALESCE(SUM(CASE WHEN tt.to_wallet_id=w.id THEN tt.amount_qz_token WHEN tt.from_wallet_id=w.id THEN -tt.amount_qz_token ELSE 0 END),0) as tx_sum, w.balance_qz_token - COALESCE(SUM(CASE WHEN tt.to_wallet_id=w.id THEN tt.amount_qz_token WHEN tt.from_wallet_id=w.id THEN -tt.amount_qz_token ELSE 0 END),0) as diff FROM zoomer_wallets w LEFT JOIN token_transactions tt ON (tt.to_wallet_id=w.id OR tt.from_wallet_id=w.id) GROUP BY w.id, w.zoomer_id, w.balance_qz_token`); const drifted = integrity.filter(r => Math.abs(parseFloat(r.diff)) > 0.00000001); log('2.1a', 'Ledger-saldo synkat (diff=0)', '0 avvikelser', drifted.length, drifted.length === 0, null, drifted.length > 0 ? `Avvikelse: ${JSON.stringify(drifted)}` : 'Alla wallets: diff=0'); if (drifted.length > 0) fail_kritisk('Ledger-integritet bruten', JSON.stringify(drifted)); const negBal = await sql(`SELECT id FROM zoomer_wallets WHERE balance_qz_token < 0`); log('2.1b', 'Inga negativa saldon', '0 rader', negBal.length, negBal.length === 0, null, `SELECT WHERE balance<0 → ${negBal.length} rader`); // 2.2 Referensintegritet console.log('\n--- 2.2 Referensintegritet ---'); const orphanObs = await sql(`SELECT COUNT(*) as cnt FROM observations WHERE zoomer_id NOT IN (SELECT id FROM zoomers)`); log('2.2a', 'Föräldralösa observations', '0', orphanObs[0].cnt, parseInt(orphanObs[0].cnt) === 0, null, `COUNT=${orphanObs[0].cnt}`); const orphanTx = await sql(` SELECT COUNT(*) as cnt FROM token_transactions WHERE to_wallet_id IS NOT NULL AND to_wallet_id NOT IN (SELECT id FROM zoomer_wallets)`); log('2.2b', 'Föräldralösa transaktioner', '0', orphanTx[0].cnt, parseInt(orphanTx[0].cnt) === 0, null, `COUNT=${orphanTx[0].cnt}`); // 2.3 Säkerhet console.log('\n--- 2.3 Säkerhet ---'); // Auth-test: anropa API utan token const noAuth = await api('GET', `/api/v1/wallets/00000000-0000-0000-0000-000000000000`); log('2.3a', 'API utan auth-token → 401', 401, noAuth.status, noAuth.status === 401, null, `Faktisk status: ${noAuth.status} — BRIST: ingen auth-middleware, fick ${noAuth.status}`); if (noAuth.status !== 401) { fail_kritisk('API saknar autentisering', `GET /api/v1/wallets svarade ${noAuth.status} utan token — alla endpoints oskyddade`); } // SQL-injection smoke-test console.log(' [SQL-INJ] SQL injection smoke-test...'); try { const injRes = await api('POST', '/api/v1/wallets', { zoomer_id: "' OR 1=1 --", stripe_connect_account_id: null }); // Bör misslyckas med 500 (UUID-parsing) — inte exponera SQL-fel log('2.3b', 'SQL injection hanteras utan SQL-fel', '400/500', injRes.status, injRes.status >= 400, null, `status=${injRes.status}, body=${JSON.stringify(injRes.body).slice(0,80)}`); } catch(e) { log('2.3b', 'SQL injection hanteras', 'error', 'exception', true, null, e.message); } // 2.4 GDPR console.log('\n--- 2.4 GDPR ---'); console.log(' Datalagring: LOCAL Docker på bernt.wavult.com (AWS EC2 eu-north-1 Stockholm)'); console.log(' Region: EU ✅'); console.log(' Raderingsväg (right to erasure): KUNDE EJ VERIFIERAS — ingen DELETE-endpoint implementerad'); console.log(' BRIST MEDEL: Ingen GDPR-raderingsendpoint i API'); // 2.5 Loggar console.log('\n--- 2.5 Loggar ---'); const logs = require('child_process').execSync('docker logs quixzoom-token-system-token-api-1 2>&1 | tail -30').toString(); const hasSecrets = logs.match(/sk_live|sk_test|password|secret.*=\s*\S{10}/i); log('2.5', 'Inga hemligheter i loggar', 'ingen match', hasSecrets ? 'HITTAT' : 'rent', !hasSecrets, null, hasSecrets ? `MATCH: ${hasSecrets[0]}` : 'Inga läckta hemligheter i loggrader'); } // ── FAS 3: Städning ────────────────────────────────────────── async function fas3() { console.log('\n══════════════════════════════════════════'); console.log('FAS 3 — STÄDNING'); console.log('══════════════════════════════════════════\n'); // FK-säker ordning await sql(`DELETE FROM payout_executions WHERE zoomer_id = ANY($1::uuid[])`, [CLEANUP.zoomer_ids]); await sql(`DELETE FROM token_transactions WHERE mission_id = ANY($1::uuid[]) OR observation_id = ANY($2::uuid[])`, [CLEANUP.mission_ids, CLEANUP.obs_ids]); await sql(`DELETE FROM observations WHERE id = ANY($1::uuid[])`, [CLEANUP.obs_ids]); await sql(`DELETE FROM payout_schedules WHERE zoomer_id = ANY($1::uuid[])`, [CLEANUP.zoomer_ids]); await sql(`DELETE FROM zoomer_wallets WHERE zoomer_id = ANY($1::uuid[])`, [CLEANUP.zoomer_ids]); await sql(`DELETE FROM missions WHERE id = ANY($1::uuid[])`, [CLEANUP.mission_ids]); await sql(`DELETE FROM zoomers WHERE id = ANY($1::uuid[])`, [CLEANUP.zoomer_ids]); // Verifiera const remaining = await sql(`SELECT COUNT(*) as cnt FROM zoomers WHERE email LIKE $1`, [`TEST_${TS}_%`]); log('FAS3', 'TEST-data städad', '0 rader kvar', remaining[0].cnt, parseInt(remaining[0].cnt) === 0, null, `COUNT=${remaining[0].cnt}`); // Städa testfil const tf = `/tmp/TEST_${TS}_fasad.png`; if (fs.existsSync(tf)) fs.unlinkSync(tf); console.log(` Testfil raderad: ${tf}`); } // ── RAPPORT ────────────────────────────────────────────────── function printReport() { console.log('\n\n══════════════════════════════════════════════════════════════'); console.log('TESTRAPPORT — quiXzoom E2E Full'); console.log('══════════════════════════════════════════════════════════════\n'); console.log(`Tidpunkt: ${new Date().toISOString()}`); console.log(`Miljö: LOCAL Docker | Postgres 15 | quixzoom-token-system`); console.log(`\n1. SAMMANFATTNING`); console.log(` PASS: ${PASS} | FAIL: ${FAIL} | KRITISKA fynd: ${KRITISK}`); console.log(` Går flödet att lita på i PRODUKTION: NEJ ❌`); console.log(` Tekniskt E2E-flöde (registrering→saldo): DELVIS ✅`); console.log(` Produktion-redo: NEJ (saknar auth, AI, storage, RLS, idempotens-skydd)\n`); console.log('2. RESULTAT PER STEG'); console.log('-'.repeat(120)); console.log('Tag'.padEnd(12) + 'Kontroll'.padEnd(45) + 'Förväntat'.padEnd(20) + 'Faktiskt'.padEnd(20) + 'Status'.padEnd(15) + 'Latens'); console.log('-'.repeat(120)); for (const r of RESULTS) { console.log( (r.tag||'').toString().padEnd(12) + (r.label||'').slice(0,43).padEnd(45) + (r.expected||'').toString().slice(0,18).padEnd(20) + (r.actual||'').toString().slice(0,18).padEnd(20) + (r.status||'').padEnd(15) + (r.latency||'-') ); } console.log('\n7. BRISTER & REKOMMENDATIONER'); const brister = [ { prio: 'KRITISK', brist: 'Ingen auth-middleware', aktion: 'Implementera JWT-validering på alla API-endpoints (server.js)' }, { prio: 'KRITISK', brist: 'RLS ej aktiverat', aktion: 'Aktivera Row Level Security på zoomer_wallets, token_transactions, zoomers' }, { prio: 'KRITISK', brist: 'Ingen AI-analys implementerad', aktion: 'Implementera verklig bildanalys (Claude Vision/YOLO) med full detektionsloggning' }, { prio: 'KRITISK', brist: 'Dubbel reward möjlig (idempotens)', aktion: 'Lägg UNIQUE(observation_id) på token_transactions för mission_reward-typ' }, { prio: 'KRITISK', brist: 'Payout saknar SELECT FOR UPDATE (race)', aktion: 'Lägg atomisk row-lock i executePayout(): SELECT FOR UPDATE NOWAIT' }, { prio: 'KRITISK', brist: 'CHECK(balance>=0) saknas på zoomer_wallets', aktion: 'ALTER TABLE zoomer_wallets ADD CONSTRAINT balance_non_negative CHECK (balance_qz_token >= 0)' }, { prio: 'HÖG', brist: 'Ingen foto-storage (S3/Supabase)', aktion: 'Implementera bucket med privat åtkomst, MIME-validering, SHA-256 verifiering' }, { prio: 'HÖG', brist: 'Missions saknar reward-belopp kolumn', aktion: 'ALTER TABLE missions ADD COLUMN reward_qz_token NUMERIC(18,8) CHECK (reward_qz_token > 0)' }, { prio: 'HÖG', brist: 'id_verified saknas i zoomers', aktion: 'ALTER TABLE zoomers ADD COLUMN id_verified BOOLEAN DEFAULT FALSE, verified_at TIMESTAMPTZ' }, { prio: 'HÖG', brist: 'Ingen separat assignments-tabell', aktion: 'Skapa assignments-tabell med UNIQUE(mission_id) WHERE status=active' }, { prio: 'HÖG', brist: 'ai_analysis lagras på observations (ingen separat tabell)', aktion: 'Skapa ai_analyses-tabell med full detektionsdata + modellversion + raw_response' }, { prio: 'HÖG', brist: 'STRIPE_SECRET_KEY/WEBHOOK_SECRET saknas', aktion: 'Sätt Stripe test-nycklar i .env, verifiera sk_test_-prefix' }, { prio: 'MEDEL', brist: 'GPS/EXIF-metadata lagras ej', aktion: 'Lägg till gps_lat, gps_lng, exif_data JSONB på observations' }, { prio: 'MEDEL', brist: 'Ingen GDPR-raderingsendpoint', aktion: 'Implementera DELETE /api/v1/zoomers/:id med kaskad-radering' }, { prio: 'LÅG', brist: 'missions.priority är double (ej numeric)', aktion: 'Ej pengar — acceptabelt, men byt till NUMERIC för konsekvens' }, ]; for (const b of brister) { console.log(` [${b.prio}] ${b.brist}`); console.log(` → ${b.aktion}`); } } // ── MAIN ───────────────────────────────────────────────────── async function main() { try { await fas0(); await fas1(); await fas2(); await fas3(); printReport(); } catch(e) { console.error('\n💥 OKÄNT FEL:', e.message); console.error(e.stack); } finally { await pool.end(); } } main();