diff --git a/frontend/App.vue b/frontend/App.vue index a53a4d6a..852c2a75 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -17,6 +17,7 @@ import Alert from './components/widgets/Toast.vue'; import PWA from './components/widgets/PWA.vue'; import { useTheme } from '@/composables/use-theme.js'; import { useAchievementEngine } from '@/composables/use-achievement-engine.js'; +import { useReportCollector } from '@/composables/use-report-collector.js'; // The standalone pages (/tools/:slug, /privacy) carry their own header, so they // drop the homepage's fixed-Nav body padding (see the `body.jn-standalone-page` @@ -65,6 +66,10 @@ useTheme(); // Achievement engine: listens for domain events emitted across the app and // evaluates the rules in data/achievement-rules.js. useAchievementEngine(); + +// Report collector: keeps the latest schema-shaped snapshot of every finished +// test for the shareable diagnostic report. +useReportCollector(); diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index 428ad22b..b2649abe 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -146,6 +146,7 @@ import { useRouter } from 'vue-router'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; +import { emitAppEvent } from '@/utils/app-events'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -326,6 +327,10 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu clearTimeout(timeoutId); const testTime = Math.round(performance.now() - beginTime); test.status = t('connectivity.StatusAvailable'); + // Locale-free twin of the localized status label, consumed by the report + // builder (utils/report-builders.js) — the label itself can't be mapped + // back to an enum without string comparison against t() output. + test.statusCode = 'ok'; test.mintime = test.mintime === 0 ? testTime : Math.min(test.mintime, testTime); test.time = (multipleTests.value && !isManualRun) ? test.mintime : testTime; if (recordRound) test.roundResults.push({ tone: testTime < 200 ? 'ok-fast' : 'ok-slow', time: testTime }); @@ -337,10 +342,12 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu // real 'fail' — it's per-round history, separate from the face/text. if (multipleTests.value && !isManualRun && test.mintime > 0) { test.status = t('connectivity.StatusAvailable'); + test.statusCode = 'ok'; test.time = test.mintime; } else { test.time = 0; test.status = t('connectivity.StatusUnavailable'); + test.statusCode = 'unreachable'; } if (recordRound) test.roundResults.push({ tone: 'fail', time: 0 }); onTestComplete(false); @@ -354,6 +361,7 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => { if (isRefresh) { connectivityTests.forEach((test) => { test.status = t('connectivity.StatusWait'); + test.statusCode = undefined; test.time = 0; }); trackEvent('Section', 'RefreshClick', 'Connectivity'); @@ -381,6 +389,18 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => { updateConnectivityAlert(successCount === totalTests ? 'success' : 'error'); // Sticky flag for the Service Status banner — set once the first pass lands. hasEverSettled.value = true; + // Domain event: snapshot after every pass (multi-round included) — + // latest wins downstream in the report collector. + emitAppEvent('connectivity:finished', { + targets: connectivityTests.map((test) => ({ + id: test.id, + name: test.name, + custom: test.custom === true, + statusCode: test.statusCode, + time: test.time, + mintime: test.mintime, + })), + }); resolve(); }); diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index fa5dad74..99879e92 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -118,6 +118,7 @@ import { useRouter } from 'vue-router'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; +import { emitAppEvent } from '@/utils/app-events'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -255,6 +256,17 @@ const checkAllDNSLeakTest = async (isRefresh) => { store.setLoadingStatus('DNSLeakTest', true); // Local sticky flag for the Enhanced DNS Leak Test banner hasEverSettled.value = true; + // Domain event: snapshot for the report collector (cards whose ip slot + // still holds a wait/error label are dropped by the builder). + emitAppEvent('dnsleak:finished', { + providers: leakTest.map((card) => ({ + id: card.id, + name: card.providerName, + ip: card.ip, + country_code: card.country_code, + org: card.org, + })), + }); }); }; diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue index 41cefd6d..505d924a 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -223,6 +223,20 @@ const trackFetchStatus = (status) => { } if (allHasFetched) { store.setLoadingStatus('IPInfo', true); + // Domain event: full snapshot of the visible cards, re-emitted whenever a + // card settles after this point (single-card refresh included). The report + // collector normalizes it (drops cards whose ip slot holds an error label). + emitAppEvent('ipinfo:finished', { + cards: ipDataCards.slice(0, ipCardsToShow.value).map((card) => ({ + source: card.source, + ip: card.ip, + country_code: card.country_code, + region: card.region, + city: card.city, + asn: card.asn, + isp: card.isp, + })), + }); } }; diff --git a/frontend/components/SpeedTest.vue b/frontend/components/SpeedTest.vue index 1117c9a4..2d2ae955 100644 --- a/frontend/components/SpeedTest.vue +++ b/frontend/components/SpeedTest.vue @@ -452,10 +452,31 @@ const setupTestEngine = async () => { } testEngine = null; - // Achievement rules for speed thresholds live in data/achievement-rules.js. + // Achievement rules for speed thresholds live in data/achievement-rules.js; + // the report collector consumes the full result (unmeasured fields keep + // their '-' placeholder — the builder drops non-numbers). emitAppEvent('speedtest:finished', { downloadSpeed: state.speedTest.downloadSpeed, uploadSpeed: state.speedTest.uploadSpeed, + latency: state.speedTest.latency, + jitter: state.speedTest.jitter, + downLoadedLatency: state.speedTest.downLoadedLatency, + upLoadedLatency: state.speedTest.upLoadedLatency, + scores: state.speedTest.hasScores + ? { + streaming: state.speedTest.streamingScore, + gaming: state.speedTest.gamingScore, + rtc: state.speedTest.rtcScore, + } + : null, + qualities: state.speedTest.hasScores + ? { + streaming: state.speedTest.streamingQuality, + gaming: state.speedTest.gamingQuality, + rtc: state.speedTest.rtcQuality, + } + : null, + connection: { ...state.connection }, }); }; testEngine.onError = (e) => { diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue index fccc4a71..e8da1dc6 100644 --- a/frontend/components/WebRtcTest.vue +++ b/frontend/components/WebRtcTest.vue @@ -120,6 +120,7 @@ import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue' import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; +import { emitAppEvent } from '@/utils/app-events'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -231,6 +232,8 @@ const checkSTUNServer = (stun) => { const label = t(`webrtc.${statusKey}`); stun.ip = label; stun.natType = label; + // Locale-free twin of the label, consumed by the report builder. + stun.natTypeCode = statusKey === 'StatusUnavailable' ? 'unavailable' : 'error'; stun.country = label; stun.country_code = ''; stun.org = label; @@ -249,6 +252,7 @@ const checkSTUNServer = (stun) => { } stun.ip = ip; stun.natType = determineNATType(candidate); + stun.natTypeCode = natTypeCodeOf(candidate); log(`resolved IP: ${ip}`); IPArray.value = [...IPArray.value, { ip, country: '' }]; // useMaxmind swallows its own errors and returns null on miss, so @@ -358,6 +362,27 @@ const determineNATType = (candidate) => { return t('webrtc.NATType.unknown'); }; +// Locale-free twin of determineNATType for the report builder. +const natTypeCodeOf = (candidate) => { + const type = candidate.split(' ')[7]; + return ['host', 'srflx', 'prflx', 'relay'].includes(type) ? type : 'unknown'; +}; + +// Domain event: snapshot of all STUN cards for the report collector (servers +// still waiting carry no natTypeCode and are dropped by the builder). +const emitWebrtcFinished = () => { + emitAppEvent('webrtc:finished', { + servers: stunServers.map((server) => ({ + id: server.id, + url: server.url, + ip: server.ip, + natTypeCode: server.natTypeCode, + country_code: server.country_code, + org: server.org, + })), + }); +}; + // Test all STUN servers const checkAllWebRTC = async (isRefresh) => { if (isRefresh) trackEvent('Section', 'RefreshClick', 'WebRTC'); @@ -371,17 +396,20 @@ const checkAllWebRTC = async (isRefresh) => { stunServers.forEach((server) => { server.ip = label; server.natType = label; + server.natTypeCode = 'unavailable'; server.country = label; server.country_code = ''; server.org = label; }); store.setLoadingStatus('WebRTC', true); + emitWebrtcFinished(); return; } const promises = stunServers.map((server) => { server.ip = t('webrtc.StatusWait'); server.natType = t('webrtc.StatusWait'); + server.natTypeCode = undefined; server.country = t('webrtc.StatusWait'); server.country_code = ''; server.org = t('webrtc.StatusWait'); @@ -392,6 +420,7 @@ const checkAllWebRTC = async (isRefresh) => { const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000)); return Promise.race([allSettledPromise, timeoutPromise]).then(() => { store.setLoadingStatus('WebRTC', true); + emitWebrtcFinished(); }); }; diff --git a/frontend/components/advanced-tools/BrowserInfo.vue b/frontend/components/advanced-tools/BrowserInfo.vue index c980af11..cbcac2e3 100644 --- a/frontend/components/advanced-tools/BrowserInfo.vue +++ b/frontend/components/advanced-tools/BrowserInfo.vue @@ -130,6 +130,7 @@ import { ref, computed, watch, onMounted } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; +import { emitAppEvent } from '@/utils/app-events'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Spinner } from '@/components/ui/spinner'; @@ -351,6 +352,12 @@ const getAll = async () => { checkingStatus.value = 'running'; await Promise.all([getUA(), getFingerPrint(), getGPU(), getOtherBrowserInfo()]); checkingStatus.value = 'finished'; + // Domain event for the report collector. Deliberately excludes the + // fingerprint hash and components blob — they never enter a report. + emitAppEvent('browserinfo:finished', { + userAgent: userAgent.value, + otherInfos: otherInfos.value, + }); } catch (error) { console.error('Error during checks:', error); checkingStatus.value = 'error'; diff --git a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue index 8dbdb6ff..74edb24f 100644 --- a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue +++ b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue @@ -258,6 +258,7 @@ import { ref, reactive, computed } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; +import { emitAppEvent } from '@/utils/app-events'; import { authenticatedFetch } from '@/utils/authenticated-fetch'; import getCountryName from '@/data/country-name.js'; import { Button } from '@/components/ui/button'; @@ -563,6 +564,9 @@ const runTest = async () => { queries: Array.isArray(data.queries) ? data.queries : [], }; status.value = 'done'; + // Domain event: raw query rows for the report collector (the builder + // maps geo sub-objects and derives the DNSSEC posture). + emitAppEvent('enhanceddnsleak:finished', { ...result.value }); } catch (error) { console.error('Enhanced DNS leak test failed:', error); if (error.message.includes('Invalid token')) { diff --git a/frontend/components/advanced-tools/GlobalLatencyTest.vue b/frontend/components/advanced-tools/GlobalLatencyTest.vue index ddd3c1c3..2fbc9627 100644 --- a/frontend/components/advanced-tools/GlobalLatencyTest.vue +++ b/frontend/components/advanced-tools/GlobalLatencyTest.vue @@ -127,6 +127,7 @@ import { ref, computed } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; +import { emitAppEvent } from '@/utils/app-events'; import { useGlobalpingMeasurement, GLOBALPING_DEFAULT_LOCATIONS, selectableIPs } from '@/composables/use-globalping-measurement'; import { isValidIP } from '@/utils/valid-ip.js'; import getCountryName from '@/data/country-name.js'; @@ -205,7 +206,17 @@ const startPingCheck = () => { processpingResults(data); return pingResults.value.length > 0; }, - onFinish: () => drawMap(), + onFinish: () => { + drawMap(); + // Domain event: final probe results for the report collector. + emitAppEvent('pingtest:finished', { + target: targetIP.value, + probes: pingResults.value.map((result) => ({ + country: result.country, + stats: result.stats, + })), + }); + }, }); }; diff --git a/frontend/components/advanced-tools/InvisibilityTest.vue b/frontend/components/advanced-tools/InvisibilityTest.vue index adcd35cd..f6a8df65 100644 --- a/frontend/components/advanced-tools/InvisibilityTest.vue +++ b/frontend/components/advanced-tools/InvisibilityTest.vue @@ -302,10 +302,13 @@ const getResult = async () => { errorMsg.value = t('invisibilitytest.fetchError'); } else { testResults.value = data; - // Achievement rules (HiddenWell / SlipUp) live in data/achievement-rules.js. + // Achievement rules (HiddenWell / SlipUp) live in data/achievement-rules.js; + // the report collector consumes ip + the 14 detection flags. emitAppEvent('invisibility:result', { proxyScore: Math.floor(data.score.proxy), vpnScore: Math.floor(data.score.vpn), + ip: data.ip, + flags: detectionItems.value.map(({ key, flagged }) => ({ key, flagged })), }); } } catch (error) { diff --git a/frontend/components/advanced-tools/MtrTest.vue b/frontend/components/advanced-tools/MtrTest.vue index ffea19cd..b34daafe 100644 --- a/frontend/components/advanced-tools/MtrTest.vue +++ b/frontend/components/advanced-tools/MtrTest.vue @@ -147,6 +147,7 @@ import { ref, computed } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; +import { emitAppEvent } from '@/utils/app-events'; import { useGlobalpingMeasurement, GLOBALPING_DEFAULT_LOCATIONS, selectableIPs } from '@/composables/use-globalping-measurement'; import { isValidIP } from '@/utils/valid-ip.js'; import { parseMtrOutput } from '@/utils/mtr-parse.js'; @@ -205,6 +206,21 @@ const startmtrCheck = () => { processmtrResults(data); return mtrResults.value.length > 0; }, + onFinish: () => { + // Domain event: final probes with parsed hops for the report + // collector (rawOutput stays local — only the structured hops + // belong in a shareable report). + emitAppEvent('mtrtest:finished', { + target: targetIP.value, + probes: mtrResults.value.map((result) => ({ + country: result.country, + city: result.city, + network: result.network, + asn: result.asn, + hops: result.hops, + })), + }); + }, }); }; diff --git a/frontend/components/advanced-tools/RuleTest.vue b/frontend/components/advanced-tools/RuleTest.vue index 8e65fde0..d9669bbb 100644 --- a/frontend/components/advanced-tools/RuleTest.vue +++ b/frontend/components/advanced-tools/RuleTest.vue @@ -200,9 +200,17 @@ const checkAllRuleTest = async (refresh = false) => { processTest(index + 1); if (index === testCount.value - 1) { finishAll.value = true; - // Achievement rule (CrossingTheWall) lives in data/achievement-rules.js. + // Achievement rule (CrossingTheWall) lives in data/achievement-rules.js; + // the report collector consumes the per-worker details (the + // builder recomputes its own unique count from valid IPs). emitAppEvent('ruletest:finished', { uniqueIPCount: new Set(ruleTests.value.map((test) => test.ip)).size, + workers: ruleTests.value.map((test) => ({ + id: test.id, + ip: test.ip, + country_code: test.country_code, + org: test.org, + })), }); } } diff --git a/frontend/composables/use-report-collector.js b/frontend/composables/use-report-collector.js new file mode 100644 index 00000000..e25c3460 --- /dev/null +++ b/frontend/composables/use-report-collector.js @@ -0,0 +1,47 @@ +// Report collector — subscribes to the test-completion events on the app bus +// (utils/app-events.js), runs each payload through its report builder +// (utils/report-builders.js) and keeps the latest schema-shaped snapshot per +// section. This is the data source for the share-report dialog; components +// stay report-agnostic, exactly like the achievement engine's pattern. +// Call once from App.vue setup. + +import { reactive, computed, onScopeDispose } from 'vue'; +import { onAppEvent } from '../utils/app-events.js'; +import { REPORT_EVENT_BUILDERS } from '../utils/report-builders.js'; + +// Module-level so later consumers (share dialog) see the same snapshots the +// App.vue-mounted subscriber collected. Shape: sectionId → schema section +// object (testedAt included). +const collectedSections = reactive({}); + +// Latest-wins: every re-run of a test overwrites its section snapshot. +const ingest = (event, payload) => { + const { section, build } = REPORT_EVENT_BUILDERS[event]; + const built = build(payload); + if (built === null) return; + collectedSections[section] = { testedAt: new Date().toISOString(), ...built }; +}; + +export const useReportCollector = () => { + const unsubscribes = Object.keys(REPORT_EVENT_BUILDERS).map((event) => + onAppEvent(event, (payload) => ingest(event, payload)), + ); + + // App.vue never unmounts in production; this keeps HMR re-runs of setup + // from stacking duplicate listeners in dev. + onScopeDispose(() => unsubscribes.forEach((unsubscribe) => unsubscribe())); +}; + +// Read-only access for the share dialog: the raw snapshots plus the list of +// sections that currently hold data (in no particular order — display order +// comes from REPORT_SECTION_IDS in the schema). +export const useCollectedReport = () => ({ + sections: collectedSections, + availableSectionIds: computed(() => Object.keys(collectedSections)), +}); + +// Drop every collected snapshot (module state is shared — used by tests and +// available to a future "clear my data" control). +export const resetCollectedReport = () => { + for (const key of Object.keys(collectedSections)) delete collectedSections[key]; +}; diff --git a/frontend/utils/app-events.js b/frontend/utils/app-events.js index 2bea7375..3045e982 100644 --- a/frontend/utils/app-events.js +++ b/frontend/utils/app-events.js @@ -1,8 +1,9 @@ // Minimal app-wide event bus for domain events — components emit what // happened ("speed test finished", "whois lookup ran") and decoupled modules // react to it. Framework-agnostic on purpose: no Vue imports, usable from any -// util / composable / component. Currently consumed by the achievement engine -// (composables/use-achievement-engine.js). +// util / composable / component. Consumed by the achievement engine +// (composables/use-achievement-engine.js) and the report collector +// (composables/use-report-collector.js). const listeners = new Map(); // event name → Set diff --git a/frontend/utils/report-builders.js b/frontend/utils/report-builders.js new file mode 100644 index 00000000..3856e7ab --- /dev/null +++ b/frontend/utils/report-builders.js @@ -0,0 +1,315 @@ +// Builders that turn the domain events emitted by test components into +// report-schema sections (see common/report-schema.js). Components emit raw +// snapshots of their own state — including wait/error placeholder strings — +// and every normalization rule here is shape-based (is it a valid IP? was a +// geo lookup recorded?), never a comparison against localized text. Each +// builder returns a schema-shaped section object, or null when the payload +// holds nothing worth reporting. + +import { isValidIP } from './valid-ip.js'; + +// --- small shared normalizers ---------------------------------------------- + +const clip = (value, max) => + typeof value === 'string' ? value.slice(0, max) : undefined; + +const finiteNum = (value, min, max) => + typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max + ? value : undefined; + +const clampInt = (value, min, max) => { + if (!Number.isInteger(value)) return undefined; + return Math.min(Math.max(value, min), max); +}; + +const countryCode = (value) => + typeof value === 'string' && /^[a-z]{2}$/i.test(value) ? value.toUpperCase() : ''; + +const ipOrUndefined = (value) => (isValidIP(value) ? value : undefined); + +// Drop undefined values so optional schema keys are absent, not null. +const compact = (obj) => { + const out = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) out[key] = value; + } + return out; +}; + +// --- per-section builders --------------------------------------------------- + +// ipinfo:finished — { cards: [{ source, ip, country_code, region, city, asn, isp }] } +// Cards whose ip never resolved (placeholder text in the slot) are dropped. +const buildIpinfo = (payload) => { + const cards = (payload?.cards ?? []) + .filter((card) => isValidIP(card?.ip)) + .slice(0, 8) + .map((card) => compact({ + source: clip(card.source, 64) ?? '', + ip: card.ip, + countryCode: countryCode(card.country_code), + region: clip(card.region, 64), + city: clip(card.city, 64), + asn: clip(card.asn, 16), + isp: clip(card.isp, 128), + })); + return cards.length ? { cards } : null; +}; + +// connectivity:finished — { targets: [{ id, name, custom, statusCode, time, mintime }] } +// statusCode is the locale-free enum the component records next to its +// localized status label; targets still waiting carry none and are dropped. +const CONNECTIVITY_STATUSES = new Set(['ok', 'unreachable', 'timeout']); +const buildConnectivity = (payload) => { + const targets = (payload?.targets ?? []) + .filter((target) => CONNECTIVITY_STATUSES.has(target?.statusCode)) + .slice(0, 24) + .map((target) => compact({ + id: clip(target.id, 48) ?? '', + name: clip(target.name, 64) ?? '', + status: target.statusCode, + timeMs: clampInt(target.time, 0, 600000), + minTimeMs: clampInt(target.mintime, 0, 600000), + custom: target.custom === true ? true : undefined, + })); + return targets.length ? { targets } : null; +}; + +// webrtc:finished — { servers: [{ id, url, ip, natTypeCode, country_code, org }] } +// org is only trusted when a geo lookup landed (country_code recorded with it). +const NAT_TYPES = new Set(['host', 'srflx', 'prflx', 'relay', 'unknown', 'unavailable', 'error']); +const buildWebrtc = (payload) => { + const servers = (payload?.servers ?? []) + .filter((server) => NAT_TYPES.has(server?.natTypeCode)) + .slice(0, 8) + .map((server) => { + const cc = countryCode(server.country_code); + return compact({ + id: clip(server.id, 32) ?? '', + url: clip(server.url, 128) ?? '', + ip: ipOrUndefined(server.ip), + natType: server.natTypeCode, + countryCode: cc, + org: cc ? clip(server.org, 128) : undefined, + }); + }); + return servers.length ? { servers } : null; +}; + +// dnsleak:finished — { providers: [{ id, name, ip, country_code, org }] } +const buildDnsleak = (payload) => { + const providers = (payload?.providers ?? []) + .filter((provider) => isValidIP(provider?.ip)) + .slice(0, 8) + .map((provider) => { + const cc = countryCode(provider.country_code); + return compact({ + id: clip(provider.id, 32) ?? '', + name: clip(provider.name, 64) ?? '', + ip: provider.ip, + countryCode: cc, + org: cc ? clip(provider.org, 128) : undefined, + }); + }); + return providers.length ? { providers } : null; +}; + +// speedtest:finished — thickened payload; numeric fields may be '-' until +// measured, which finiteNum turns into an absent key. +const QUALITIES = new Set(['bad', 'poor', 'average', 'good', 'great']); +const quality = (value) => (QUALITIES.has(value) ? value : undefined); +const buildSpeedtest = (payload) => { + if (!payload) return null; + const section = compact({ + downloadMbps: finiteNum(payload.downloadSpeed, 0, 1000000), + uploadMbps: finiteNum(payload.uploadSpeed, 0, 1000000), + latencyMs: finiteNum(payload.latency, 0, 600000), + jitterMs: finiteNum(payload.jitter, 0, 600000), + loadedLatencyDownMs: finiteNum(payload.downLoadedLatency, 0, 600000), + loadedLatencyUpMs: finiteNum(payload.upLoadedLatency, 0, 600000), + }); + if (Object.keys(section).length === 0) return null; + + const scores = compact({ + streaming: clampInt(payload.scores?.streaming, 0, 10000), + gaming: clampInt(payload.scores?.gaming, 0, 10000), + rtc: clampInt(payload.scores?.rtc, 0, 10000), + }); + if (Object.keys(scores).length) section.scores = scores; + + const qualities = compact({ + streaming: quality(payload.qualities?.streaming), + gaming: quality(payload.qualities?.gaming), + rtc: quality(payload.qualities?.rtc), + }); + if (Object.keys(qualities).length) section.qualities = qualities; + + const connection = compact({ + ip: ipOrUndefined(payload.connection?.ip), + colo: clip(payload.connection?.colo, 8), + coloCountryCode: countryCode(payload.connection?.coloCountryCode), + coloCity: clip(payload.connection?.coloCity, 64), + }); + if (Object.keys(connection).length) section.connection = connection; + + return section; +}; + +// pingtest:finished — { target, probes: [{ country, stats }] }. Globalping +// stats can carry extra keys (mdev, …) — only the whitelisted ones survive. +const buildPingtest = (payload) => { + const target = clip(payload?.target, 253); + const probes = (payload?.probes ?? []) + .slice(0, 32) + .map((probe) => ({ + countryCode: countryCode(probe?.country), + stats: compact({ + min: finiteNum(probe?.stats?.min, 0, 600000), + max: finiteNum(probe?.stats?.max, 0, 600000), + avg: finiteNum(probe?.stats?.avg, 0, 600000), + loss: finiteNum(probe?.stats?.loss, 0, 100), + total: clampInt(probe?.stats?.total, 0, 1000), + rcv: clampInt(probe?.stats?.rcv, 0, 1000), + drop: clampInt(probe?.stats?.drop, 0, 1000), + }), + })); + return target && probes.length ? { target, probes } : null; +}; + +// mtrtest:finished — { target, probes: [{ country, city, network, asn, hops }] } +// hops come pre-structured from parseMtrOutput and already fit the schema. +const buildMtrtest = (payload) => { + const target = clip(payload?.target, 253); + const probes = (payload?.probes ?? []) + .slice(0, 32) + .map((probe) => compact({ + countryCode: countryCode(probe?.country), + city: clip(probe?.city, 64), + network: clip(probe?.network, 128), + asn: clampInt(probe?.asn, 0, 4294967295), + hops: (probe?.hops ?? []).slice(0, 64).map((hop) => compact({ + ...hop, + host: clip(hop.host, 128), + ip: ipOrUndefined(hop.ip), + })), + })) + .filter((probe) => probe.hops.length); + return target && probes.length ? { target, probes } : null; +}; + +// ruletest:finished — { uniqueIPCount, workers: [{ id, ip, country_code, org }] } +// uniqueIPCount is recomputed from valid IPs only — the event's own count +// (kept for achievement rules) also counts error placeholders as "IPs". +const buildRuletest = (payload) => { + const workers = (payload?.workers ?? []) + .filter((worker) => isValidIP(worker?.ip)) + .slice(0, 16) + .map((worker) => compact({ + id: clampInt(worker.id, 1, 16) ?? 1, + ip: worker.ip, + countryCode: countryCode(worker.country_code), + org: clip(worker.org, 128), + })); + if (!workers.length) return null; + return { + uniqueIPCount: new Set(workers.map((worker) => worker.ip)).size, + workers, + }; +}; + +// browserinfo:finished — { userAgent, otherInfos } (ua-parser result + the +// component's otherInfos block). Fingerprint data is deliberately never +// forwarded by the component. +const nameVersion = (part) => { + const picked = compact({ name: clip(part?.name, 64), version: clip(part?.version, 32) }); + return Object.keys(picked).length ? picked : undefined; +}; +const buildBrowserinfo = (payload) => { + const ua = payload?.userAgent; + const other = payload?.otherInfos; + if (!ua && !other) return null; + const connection = other?.connection + ? compact({ + effectiveType: clip(other.connection.effectiveType, 16), + downlink: finiteNum(other.connection.downlink, 0, 100000), + rtt: clampInt(other.connection.rtt, 0, 600000), + }) + : undefined; + const section = compact({ + browser: nameVersion(ua?.browser), + os: nameVersion(ua?.os), + engine: nameVersion(ua?.engine), + timezone: clip(other?.timezone, 64), + languages: Array.isArray(other?.languages) + ? other.languages.slice(0, 16).map((l) => clip(l, 16) ?? '') + : undefined, + display: other?.display + ? compact({ + width: clampInt(other.display.width, 0, 100000), + height: clampInt(other.display.height, 0, 100000), + pixelRatio: finiteNum(other.display.pixelRatio, 0, 100), + }) + : undefined, + connection, + }); + return Object.keys(section).length ? section : null; +}; + +// invisibility:result — { proxyScore, vpnScore, ip, flags: [{ key, flagged }] } +const buildInvisibility = (payload) => { + const proxy = finiteNum(payload?.proxyScore, 0, 100); + const vpn = finiteNum(payload?.vpnScore, 0, 100); + if (proxy === undefined || vpn === undefined) return null; + return compact({ + ip: ipOrUndefined(payload.ip), + scores: { proxy, vpn }, + flags: (payload?.flags ?? []).slice(0, 20).map((flag) => ({ + key: clip(flag?.key, 32) ?? '', + flagged: flag?.flagged === true, + })), + }); +}; + +// enhanceddnsleak:finished — { rawCount, resolverCount, queries } with the +// upstream's raw query rows; the DNSSEC posture is derived here (worst-of, +// same rule as the component's dnssecSummary). +const buildEnhanceddnsleak = (payload) => { + const rows = Array.isArray(payload?.queries) ? payload.queries : []; + const queries = rows + .filter((row) => isValidIP(row?.ip)) + .slice(0, 128) + .map((row) => compact({ + ip: row.ip, + countryCode: countryCode(row.ipInfo?.country_code), + asn: row.ipInfo?.asn != null ? clip(String(row.ipInfo.asn), 16) : undefined, + org: clip(row.ipInfo?.org, 128), + transport: clip(row.transport, 16), + ecs: clip(row.ecs, 64), + do: row.do === true, + cd: row.cd === true, + })); + if (!queries.length) return null; + const dnssec = queries.some((q) => q.cd) || queries.some((q) => !q.do) ? 'partial' : 'ok'; + return { + rawCount: clampInt(payload.rawCount, 0, 100000) ?? 0, + resolverCount: clampInt(payload.resolverCount, 0, 10000) ?? 0, + dnssec, + queries, + }; +}; + +// --- event → builder registry (consumed by use-report-collector) ------------ + +export const REPORT_EVENT_BUILDERS = { + 'ipinfo:finished': { section: 'ipinfo', build: buildIpinfo }, + 'connectivity:finished': { section: 'connectivity', build: buildConnectivity }, + 'webrtc:finished': { section: 'webrtc', build: buildWebrtc }, + 'dnsleak:finished': { section: 'dnsleak', build: buildDnsleak }, + 'speedtest:finished': { section: 'speedtest', build: buildSpeedtest }, + 'pingtest:finished': { section: 'pingtest', build: buildPingtest }, + 'mtrtest:finished': { section: 'mtrtest', build: buildMtrtest }, + 'ruletest:finished': { section: 'ruletest', build: buildRuletest }, + 'browserinfo:finished': { section: 'browserinfo', build: buildBrowserinfo }, + 'invisibility:result': { section: 'invisibility', build: buildInvisibility }, + 'enhanceddnsleak:finished': { section: 'enhanceddnsleak', build: buildEnhanceddnsleak }, +}; diff --git a/tests/composable-report-collector.test.js b/tests/composable-report-collector.test.js new file mode 100644 index 00000000..dc20a5ae --- /dev/null +++ b/tests/composable-report-collector.test.js @@ -0,0 +1,86 @@ +// Tests for the report collector (frontend/composables/use-report-collector.js): +// event-driven accumulation of schema-shaped section snapshots, latest-wins +// overwrites, null-builder payloads ignored, and unsubscribe on scope dispose. +// Unlike the achievement engine it needs no Pinia store — just Vue reactivity. + +import assert from 'node:assert/strict'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { effectScope } from 'vue'; + +import { + useReportCollector, + useCollectedReport, + resetCollectedReport, +} from '../frontend/composables/use-report-collector.js'; +import { emitAppEvent } from '../frontend/utils/app-events.js'; + +const SPEEDTEST_PAYLOAD = { + downloadSpeed: 100, uploadSpeed: 20, latency: 15, jitter: 2, + downLoadedLatency: 40, upLoadedLatency: 60, + scores: null, qualities: null, + connection: { ip: '104.16.0.1', colo: 'HKG', coloCountryCode: 'HK', coloCity: 'Hong Kong' }, +}; + +let scope; + +beforeEach(() => { + resetCollectedReport(); + scope = effectScope(); + scope.run(() => useReportCollector()); +}); + +afterEach(() => { + scope.stop(); + resetCollectedReport(); +}); + +describe('report collector', () => { + it('stores a schema-shaped snapshot with testedAt when a test finishes', () => { + emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD); + const { sections, availableSectionIds } = useCollectedReport(); + assert.deepEqual(availableSectionIds.value, ['speedtest']); + assert.equal(sections.speedtest.downloadMbps, 100); + assert.equal(Number.isNaN(Date.parse(sections.speedtest.testedAt)), false); + }); + + it('latest emit wins', () => { + emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD); + emitAppEvent('speedtest:finished', { ...SPEEDTEST_PAYLOAD, downloadSpeed: 250 }); + const { sections } = useCollectedReport(); + assert.equal(sections.speedtest.downloadMbps, 250); + }); + + it('ignores payloads the builder rejects, keeping the previous snapshot', () => { + emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD); + emitAppEvent('speedtest:finished', {}); // builder → null + const { sections } = useCollectedReport(); + assert.equal(sections.speedtest.downloadMbps, 100); + }); + + it('ignores unrelated bus events entirely', () => { + emitAppEvent('whois:lookup', { query: 'example.com' }); + emitAppEvent('ip-source:exhausted', { source: 'ipip', ipVersion: 'v4' }); + const { availableSectionIds } = useCollectedReport(); + assert.deepEqual(availableSectionIds.value, []); + }); + + it('accumulates independent sections side by side', () => { + emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD); + emitAppEvent('ruletest:finished', { + uniqueIPCount: 1, + workers: [{ id: 1, ip: '1.2.3.4', country_code: 'US', org: 'CF' }], + }); + const { availableSectionIds } = useCollectedReport(); + assert.deepEqual([...availableSectionIds.value].sort(), ['ruletest', 'speedtest']); + }); + + it('stops listening once its scope is disposed', () => { + scope.stop(); + emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD); + const { availableSectionIds } = useCollectedReport(); + assert.deepEqual(availableSectionIds.value, []); + // beforeEach of the next test re-creates a scope; afterEach's stop + // on an already-stopped scope is a no-op. + scope = effectScope(); + }); +}); diff --git a/tests/report-builders.test.js b/tests/report-builders.test.js new file mode 100644 index 00000000..8f7b3737 --- /dev/null +++ b/tests/report-builders.test.js @@ -0,0 +1,228 @@ +// Exercises the report builders (frontend/utils/report-builders.js): every +// builder's output — wrapped in a report envelope — must pass the shared +// schema validator, and the shape-based cleaning rules (invalid IPs dropped, +// geo-less org distrusted, stat whitelists) must hold. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { REPORT_EVENT_BUILDERS } from '../frontend/utils/report-builders.js'; +import { validateReport, REPORT_VERSION } from '../common/report-schema.js'; + +const wrap = (sectionId, section) => ({ + v: REPORT_VERSION, + generatedAt: '2026-07-14T08:00:00.000Z', + origin: 'ipcheck.ing', + locale: 'zh', + sections: { [sectionId]: { testedAt: '2026-07-14T08:00:00.000Z', ...section } }, +}); + +// Realistic payloads mirroring what each component actually emits — including +// localized placeholder strings in ip slots, which the builders must drop. +const PAYLOADS = { + 'ipinfo:finished': { + cards: [ + { source: 'IPCheck.ing IPv4', ip: '1.2.3.4', country_code: 'us', region: 'CA', city: 'SF', asn: 'AS15169', isp: 'Google LLC' }, + { source: 'IPCheck.ing IPv6', ip: 'IPv6 检测失败' }, + { source: 'Cloudflare IPv6', ip: '2001:db8::1', country_code: '' }, + ], + }, + 'connectivity:finished': { + targets: [ + { id: 'google', name: 'Google', custom: false, statusCode: 'ok', time: 123, mintime: 98 }, + { id: 'wechat', name: 'WeChat', custom: false, statusCode: 'unreachable', time: 0, mintime: 0 }, + { id: 'custom-1', name: 'My server', custom: true, statusCode: undefined, time: 0, mintime: 0 }, + ], + }, + 'webrtc:finished': { + servers: [ + { id: 'google', url: 'stun.l.google.com:19302', ip: '1.2.3.4', natTypeCode: 'srflx', country_code: 'US', org: 'Google' }, + { id: 'twilio', url: 'global.stun.twilio.com', ip: 'STUN 请求失败', natTypeCode: 'error', country_code: '', org: 'STUN 请求失败' }, + { id: 'cloudflare', url: 'stun.cloudflare.com', ip: '等待检测', natTypeCode: undefined, country_code: '', org: '等待检测' }, + ], + }, + 'dnsleak:finished': { + providers: [ + { id: 'surfshark', name: 'Surfshark', ip: '8.8.4.4', country_code: 'US', org: 'Google' }, + { id: 'ipleak', name: 'IPLeak', ip: '检测失败', country_code: '', org: '检测失败' }, + ], + }, + 'speedtest:finished': { + downloadSpeed: 500.2, uploadSpeed: 42.1, latency: 12, jitter: '-', + downLoadedLatency: 45, upLoadedLatency: '-', + scores: { streaming: 90, gaming: 70, rtc: 80 }, + qualities: { streaming: 'great', gaming: 'good', rtc: 'good' }, + connection: { ip: '104.16.0.1', colo: 'HKG', loc: 'HK', country: 'Hong Kong', coloCountry: 'Hong Kong', coloCountryCode: 'HK', coloCity: 'Hong Kong' }, + }, + 'pingtest:finished': { + target: '1.2.3.4', + probes: [ + { country: 'US', stats: { min: 1.2, max: 4.5, avg: 2.3, total: 8, loss: 0, rcv: 8, drop: 0, mdev: 0.4 } }, + ], + }, + 'mtrtest:finished': { + target: '8.8.8.8', + probes: [ + { + country: 'DE', city: 'Falkenstein', network: 'Hetzner Online', asn: 24940, + hops: [ + { n: 1, asn: null, host: '_gateway', ip: '172.17.0.1', lossPct: 0, drop: 0, rcv: 3, avgMs: 0.3 }, + { n: 2, lossPct: 100 }, + ], + }, + { country: 'US', city: 'Ashburn', network: 'AWS', asn: 14618, hops: [] }, + ], + }, + 'ruletest:finished': { + uniqueIPCount: 3, // counts error labels too — builder recomputes + workers: [ + { id: 1, ip: '1.2.3.4', country_code: 'US', org: 'Cloudflare' }, + { id: 2, ip: '1.2.3.4', country_code: 'US', org: 'Cloudflare' }, + { id: 3, ip: '检测失败', country_code: '', org: '检测失败' }, + ], + }, + 'browserinfo:finished': { + userAgent: { + ua: 'Mozilla/5.0 …', + browser: { name: 'Chrome', version: '138.0.0.0', major: '138' }, + engine: { name: 'Blink', version: '138.0.0.0' }, + os: { name: 'macOS', version: '15.5' }, + device: { vendor: 'Apple', model: 'Macintosh' }, + cpu: { architecture: 'arm64' }, + }, + otherInfos: { + cookieEnabled: true, cpucores: 10, + languages: ['zh-CN', 'en'], + timezone: 'Asia/Hong_Kong', + display: { width: 1512, height: 982, colorDepth: 30, pixelRatio: 2 }, + connection: { effectiveType: '4g', downlink: 10, rtt: 50, saveData: false }, + touchPoints: 0, doNotTrack: null, pdfViewer: true, + }, + }, + 'invisibility:result': { + proxyScore: 12, vpnScore: 88, ip: '1.2.3.4', + flags: [{ key: 'timezone', flagged: true }, { key: 'blocklist.vpn', flagged: false }], + }, + 'enhanceddnsleak:finished': { + rawCount: 12, resolverCount: 3, + queries: [ + { + ip: '8.8.8.8', queryType: 'A', transport: 'udp', ecs: '1.2.3.0/24', do: true, cd: false, + ipInfo: { country_code: 'US', country_name: 'United States', region: 'CA', city: 'MV', asn: 15169, org: 'Google' }, + ecsInfo: { country_code: 'HK' }, + }, + { + ip: '9.9.9.9', queryType: 'A', transport: 'tcp', ecs: '', do: false, cd: false, + ipInfo: { country_code: 'N/A', asn: null, org: 'Quad9' }, + }, + ], + }, +}; + +describe('report builders produce schema-valid sections', () => { + for (const [event, { section, build }] of Object.entries(REPORT_EVENT_BUILDERS)) { + it(`${event} → sections.${section} passes validateReport`, () => { + const built = build(PAYLOADS[event]); + assert.notEqual(built, null, `builder for ${event} returned null on a realistic payload`); + const result = validateReport(wrap(section, built)); + assert.deepEqual(result, { ok: true, errors: [] }); + }); + } + + it('every builder returns null on an empty payload', () => { + for (const [event, { build }] of Object.entries(REPORT_EVENT_BUILDERS)) { + assert.equal(build({}), null, `${event} should return null on {}`); + assert.equal(build(undefined), null, `${event} should return null on undefined`); + } + }); +}); + +describe('cleaning rules', () => { + it('ipinfo drops cards whose ip slot holds a placeholder', () => { + const { build } = REPORT_EVENT_BUILDERS['ipinfo:finished']; + const section = build(PAYLOADS['ipinfo:finished']); + assert.equal(section.cards.length, 2); + assert.deepEqual(section.cards.map((c) => c.ip), ['1.2.3.4', '2001:db8::1']); + assert.equal(section.cards[0].countryCode, 'US'); // normalized to upper + }); + + it('connectivity keeps only settled targets and maps the enum', () => { + const { build } = REPORT_EVENT_BUILDERS['connectivity:finished']; + const section = build(PAYLOADS['connectivity:finished']); + assert.equal(section.targets.length, 2); + assert.equal(section.targets[0].status, 'ok'); + assert.equal(section.targets[1].status, 'unreachable'); + // custom:false is omitted, not serialized + assert.equal('custom' in section.targets[0], false); + }); + + it('webrtc drops waiting servers, keeps errored ones without ip/org', () => { + const { build } = REPORT_EVENT_BUILDERS['webrtc:finished']; + const section = build(PAYLOADS['webrtc:finished']); + assert.equal(section.servers.length, 2); + const errored = section.servers[1]; + assert.equal(errored.natType, 'error'); + assert.equal('ip' in errored, false); // placeholder text is not an IP + assert.equal('org' in errored, false); // no geo lookup → org distrusted + }); + + it('speedtest turns "-" placeholders into absent keys', () => { + const { build } = REPORT_EVENT_BUILDERS['speedtest:finished']; + const section = build(PAYLOADS['speedtest:finished']); + assert.equal(section.jitterMs, undefined); + assert.equal(section.loadedLatencyUpMs, undefined); + assert.equal(section.downloadMbps, 500.2); + assert.deepEqual(section.connection, { ip: '104.16.0.1', colo: 'HKG', coloCountryCode: 'HK', coloCity: 'Hong Kong' }); + }); + + it('pingtest whitelists stats keys (mdev dropped)', () => { + const { build } = REPORT_EVENT_BUILDERS['pingtest:finished']; + const section = build(PAYLOADS['pingtest:finished']); + assert.equal('mdev' in section.probes[0].stats, false); + assert.equal(section.probes[0].stats.avg, 2.3); + }); + + it('mtrtest drops probes without hops', () => { + const { build } = REPORT_EVENT_BUILDERS['mtrtest:finished']; + const section = build(PAYLOADS['mtrtest:finished']); + assert.equal(section.probes.length, 1); + assert.equal(section.probes[0].asn, 24940); + }); + + it('ruletest recomputes uniqueIPCount from valid IPs only', () => { + const { build } = REPORT_EVENT_BUILDERS['ruletest:finished']; + const section = build(PAYLOADS['ruletest:finished']); + assert.equal(section.workers.length, 2); + assert.equal(section.uniqueIPCount, 1); // two workers, same egress IP + }); + + it('browserinfo picks name/version pairs and drops fingerprint-adjacent noise', () => { + const { build } = REPORT_EVENT_BUILDERS['browserinfo:finished']; + const section = build(PAYLOADS['browserinfo:finished']); + assert.deepEqual(section.browser, { name: 'Chrome', version: '138.0.0.0' }); + assert.equal('device' in section, false); + assert.deepEqual(section.connection, { effectiveType: '4g', downlink: 10, rtt: 50 }); + }); + + it('enhanceddnsleak derives DNSSEC posture and normalizes geo', () => { + const { build } = REPORT_EVENT_BUILDERS['enhanceddnsleak:finished']; + const section = build(PAYLOADS['enhanceddnsleak:finished']); + assert.equal(section.dnssec, 'partial'); // second row lacks DO + assert.equal(section.queries[0].asn, '15169'); + assert.equal(section.queries[1].countryCode, ''); // 'N/A' → '' + assert.equal('asn' in section.queries[1], false); // null asn → absent + + const allDo = build({ + rawCount: 1, resolverCount: 1, + queries: [{ ip: '8.8.8.8', do: true, cd: false, ipInfo: {} }], + }); + assert.equal(allDo.dnssec, 'ok'); + }); + + it('invisibility requires both scores', () => { + const { build } = REPORT_EVENT_BUILDERS['invisibility:result']; + assert.equal(build({ proxyScore: 10 }), null); + const section = build(PAYLOADS['invisibility:result']); + assert.deepEqual(section.scores, { proxy: 12, vpn: 88 }); + }); +});