MovixOpenSource/API/Mainapi/utils/__tests__/vipPaygate.test.js
Movix 3a7ab5ad42 feat: synchronise le repo privé — Cast, sources, API et mobile 2.5.7
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour
regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV,
le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de
l’application mobile.

Lecteur, sources et Live TV

- Refonte du lecteur HLS avec sélection et mémorisation de la qualité,
  détection des résolutions, scan des liens, choix des pistes audio et
  fallback contrôlé de 1080p vers 720p.
- Ajoute une garde contre les boucles de changement de source, notamment
  lors des erreurs vidéo et des réponses 429.
- Revoit SeekStreaming de bout en bout : validation des embeds, prise en
  charge de plusieurs candidats, regroupement par serveur, règles de headers
  persistantes et cache d’extraction raccourci.
- Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache
  stale-while-revalidate, déduplication des requêtes et limite partagée côté API.
- Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill
  switch, développe les wrappers 1jour1film et actualise le domaine Wiflix.
- Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même
  chaîne et permet de choisir le serveur ou la qualité.
- Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi
  que de nouvelles métriques de visionnage.

Cast et application mobile

- Unifie le contrôle Cast entre le Web Cast SDK et le pont Android :
  état distant, lecture, pause, seek, arrêt et reprise après reconnexion.
- Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et
  au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground.
- Prépare les sources avant diffusion et prend en charge les playlists HLS
  TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les
  segments MPEG-TS encapsulés dans des PNG.
- Renforce le proxy média local : support Range, validation des redirections,
  liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid.
- Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités
  propres à chaque document et invalidation lors des navigations.
- Ajoute le Picture-in-Picture Android avec lecture/pause et seek de
  10 secondes depuis la fenêtre flottante.
- Maintient désormais l’écran allumé uniquement pendant une lecture active.
- Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses
  métadonnées d’intégrité.

API, base de données et paiements

- Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL
  centralisé, additif et capable de détecter les divergences de structure.
- Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL,
  confirmations pour les tables Wrapped et couverture de tests dédiée.
- Rend configurables les limites du pool MySQL et fiabilise les advisory locks.
- Revoit entièrement PayGate : références de callback séparées, nonces,
  validation des wallets et transactions, états monotones, déduplication des
  paiements et protection contre les courses ou réutilisations de txid.
- Renforce l’annulation, la réconciliation et la livraison idempotente des
  factures VIP, avec réponses no-store sur les routes sensibles.
- Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer
  correctement 401 ou 503 sans provoquer de déconnexions inutiles.
- Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en
  solution de secours.
- Améliore la gestion HLS et Range dans proxiesembed.

Frontend, profils et infrastructure

- Protège la sélection et l’hydratation des profils contre les réponses
  concurrentes, les intentions obsolètes et l’effacement accidentel du
  stockage local.
- Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données
  structurées, le service worker, robots.txt et sitemap.xml.
- Optimise les probes réseau du service worker avec cache court et single-flight.
- Charge dynamiquement les traductions anglaises, plusieurs routes secondaires
  et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires.
- Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit
  les animations, calculs et rendus hors écran.
- Ajoute la page publique des frais, les coins carrés persistants, une nouvelle
  intro Movix, les avertissements de publicités adultes et de nombreux textes
  Cast, qualité, Live TV et sources.
- Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le
  userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast.
- Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le
  proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur.
- Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et
  actualise la documentation d’installation et d’initialisation MySQL.

Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
2026-08-02 12:09:37 +02:00

421 lines
14 KiB
JavaScript

'use strict';
const { test, mock } = require('node:test');
const assert = require('node:assert/strict');
const {
PAYGATE_FIXTURE,
buildCallbackUrl,
buildWalletPayload,
buildCallbackPayload
} = require('./fixtures/paygateFixtures');
const paygate = require('../vipPaygate');
test('opaque values: encoded and decoded inputs produce one canonical query layer', () => {
assert.equal(
paygate.normalizePaygateOpaqueValue(PAYGATE_FIXTURE.ipnTokenEncoded, 'ipn_token'),
PAYGATE_FIXTURE.ipnTokenRaw
);
assert.equal(
paygate.normalizePaygateOpaqueValue(PAYGATE_FIXTURE.ipnTokenRaw, 'ipn_token'),
PAYGATE_FIXTURE.ipnTokenRaw
);
const encoded = new URLSearchParams();
const decoded = new URLSearchParams();
paygate.setOpaqueQueryParam(encoded, 'ipn_token', PAYGATE_FIXTURE.ipnTokenEncoded);
paygate.setOpaqueQueryParam(decoded, 'ipn_token', PAYGATE_FIXTURE.ipnTokenRaw);
assert.equal(encoded.toString(), decoded.toString());
assert.equal(encoded.get('ipn_token'), PAYGATE_FIXTURE.ipnTokenRaw);
assert.equal(encoded.toString().includes('%252F'), false);
assert.match(encoded.toString(), /%2F/);
assert.match(encoded.toString(), /%2B/);
assert.match(encoded.toString(), /%3D/);
});
test('opaque values: malformed, ambiguous, missing, and non-scalar values fail closed', () => {
for (const value of ['%', '%2', '%2G', '%GG', 'abc%252Fdef', '', ' ', null, undefined, [], {}]) {
assert.throws(
() => paygate.normalizePaygateOpaqueValue(value, 'ipn_token'),
(error) => error.code === 'PAYGATE_OPAQUE_VALUE_INVALID'
);
}
});
test('opaque values: raw plus is preserved as plus, never converted to a space', () => {
const params = new URLSearchParams();
paygate.setOpaqueQueryParam(params, 'token', 'abc+def=');
assert.equal(params.get('token'), 'abc+def=');
assert.match(params.toString(), /abc%2Bdef%3D/);
});
test('nonce comparison is safe for equal, unequal, and unequal-length strings', () => {
assert.equal(
paygate.safeComparePaygateNonce(PAYGATE_FIXTURE.callbackNonce, PAYGATE_FIXTURE.callbackNonce),
true
);
assert.equal(
paygate.safeComparePaygateNonce(PAYGATE_FIXTURE.callbackNonce, `${PAYGATE_FIXTURE.callbackNonce}00`), false);
assert.equal(paygate.safeComparePaygateNonce(PAYGATE_FIXTURE.callbackNonce, []), false);
});
test('callback URL contains only the gateway reference and nonce', () => {
const result = paygate.buildPaygateCallbackUrl({
baseUrl: 'https://api.movix.example',
reference: PAYGATE_FIXTURE.callbackReference,
nonce: PAYGATE_FIXTURE.callbackNonce,
requireHttps: true
});
const url = new URL(result);
assert.equal(url.origin, 'https://api.movix.example');
assert.equal(url.pathname, '/api/vip/paygate/callback');
assert.deepEqual([...url.searchParams.keys()].sort(), ['nonce', 'reference']);
assert.equal(url.searchParams.get('reference'), PAYGATE_FIXTURE.callbackReference);
assert.equal(url.searchParams.get('nonce'), PAYGATE_FIXTURE.callbackNonce);
assert.equal(url.searchParams.has('publicId'), false);
});
test('production callback base rejects HTTP, paths, credentials, query, and fragments', () => {
for (const baseUrl of [
'http://api.movix.example',
'https://api.movix.example/base',
'https://user:pass@api.movix.example',
'https://api.movix.example?x=1',
'https://api.movix.example#fragment'
]) {
assert.throws(
() => paygate.buildPaygateCallbackUrl({
baseUrl,
reference: PAYGATE_FIXTURE.callbackReference,
nonce: PAYGATE_FIXTURE.callbackNonce,
requireHttps: true
}),
paygate.PaygateProtocolError
);
}
});
test('hosted checkout encodes opaque address, email, amount, and branding exactly once', () => {
const input = {
checkoutAddress: PAYGATE_FIXTURE.addressInEncoded,
amountEur: 7,
payerEmail: 'payer+vip@example.test',
branding: {
logo: 'https://cdn.movix.example/paygate logo.png',
background: '#0A0A0A',
theme: '#FBBF24',
button: '#EAB308'
}
};
const encodedUrl = paygate.buildPaygateCheckoutUrl(input);
const decodedUrl = paygate.buildPaygateCheckoutUrl({
...input,
checkoutAddress: PAYGATE_FIXTURE.addressInRaw
});
assert.equal(encodedUrl, decodedUrl);
const url = new URL(encodedUrl);
assert.equal(url.origin, 'https://checkout.paygate.to');
assert.equal(url.pathname, '/pay.php');
assert.equal(url.searchParams.get('address'), PAYGATE_FIXTURE.addressInRaw);
assert.equal(url.searchParams.get('amount'), '7.00');
assert.equal(url.searchParams.get('email'), 'payer+vip@example.test');
assert.equal(url.searchParams.get('currency'), 'EUR');
assert.equal(url.searchParams.get('logo'), input.branding.logo);
assert.equal(url.searchParams.get('background'), input.branding.background);
assert.equal(url.searchParams.has('provider'), false);
assert.equal(url.search.includes('%252F'), false);
});
test('wallet response is normalized only when every binding field is valid', () => {
const parsed = paygate.parsePaygateWalletResponse(
buildWalletPayload(),
buildCallbackUrl()
);
assert.deepEqual(parsed, {
checkoutAddress: PAYGATE_FIXTURE.addressInRaw,
temporaryWalletAddress: PAYGATE_FIXTURE.temporaryWallet,
callbackUrl: paygate.canonicalizeCallbackUrl(buildCallbackUrl()),
ipnToken: PAYGATE_FIXTURE.ipnTokenRaw
});
const reversed = new URL(buildCallbackUrl());
reversed.search = `?nonce=${PAYGATE_FIXTURE.callbackNonce}&reference=${PAYGATE_FIXTURE.callbackReference}`;
assert.doesNotThrow(() => paygate.parsePaygateWalletResponse(
buildWalletPayload({ callback_url: reversed.toString() }),
buildCallbackUrl()
));
});
test('wallet response rejects missing fields, invalid EVM address, callback mismatch, and duplicate params', () => {
const invalidPayloads = [
buildWalletPayload({ address_in: '' }),
buildWalletPayload({ polygon_address_in: 'not-an-address' }),
buildWalletPayload({ callback_url: '' }),
buildWalletPayload({ callback_url: buildCallbackUrl({ nonce: '0'.repeat(48) }) }),
buildWalletPayload({ callback_url: `${buildCallbackUrl()}&nonce=${PAYGATE_FIXTURE.callbackNonce}` }),
buildWalletPayload({ ipn_token: '' })
];
for (const payload of invalidPayloads) {
assert.throws(
() => paygate.parsePaygateWalletResponse(payload, buildCallbackUrl()),
paygate.PaygateProtocolError
);
}
});
test('callback URL canonicalization rejects malformed percent query values', () => {
assert.throws(
() => paygate.canonicalizeCallbackUrl(`${buildCallbackUrl()}&token=%`),
paygate.PaygateProtocolError
);
});
test('callback URL canonicalization rejects double-encoded query values', () => {
assert.throws(
() => paygate.canonicalizeCallbackUrl(`${buildCallbackUrl()}&token=%252F`),
paygate.PaygateProtocolError
);
});
test('callback URL canonicalization preserves raw plus query values', () => {
const result = paygate.canonicalizeCallbackUrl(`${buildCallbackUrl()}&token=raw+plus`);
assert.match(new URL(result).search, /token=raw%2Bplus/);
});
test('callback payload normalizes the documented card fields', () => {
const result = paygate.parsePaygateCallbackPayload(buildCallbackPayload({
coin: ' POLYGON_USDC ',
address_in: PAYGATE_FIXTURE.temporaryWallet.toLowerCase()
}));
assert.deepEqual(result, {
reference: PAYGATE_FIXTURE.callbackReference,
publicId: null,
nonce: PAYGATE_FIXTURE.callbackNonce,
coin: 'polygon_usdc',
valueCoin: 7.6,
valueForwardedCoin: 7.486,
txidIn: PAYGATE_FIXTURE.txidIn,
txidOut: PAYGATE_FIXTURE.txidOut,
addressIn: PAYGATE_FIXTURE.temporaryWallet.toLowerCase()
});
});
test('callback payload rejects transfer legs that collapse to the same transaction ID', () => {
assert.throws(
() => paygate.parsePaygateCallbackPayload(buildCallbackPayload({
txid_out: `0x${PAYGATE_FIXTURE.txidIn.slice(2).toUpperCase()}`
})),
(error) => error.code === 'PAYGATE_CALLBACK_TX_DUPLICATE'
);
});
test('callback payload accepts legacy publicId but rejects ambiguous or malformed identifiers', () => {
const legacy = buildCallbackPayload({
reference: undefined,
publicId: PAYGATE_FIXTURE.legacyPublicId
});
assert.equal(paygate.parsePaygateCallbackPayload(legacy).publicId, PAYGATE_FIXTURE.legacyPublicId);
for (const payload of [
buildCallbackPayload({ reference: [], nonce: PAYGATE_FIXTURE.callbackNonce }),
buildCallbackPayload({ publicId: PAYGATE_FIXTURE.legacyPublicId }),
buildCallbackPayload({ value_coin: '0' }),
buildCallbackPayload({ value_coin: 'Infinity' }),
buildCallbackPayload({ coin: 'doge' }),
buildCallbackPayload({ address_in: '0x1234' }),
buildCallbackPayload({ txid_in: '0x1234' }),
buildCallbackPayload({ txid_out: '0x1234' }),
buildCallbackPayload({ value_forwarded_coin: '' })
]) {
assert.throws(() => paygate.parsePaygateCallbackPayload(payload), paygate.PaygateProtocolError);
}
});
test('six stablecoin payout identifiers remain 1:1 USD without a price call', async () => {
const httpClient = { get: mock.fn() };
for (const coin of paygate.STABLE_PAYOUT_COINS) {
assert.equal(
await paygate.convertPaygatePaymentToUsd({ coin, valueCoin: 7.6 }, { httpClient }),
7.6
);
}
assert.equal(httpClient.get.mock.callCount(), 0);
});
test('stablecoin conversion rejects a positive input that rounds to zero', async () => {
const httpClient = { get: mock.fn() };
await assert.rejects(
paygate.convertPaygatePaymentToUsd(
{ coin: 'polygon_usdc', valueCoin: '0.000000001' },
{ httpClient }
),
(error) => (
error instanceof paygate.PaygateProtocolError
&& error.code === 'PAYGATE_CONVERSION_INVALID'
&& error.statusCode === 502
&& !error.message.includes('0.000000001')
)
);
assert.equal(httpClient.get.mock.callCount(), 0);
});
test('native payouts require the fixed PayGate path and a positive USD price', async () => {
const calls = [];
const httpClient = {
async get(url, config) {
calls.push({ url, config });
return { data: { prices: { USD: '0.20' } } };
}
};
assert.equal(
await paygate.convertPaygatePaymentToUsd(
{ coin: 'polygon_pol', valueCoin: 23 },
{ httpClient }
),
4.6
);
assert.equal(calls[0].url, 'https://api.paygate.to/crypto/polygon/pol/info.php');
const invalidPriceClient = {
async get() {
return { data: { prices: { USD: '0' } } };
}
};
await assert.rejects(
paygate.convertPaygatePaymentToUsd(
{ coin: 'eth', valueCoin: 1 },
{ httpClient: invalidPriceClient }
),
(error) => error.code === 'PAYGATE_NATIVE_PRICE_INVALID'
);
});
test('native conversion rejects a positive multiplication result that rounds to zero', async () => {
const httpClient = {
async get() {
return { data: { prices: { USD: '0.004' } } };
}
};
await assert.rejects(
paygate.convertPaygatePaymentToUsd(
{ coin: 'eth', valueCoin: '0.000000001' },
{ httpClient }
),
(error) => (
error instanceof paygate.PaygateProtocolError
&& error.code === 'PAYGATE_NATIVE_CONVERSION_INVALID'
&& error.statusCode === 502
&& !error.message.includes('0.000000001')
)
);
});
test('native payouts reject finite inputs whose USD multiplication overflows', async () => {
const httpClient = {
async get() {
return { data: { prices: { USD: Number.MAX_VALUE } } };
}
};
await assert.rejects(
paygate.convertPaygatePaymentToUsd(
{ coin: 'eth', valueCoin: Number.MAX_VALUE },
{ httpClient }
),
(error) => error.code === 'PAYGATE_NATIVE_CONVERSION_INVALID'
);
});
test('unknown and inherited payout identifiers fail before any network call', async () => {
const httpClient = { get: mock.fn() };
for (const coin of ['usdc', 'constructor', 'toString', '__proto__']) {
await assert.rejects(
paygate.convertPaygatePaymentToUsd({ coin, valueCoin: 7.6 }, { httpClient }),
(error) => error.code === 'PAYGATE_COIN_UNSUPPORTED'
);
}
assert.equal(httpClient.get.mock.callCount(), 0);
});
test('EUR conversion rejects a value that would be zero at invoice storage scale', async () => {
const client = paygate.createPaygateClient({
httpClient: {
async get() {
return { data: { status: 'success', value_coin: '0.004' } };
}
}
});
await assert.rejects(
client.convertEurToUsd(7),
(error) => (
error instanceof paygate.PaygateProtocolError
&& error.code === 'PAYGATE_CONVERT_INVALID'
&& error.statusCode === 502
&& !error.message.includes('0.004')
)
);
});
test('PayGate HTTP client normalizes wallet/status tokens and validates upstream payloads', async () => {
const calls = [];
const httpClient = {
async get(url, config) {
calls.push({ url, config });
if (url.endsWith('/convert.php')) {
return { data: { status: 'success', value_coin: '7.60' } };
}
if (url.endsWith('/wallet.php')) {
return { data: buildWalletPayload() };
}
return {
data: {
status: 'paid',
coin: 'polygon_usdc',
value_coin: '7.60',
txid_out: PAYGATE_FIXTURE.txidOut
}
};
}
};
const client = paygate.createPaygateClient({ httpClient, timeoutMs: 3210 });
assert.equal(await client.convertEurToUsd(7), 7.6);
assert.deepEqual(
await client.createWallet({
settlementWallet: PAYGATE_FIXTURE.settlementWallet,
callbackUrl: buildCallbackUrl()
}),
paygate.parsePaygateWalletResponse(buildWalletPayload(), buildCallbackUrl())
);
assert.deepEqual(await client.fetchPaymentStatus(PAYGATE_FIXTURE.ipnTokenEncoded), {
status: 'paid',
coin: 'polygon_usdc',
valueCoin: 7.6,
txidOut: PAYGATE_FIXTURE.txidOut
});
const statusCall = calls.at(-1);
assert.equal(statusCall.config.params.ipn_token, PAYGATE_FIXTURE.ipnTokenRaw);
assert.equal(statusCall.config.timeout, 3210);
assert.equal(
new URLSearchParams(statusCall.config.params).toString().includes('%252F'),
false
);
});
test('status parser accepts only paid or unpaid and requires paid payment fields', () => {
assert.deepEqual(paygate.parsePaygatePaymentStatus({ status: 'unpaid' }), {
status: 'unpaid'
});
assert.throws(
() => paygate.parsePaygatePaymentStatus({ status: 'pending' }),
paygate.PaygateProtocolError
);
assert.throws(
() => paygate.parsePaygatePaymentStatus({ status: 'paid', coin: 'polygon_usdc' }),
paygate.PaygateProtocolError
);
});