mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
✨ feat(simyo): 完善 eSIM 更换流程(仅 EMAIL)
- 修 handleApiResponse:登录/查询体无 result.success 时正确判定成功 - 设备更换:先 GET settings/simcard,再 POST EMAIL 申请;等待验证码时跳过重复下单 - 登录识别 mfaStatus,未关闭 MFA 时明确阻断 - bad session 单独文案;UA 更新为 iOS 18.2 / iPhone12,8
This commit is contained in:
@@ -41,13 +41,13 @@ ACCESS_KEY=
|
||||
SIMYO_CLIENT_TOKEN=
|
||||
# 可选:固定 X-Device-ID(UUID,官方 App 每个请求必带;未设则进程内自动生成)
|
||||
# SIMYO_DEVICE_ID=
|
||||
# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 27.0 / iPhone16,1 抓包)
|
||||
# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 18.2 / iPhone12,8 抓包)
|
||||
# 与 src/simyo/js/modules/client-identity.js 保持同步
|
||||
# SIMYO_CLIENT_VERSION=4.28.0
|
||||
# SIMYO_CLIENT_PLATFORM=ios
|
||||
# SIMYO_IOS_VERSION=27.0
|
||||
# SIMYO_DEVICE_MODEL=iPhone16,1
|
||||
# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)
|
||||
# SIMYO_IOS_VERSION=18.2
|
||||
# SIMYO_DEVICE_MODEL=iPhone12,8
|
||||
# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)
|
||||
|
||||
# Giffgaff OAuth token exchange 回调 URI
|
||||
# 注意:该变量只影响服务端 token exchange;前端授权跳转 URI 仍在 src/giffgaff/js/modules/api-config.js 中配置。
|
||||
|
||||
14
netlify.toml
14
netlify.toml
@@ -35,22 +35,14 @@
|
||||
|
||||
# API代理重定向(用于解决CORS问题)
|
||||
# 通用 Simyo API 代理 (webapi - 新版API)
|
||||
# User-Agent 覆盖为官方 iOS 4.28.0 抓包值(浏览器无法可靠设置 UA)
|
||||
# User-Agent 覆盖为官方 iOS 4.28.0 eSIM 更换抓包值(浏览器无法可靠设置 UA)
|
||||
# 与 src/simyo/js/modules/client-identity.js 保持同步
|
||||
[[redirects]]
|
||||
from = "/api/simyo/*"
|
||||
to = "https://appapi.simyo.nl/webapi/api/v1/:splat"
|
||||
status = 200
|
||||
force = true
|
||||
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"}
|
||||
|
||||
# v2 API 代理(用于特定端点如可用验证方式查询)
|
||||
[[redirects]]
|
||||
from = "/api/simyo/esim.availableValidationMethods"
|
||||
to = "https://appapi.simyo.nl/webapi/api/v2/esim.availableValidationMethods"
|
||||
status = 200
|
||||
force = true
|
||||
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"}
|
||||
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)"}
|
||||
|
||||
# 保留旧版 simyoapi 路径的兼容性(如果需要)
|
||||
[[redirects]]
|
||||
@@ -58,7 +50,7 @@
|
||||
to = "https://appapi.simyo.nl/simyoapi/api/v1/:splat"
|
||||
status = 200
|
||||
force = true
|
||||
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"}
|
||||
headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)"}
|
||||
|
||||
[[headers]]
|
||||
# 限制 Simyo 代理仅允许指定站点跨域访问
|
||||
|
||||
@@ -29,11 +29,11 @@ const { parseOrigins, isAllowedOrigin: _isAllowedOrigin, resolveCorsOrigin: _res
|
||||
const origins = parseOrigins(process.env.ALLOWED_ORIGIN);
|
||||
const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins);
|
||||
const getCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins);
|
||||
// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 抓包)
|
||||
// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 eSIM 更换 HAR)
|
||||
const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios';
|
||||
const DEFAULT_SIMYO_CLIENT_VERSION = '4.28.0';
|
||||
const DEFAULT_SIMYO_IOS_VERSION = '27.0';
|
||||
const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone16,1';
|
||||
const DEFAULT_SIMYO_IOS_VERSION = '18.2';
|
||||
const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone12,8';
|
||||
// 版本号与括号之间为两个空格
|
||||
const DEFAULT_SIMYO_USER_AGENT =
|
||||
`MijnSimyoFT/${DEFAULT_SIMYO_CLIENT_VERSION} (iOS ${DEFAULT_SIMYO_IOS_VERSION}; ${DEFAULT_SIMYO_DEVICE_MODEL})`;
|
||||
|
||||
@@ -558,9 +558,12 @@ export const TRANSLATIONS = {
|
||||
'simyo.errors.requirePassword': '请输入密码',
|
||||
'simyo.errors.invalidCodeFormat': '请输入6位数字验证码',
|
||||
'simyo.device.applyFailed': '设备更换请求失败',
|
||||
'simyo.device.applySuccess': '验证码已成功发送到邮箱',
|
||||
'simyo.device.applySuccessAlreadyOrdered': '验证码已发送到邮箱(您的账户已有eSIM配置,系统正在处理设备更换)',
|
||||
'simyo.device.applySuccess': '验证码已成功发送到邮箱,请查收后在下方输入',
|
||||
'simyo.device.applySuccessAlreadyOrdered': '已有进行中的 eSIM 更换订单,验证码应已发送到邮箱。请直接输入验证码,无需重复申请',
|
||||
'simyo.device.readyForDownload': '验证已完成,可直接获取 eSIM 二维码 / LPA',
|
||||
'simyo.device.statusFailed': '查询 eSIM 状态失败',
|
||||
'simyo.device.log.applyFailed': '设备更换请求失败:',
|
||||
'simyo.device.log.statusFailed': '查询 simcard 状态失败,将继续尝试申请:',
|
||||
'simyo.device.queryFailed': '查询可用验证方式失败',
|
||||
'simyo.device.querySuccess': '查询成功',
|
||||
'simyo.device.log.queryFailed': '查询可用验证方式失败:',
|
||||
@@ -573,6 +576,7 @@ export const TRANSLATIONS = {
|
||||
'simyo.device.log.verifyFailed': '验证码验证失败:',
|
||||
'simyo.auth.loginMissingToken': '登录失败,未获取到session token',
|
||||
'simyo.auth.loginSuccess': '登录成功',
|
||||
'simyo.auth.mfaRequired': '账号已开启登录二次验证(MFA:{status} / {method})。本工具暂不支持在线完成 MFA。请先在官方 Mijn Simyo App 关闭 MFA,或完成验证后再试',
|
||||
'simyo.auth.log.loginFailed': '登录失败:',
|
||||
'simyo.esim.errors.notFound': '未找到有效的eSIM信息',
|
||||
'simyo.esim.status.fetchSuccess': 'eSIM信息获取成功',
|
||||
@@ -589,6 +593,7 @@ export const TRANSLATIONS = {
|
||||
'simyo.api.error.missingDeviceId': '缺少设备标识(X-Device-ID)。请刷新页面后重试',
|
||||
'simyo.api.error.invalidCredentials': '手机号或密码不正确,请检查后重试',
|
||||
'simyo.api.error.sessionExpired': '登录已失效,请重新登录',
|
||||
'simyo.api.error.badSession': '会话无效或未完成登录验证(bad session)。请重新登录;若登录后立刻收到验证码,请先在官方 App 关闭 MFA 或完成验证后再使用本工具',
|
||||
'simyo.api.error.rateLimited': '请求过于频繁,请稍后再试',
|
||||
'simyo.api.error.badRequest': '请求参数有误,请检查输入后重试',
|
||||
'simyo.api.error.unauthorized': '未授权或登录已过期,请重新登录',
|
||||
@@ -864,9 +869,12 @@ export const TRANSLATIONS = {
|
||||
'simyo.errors.requirePassword': 'Enter your password.',
|
||||
'simyo.errors.invalidCodeFormat': 'Enter the six-digit verification code.',
|
||||
'simyo.device.applyFailed': 'Device change request failed.',
|
||||
'simyo.device.applySuccess': 'Verification code sent to your email successfully.',
|
||||
'simyo.device.applySuccessAlreadyOrdered': 'Code sent to email (You already have an eSIM. Processing device change...)',
|
||||
'simyo.device.applySuccess': 'Verification code sent to your email. Enter it below.',
|
||||
'simyo.device.applySuccessAlreadyOrdered': 'An eSIM change is already in progress; the code should be in your email. Enter it below without requesting again.',
|
||||
'simyo.device.readyForDownload': 'Verification complete. You can fetch the eSIM QR / LPA now.',
|
||||
'simyo.device.statusFailed': 'Failed to query eSIM status.',
|
||||
'simyo.device.log.applyFailed': 'New eSIM request failed:',
|
||||
'simyo.device.log.statusFailed': 'simcard status query failed; will still try apply:',
|
||||
'simyo.device.queryFailed': 'Failed to query available validation methods.',
|
||||
'simyo.device.querySuccess': 'Query succeeded.',
|
||||
'simyo.device.log.queryFailed': 'Query validation methods failed:',
|
||||
@@ -879,6 +887,7 @@ export const TRANSLATIONS = {
|
||||
'simyo.device.log.verifyFailed': 'Verification failed:',
|
||||
'simyo.auth.loginMissingToken': 'Login succeeded but no session token was returned.',
|
||||
'simyo.auth.loginSuccess': 'Signed in successfully.',
|
||||
'simyo.auth.mfaRequired': 'Login MFA is enabled on this account ({status} / {method}). This tool cannot complete MFA online. Disable MFA in the official Mijn Simyo app, or finish verification there first.',
|
||||
'simyo.auth.log.loginFailed': 'Login failed:',
|
||||
'simyo.esim.errors.notFound': 'No valid eSIM profile found.',
|
||||
'simyo.esim.status.fetchSuccess': 'eSIM details retrieved.',
|
||||
@@ -895,6 +904,7 @@ export const TRANSLATIONS = {
|
||||
'simyo.api.error.missingDeviceId': 'Missing device id (X-Device-ID). Please refresh the page and try again.',
|
||||
'simyo.api.error.invalidCredentials': 'Incorrect phone number or password. Please check and try again.',
|
||||
'simyo.api.error.sessionExpired': 'Your session has expired. Please sign in again.',
|
||||
'simyo.api.error.badSession': 'Invalid or incomplete session (bad session). Sign in again. If you receive a code immediately after login, disable MFA in the official Mijn Simyo app or complete verification first.',
|
||||
'simyo.api.error.rateLimited': 'Too many requests. Please wait a moment and try again.',
|
||||
'simyo.api.error.badRequest': 'Invalid request. Please check your input and try again.',
|
||||
'simyo.api.error.unauthorized': 'Unauthorized or session expired. Please sign in again.',
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* 请求头字段对齐官方 Mijn Simyo iOS 抓包:
|
||||
* X-Client-Token / X-Client-Platform / X-Client-Version / X-Device-ID / User-Agent
|
||||
* 版本与 UA 见 client-identity.js(单一事实来源)
|
||||
*
|
||||
* eSIM 更换主路径(HAR 2026-07-22,仅 EMAIL):
|
||||
* POST /sessions → GET /settings/simcard → POST /settings/simcard
|
||||
* → POST /esim/verify-code → GET /esim/get-by-customer
|
||||
*/
|
||||
|
||||
import { isNetlifyEnvironment } from './utils.js';
|
||||
@@ -34,7 +38,6 @@ function generateUuidV4() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID().toUpperCase();
|
||||
}
|
||||
// 无 crypto.randomUUID 时的回退(仍保证 RFC4122 形态)
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
crypto.getRandomValues(bytes);
|
||||
@@ -61,7 +64,6 @@ function generateUuidV4() {
|
||||
|
||||
/**
|
||||
* 获取或创建稳定的 X-Device-ID
|
||||
* 官方 App 每个请求都携带 UUID 形态的设备 ID;缺失会返回 400 missing X-Device-ID
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getOrCreateDeviceId() {
|
||||
@@ -85,7 +87,7 @@ export function getOrCreateDeviceId() {
|
||||
}
|
||||
|
||||
/**
|
||||
* API端点配置
|
||||
* API端点配置(对齐官方 webapi 路径)
|
||||
*/
|
||||
export function getApiEndpoints() {
|
||||
const isNetlify = isNetlifyEnvironment();
|
||||
@@ -93,30 +95,27 @@ export function getApiEndpoints() {
|
||||
const localBase = isBrowserServed ? '' : 'http://localhost:3000';
|
||||
|
||||
return {
|
||||
// 认证相关
|
||||
login: isNetlify
|
||||
? '/api/simyo/sessions'
|
||||
: `${localBase}/api/simyo/sessions`,
|
||||
|
||||
// eSIM相关
|
||||
getEsim: isNetlify
|
||||
? '/api/simyo/esim/get-by-customer'
|
||||
: `${localBase}/api/simyo/esim/get-by-customer`,
|
||||
|
||||
// 设备更换相关
|
||||
// GET 查询 / POST 申请 共用 settings/simcard
|
||||
simcard: isNetlify
|
||||
? '/api/simyo/settings/simcard'
|
||||
: `${localBase}/api/simyo/settings/simcard`,
|
||||
// 兼容旧命名
|
||||
applyNewEsim: isNetlify
|
||||
? '/api/simyo/settings/simcard'
|
||||
: `${localBase}/api/simyo/settings/simcard`,
|
||||
|
||||
verifyCode: isNetlify
|
||||
? '/api/simyo/esim/verify-code'
|
||||
: `${localBase}/api/simyo/esim/verify-code`,
|
||||
|
||||
// 新增:查询可用的验证方式 (v2 API)
|
||||
availableValidationMethods: isNetlify
|
||||
? '/api/simyo/esim.availableValidationMethods'
|
||||
: `${localBase}/api/simyo/esim.availableValidationMethods`,
|
||||
getEsim: isNetlify
|
||||
? '/api/simyo/esim/get-by-customer'
|
||||
: `${localBase}/api/simyo/esim/get-by-customer`,
|
||||
|
||||
// 安装确认
|
||||
confirmInstall: isNetlify
|
||||
? '/api/simyo/esim/reorder-profile-installed'
|
||||
: `${localBase}/api/simyo/esim/reorder-profile-installed`
|
||||
@@ -148,10 +147,8 @@ export function createHeaders(includeSession = false, sessionToken = '') {
|
||||
|
||||
/**
|
||||
* 将 Simyo / 代理返回的原始错误映射为用户可读文案
|
||||
* 优先匹配已知业务短语,其次按 HTTP 状态码,最后保留原文或通用失败
|
||||
*
|
||||
* @param {number} status - HTTP 状态码(业务体错误可传 0)
|
||||
* @param {object|string|null} data - 响应体或原始字符串
|
||||
* @param {number} status
|
||||
* @param {object|string|null} data
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mapSimyoErrorMessage(status, data) {
|
||||
@@ -167,7 +164,16 @@ export function mapSimyoErrorMessage(status, data) {
|
||||
const raw = rawParts.filter((p) => p != null && String(p).trim() !== '').map(String).join(' ');
|
||||
const normalized = raw.toLowerCase();
|
||||
|
||||
// 已知业务错误(Simyo 原文 / 代理透传)
|
||||
// 会话类(须先于笼统 unauthorized / credentials)
|
||||
if (
|
||||
normalized.includes('bad session') ||
|
||||
(normalized.includes('unauthoris') && normalized.includes('session')) ||
|
||||
(normalized.includes('unauthorized') && normalized.includes('session')) ||
|
||||
(normalized.includes('session') && (normalized.includes('expired') || normalized.includes('invalid')))
|
||||
) {
|
||||
return t('simyo.api.error.badSession');
|
||||
}
|
||||
|
||||
if (normalized.includes('missing x-device-id') || normalized.includes('x-device-id')) {
|
||||
return t('simyo.api.error.missingDeviceId');
|
||||
}
|
||||
@@ -175,19 +181,14 @@ export function mapSimyoErrorMessage(status, data) {
|
||||
normalized.includes('invalid credentials') ||
|
||||
normalized.includes('invalid password') ||
|
||||
normalized.includes('authentication failed') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('wrong password')
|
||||
) {
|
||||
return t('simyo.api.error.invalidCredentials');
|
||||
}
|
||||
if (normalized.includes('session') && (normalized.includes('expired') || normalized.includes('invalid'))) {
|
||||
return t('simyo.api.error.sessionExpired');
|
||||
}
|
||||
if (normalized.includes('too many') || normalized.includes('rate limit') || normalized.includes('throttl')) {
|
||||
return t('simyo.api.error.rateLimited');
|
||||
}
|
||||
|
||||
// HTTP 状态码语义(含历史上 426 客户端版本过旧)
|
||||
const statusMap = {
|
||||
400: 'simyo.api.error.badRequest',
|
||||
401: 'simyo.api.error.unauthorized',
|
||||
@@ -202,10 +203,8 @@ export function mapSimyoErrorMessage(status, data) {
|
||||
504: 'simyo.api.error.timeout'
|
||||
};
|
||||
if (status && statusMap[status]) {
|
||||
// 有状态码映射时,优先友好文案;附带简短原文便于排查(过长则截断)
|
||||
const friendly = t(statusMap[status]);
|
||||
if (raw && raw.length > 0 && raw.length <= 80 && !/^HTTP\s+\d+/i.test(raw)) {
|
||||
// 避免重复拼接已是友好文案的情况
|
||||
if (!friendly.includes(raw) && normalized !== friendly.toLowerCase()) {
|
||||
return `${friendly}(${raw})`;
|
||||
}
|
||||
@@ -214,7 +213,6 @@ export function mapSimyoErrorMessage(status, data) {
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
// 纯 HTTP NNN 形态的原文也换成通用失败
|
||||
if (/^HTTP\s+\d+$/i.test(raw.trim())) {
|
||||
return t('simyo.api.error.genericWithStatus', { status: raw.replace(/\D/g, '') || status || '?' });
|
||||
}
|
||||
@@ -227,9 +225,44 @@ export function mapSimyoErrorMessage(status, data) {
|
||||
return t('simyo.api.error.generic');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Simyo result 体是否表示业务成功
|
||||
* 官方登录/查询常无 result.success,仅有 sessionToken / eSimStatus 等字段
|
||||
* @param {object} result
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSimyoResultSuccessful(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (result.success === false) {
|
||||
return false;
|
||||
}
|
||||
if (result.success === true) {
|
||||
return true;
|
||||
}
|
||||
// 登录
|
||||
if (result.sessionToken) {
|
||||
return true;
|
||||
}
|
||||
// 取 eSIM
|
||||
if (result.activationCode) {
|
||||
return true;
|
||||
}
|
||||
// simcard 状态查询
|
||||
if (result.eSimStatus || result.canCreateESim || result.canCreateSimcard) {
|
||||
return true;
|
||||
}
|
||||
// 申请更换:reason + remainingNumberOfTries
|
||||
if (result.reason != null || result.remainingNumberOfTries != null) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理API响应
|
||||
* @param {Response} response - Fetch API响应对象
|
||||
* @param {Response} response
|
||||
*/
|
||||
export async function handleApiResponse(response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
@@ -238,7 +271,6 @@ export async function handleApiResponse(response) {
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
// JSON 解析失败(空体/坏体),回退为空对象
|
||||
data = {};
|
||||
}
|
||||
} else {
|
||||
@@ -246,32 +278,27 @@ export async function handleApiResponse(response) {
|
||||
data = text ? { message: text } : {};
|
||||
}
|
||||
|
||||
// HTTP 错误:映射为用户可读中文/英文,避免只显示「HTTP 426」
|
||||
if (!response.ok) {
|
||||
throw new Error(mapSimyoErrorMessage(response.status, data));
|
||||
}
|
||||
|
||||
// 非 JSON 响应直接返回文本内容
|
||||
if (!contentType.includes('application/json')) {
|
||||
return { success: true, result: data };
|
||||
}
|
||||
|
||||
// 统一兼容三种返回格式:
|
||||
// 1. 本地旧代理包装: { success, result, message }
|
||||
// 2. 直通 Simyo: { result: {...} }
|
||||
// 3. Simyo 展平成功结构: { success: true, ... }
|
||||
// 本地代理包装: { success, result, message }
|
||||
if (data.success === true && data.result) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// 直通 Simyo: { result: {...} }
|
||||
if (data.result) {
|
||||
const nestedSuccess = data.result.success;
|
||||
// 业务失败(HTTP 200 但 success=false / 无 success 字段)也走友好文案
|
||||
if (nestedSuccess === false) {
|
||||
if (data.result.success === false) {
|
||||
throw new Error(mapSimyoErrorMessage(0, data));
|
||||
}
|
||||
const ok = isSimyoResultSuccessful(data.result);
|
||||
return {
|
||||
success: nestedSuccess === true,
|
||||
success: ok,
|
||||
result: data.result,
|
||||
message: data.message || data.result.message || data.result.reason
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Simyo认证处理模块
|
||||
* 负责账户登录认证
|
||||
* 负责账户登录认证;识别 mfaStatus,未关闭 MFA 时阻断后续业务
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
@@ -9,6 +9,29 @@ import { validatePhoneNumber } from './utils.js';
|
||||
import { t } from '../../../js/modules/i18n.js';
|
||||
import Logger from '../../../js/modules/logger.js';
|
||||
|
||||
/**
|
||||
* 判断登录返回的 mfaStatus 是否允许继续业务
|
||||
* 官方 HAR:DISABLED_BY_CUSTOMER 表示客户关闭 MFA
|
||||
* @param {string} mfaStatus
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isMfaDisabledOrComplete(mfaStatus) {
|
||||
if (mfaStatus == null || mfaStatus === '') {
|
||||
return true;
|
||||
}
|
||||
const s = String(mfaStatus).toUpperCase();
|
||||
return (
|
||||
s === 'DISABLED_BY_CUSTOMER' ||
|
||||
s === 'DISABLED' ||
|
||||
s === 'NONE' ||
|
||||
s === 'OFF' ||
|
||||
s === 'OK' ||
|
||||
s === 'VERIFIED' ||
|
||||
s === 'COMPLETED' ||
|
||||
s === 'NOT_REQUIRED'
|
||||
);
|
||||
}
|
||||
|
||||
export class AuthHandler {
|
||||
constructor() {
|
||||
this.apiEndpoints = getApiEndpoints();
|
||||
@@ -16,12 +39,11 @@ export class AuthHandler {
|
||||
|
||||
/**
|
||||
* 登录Simyo账户
|
||||
* @param {string} phoneNumber - 手机号
|
||||
* @param {string} password - 密码
|
||||
* @returns {Promise<Object>} 登录结果
|
||||
* @param {string} phoneNumber
|
||||
* @param {string} password
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async login(phoneNumber, password) {
|
||||
// 验证手机号格式
|
||||
if (!validatePhoneNumber(phoneNumber)) {
|
||||
throw new Error(t('simyo.errors.invalidPhone'));
|
||||
}
|
||||
@@ -41,24 +63,42 @@ export class AuthHandler {
|
||||
});
|
||||
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
// 提取session token
|
||||
const sessionToken = data.result && data.result.sessionToken;
|
||||
const result = data.result || {};
|
||||
const sessionToken = result.sessionToken;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error(data.message || t('simyo.auth.loginMissingToken'));
|
||||
}
|
||||
|
||||
// 仅保存会话令牌与手机号;密码不进入状态
|
||||
const mfaStatus = result.mfaStatus || '';
|
||||
const mfaMethod = result.mfaMethod || '';
|
||||
const methodHint = result.methodHint || '';
|
||||
|
||||
// 先落会话,便于诊断;MFA 未关闭则禁止进入设备更换
|
||||
stateManager.setState({
|
||||
sessionToken: sessionToken,
|
||||
phoneNumber: phoneNumber
|
||||
phoneNumber: phoneNumber,
|
||||
mfaStatus: mfaStatus,
|
||||
mfaMethod: mfaMethod
|
||||
});
|
||||
|
||||
if (!isMfaDisabledOrComplete(mfaStatus)) {
|
||||
const err = new Error(
|
||||
t('simyo.auth.mfaRequired', {
|
||||
status: mfaStatus,
|
||||
method: mfaMethod || methodHint || '—'
|
||||
})
|
||||
);
|
||||
err.code = 'MFA_REQUIRED';
|
||||
err.mfaStatus = mfaStatus;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: t('simyo.auth.loginSuccess'),
|
||||
sessionToken: sessionToken
|
||||
sessionToken: sessionToken,
|
||||
mfaStatus: mfaStatus
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(t('simyo.auth.log.loginFailed'), error);
|
||||
@@ -66,22 +106,13 @@ export class AuthHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录状态
|
||||
* @returns {boolean} 是否已登录
|
||||
*/
|
||||
isLoggedIn() {
|
||||
return !!stateManager.get('sessionToken');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前session token
|
||||
* @returns {string} session token
|
||||
*/
|
||||
getSessionToken() {
|
||||
return stateManager.get('sessionToken');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const authHandler = new AuthHandler();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Simyo 官方 App 客户端身份(单一事实来源)
|
||||
*
|
||||
* 对齐 Mijn Simyo iOS 抓包(2026-07-22 HAR):
|
||||
* 对齐 Mijn Simyo iOS 抓包(2026-07-22 eSIM 更换 HAR):
|
||||
* - App 版本: 4.28.0
|
||||
* - API User-Agent: MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)
|
||||
* - API User-Agent: MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)
|
||||
* (版本号与括号之间为两个空格)
|
||||
* - 平台: ios
|
||||
* - 设备型号: iPhone16,1
|
||||
* - 系统版本: 27.0
|
||||
* - 设备型号: iPhone12,8
|
||||
* - 系统版本: 18.2
|
||||
*
|
||||
* 修改此处后请同步:
|
||||
* - server.js 中的 DEFAULT_SIMYO_* 常量
|
||||
@@ -26,10 +26,10 @@ export const SIMYO_CLIENT_PLATFORM = 'ios';
|
||||
export const SIMYO_CLIENT_TOKEN = 'e77b7e2f43db41bb95b17a2a11581a38';
|
||||
|
||||
/** 抓包中的 iOS 系统版本 */
|
||||
export const SIMYO_IOS_VERSION = '27.0';
|
||||
export const SIMYO_IOS_VERSION = '18.2';
|
||||
|
||||
/** 抓包中的设备型号(identifier) */
|
||||
export const SIMYO_DEVICE_MODEL = 'iPhone16,1';
|
||||
export const SIMYO_DEVICE_MODEL = 'iPhone12,8';
|
||||
|
||||
/**
|
||||
* appapi 请求使用的 User-Agent
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Simyo设备更换处理模块
|
||||
* 负责设备更换的完整流程
|
||||
* 对齐官方 App HAR(仅 EMAIL 验证):
|
||||
* GET settings/simcard → POST settings/simcard(EMAIL) → POST esim/verify-code
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
@@ -9,41 +10,109 @@ import { validateVerificationCode } from './utils.js';
|
||||
import { t } from '../../../js/modules/i18n.js';
|
||||
import Logger from '../../../js/modules/logger.js';
|
||||
|
||||
/** 官方 eSimStatus 状态 */
|
||||
export const ESIM_STATUS = {
|
||||
START_REQUEST: 'ESIM_START_REQUEST',
|
||||
WAITING_FOR_VALIDATION_CODE: 'ESIM_REQUEST_WAITING_FOR_VALIDATION_CODE',
|
||||
READY_FOR_DOWNLOAD: 'ESIM_REQUEST_READY_FOR_DOWNLOAD'
|
||||
};
|
||||
|
||||
export class DeviceChangeHandler {
|
||||
constructor() {
|
||||
this.apiEndpoints = getApiEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求设备更换(支持邮箱验证)
|
||||
* @param {string} validationMethod - 验证方式 (默认: EMAIL)
|
||||
* @returns {Promise<Object>} 请求结果
|
||||
* 读取当前 session token,未登录则抛错
|
||||
* @returns {string}
|
||||
*/
|
||||
async applyNewEsim(validationMethod = 'EMAIL') {
|
||||
requireSessionToken() {
|
||||
const sessionToken = stateManager.get('sessionToken');
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error(t('simyo.errors.requireLogin'));
|
||||
}
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /settings/simcard — 查询 eSIM 订单状态
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getSimcardStatus() {
|
||||
const sessionToken = this.requireSessionToken();
|
||||
const response = await fetch(this.apiEndpoints.simcard, {
|
||||
method: 'GET',
|
||||
headers: createHeaders(true, sessionToken)
|
||||
});
|
||||
const data = await handleApiResponse(response);
|
||||
if (!data.result) {
|
||||
throw new Error(data.message || t('simyo.device.statusFailed'));
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求设备更换(固定 EMAIL,对齐官方 body)
|
||||
* 会先 GET 状态:已在等待验证码 / 可下载时避免重复下单
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async applyNewEsim() {
|
||||
const sessionToken = this.requireSessionToken();
|
||||
|
||||
try {
|
||||
// 1) 查询当前状态
|
||||
let status = null;
|
||||
try {
|
||||
status = await this.getSimcardStatus();
|
||||
} catch (statusError) {
|
||||
Logger.warn(t('simyo.device.log.statusFailed'), statusError);
|
||||
// 查询失败仍尝试 POST,兼容旧代理
|
||||
}
|
||||
|
||||
if (status) {
|
||||
if (status.eSimStatus === ESIM_STATUS.WAITING_FOR_VALIDATION_CODE) {
|
||||
return {
|
||||
success: true,
|
||||
message: t('simyo.device.applySuccessAlreadyOrdered'),
|
||||
remainingTries: status.remainingNumberOfTries,
|
||||
eSimStatus: status.eSimStatus,
|
||||
reason: status.canCreateESim || 'AlreadyOrderedSimcardEsim',
|
||||
alreadyPending: true
|
||||
};
|
||||
}
|
||||
if (status.eSimStatus === ESIM_STATUS.READY_FOR_DOWNLOAD) {
|
||||
return {
|
||||
success: true,
|
||||
message: t('simyo.device.readyForDownload'),
|
||||
remainingTries: status.remainingNumberOfTries,
|
||||
eSimStatus: status.eSimStatus,
|
||||
readyForDownload: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) POST 申请(仅 EMAIL)
|
||||
const response = await fetch(this.apiEndpoints.applyNewEsim, {
|
||||
method: 'POST',
|
||||
headers: createHeaders(true, sessionToken),
|
||||
body: JSON.stringify({
|
||||
initialValidationMethod: validationMethod,
|
||||
initialValidationMethod: 'EMAIL',
|
||||
esim: true
|
||||
})
|
||||
});
|
||||
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data.success || !data.result) {
|
||||
if (!data.result) {
|
||||
throw new Error(data.message || t('simyo.device.applyFailed'));
|
||||
}
|
||||
|
||||
// 根据reason字段选择不同的提示消息
|
||||
let message = data.message || t('simyo.device.applySuccess');
|
||||
// 官方:success:true + reason Available | AlreadyOrderedSimcardEsim
|
||||
if (data.result.success === false) {
|
||||
throw new Error(data.message || data.result.reason || t('simyo.device.applyFailed'));
|
||||
}
|
||||
|
||||
let message = t('simyo.device.applySuccess');
|
||||
if (data.result.reason === 'AlreadyOrderedSimcardEsim') {
|
||||
message = t('simyo.device.applySuccessAlreadyOrdered');
|
||||
}
|
||||
@@ -51,7 +120,9 @@ export class DeviceChangeHandler {
|
||||
return {
|
||||
success: true,
|
||||
message: message,
|
||||
remainingTries: data.result.remainingNumberOfTries
|
||||
remainingTries: data.result.remainingNumberOfTries,
|
||||
reason: data.result.reason,
|
||||
eSimStatus: ESIM_STATUS.WAITING_FOR_VALIDATION_CODE
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(t('simyo.device.log.applyFailed'), error);
|
||||
@@ -60,16 +131,12 @@ export class DeviceChangeHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param {string} validationCode - 验证码
|
||||
* @returns {Promise<Object>} 验证结果
|
||||
* 验证邮箱验证码 POST /esim/verify-code
|
||||
* @param {string} validationCode
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async verifyCode(validationCode) {
|
||||
const sessionToken = stateManager.get('sessionToken');
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error(t('simyo.errors.requireLogin'));
|
||||
}
|
||||
const sessionToken = this.requireSessionToken();
|
||||
|
||||
if (!validateVerificationCode(validationCode)) {
|
||||
throw new Error(t('simyo.errors.invalidCodeFormat'));
|
||||
@@ -86,17 +153,17 @@ export class DeviceChangeHandler {
|
||||
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data.success) {
|
||||
if (!data.result || data.result.success === false) {
|
||||
throw new Error(data.message || t('simyo.device.verifyFailed'));
|
||||
}
|
||||
|
||||
// 保存验证码到状态
|
||||
stateManager.set('validationCode', validationCode);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || t('simyo.device.verifySuccess'),
|
||||
remainingTries: data.result && data.result.remainingNumberOfTries
|
||||
remainingTries: data.result.remainingNumberOfTries,
|
||||
eSimStatus: ESIM_STATUS.READY_FOR_DOWNLOAD
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(t('simyo.device.log.verifyFailed'), error);
|
||||
@@ -105,5 +172,4 @@ export class DeviceChangeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const deviceChangeHandler = new DeviceChangeHandler();
|
||||
|
||||
@@ -32,8 +32,9 @@ export class EsimService {
|
||||
});
|
||||
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data.success || !data.result || !data.result.activationCode) {
|
||||
|
||||
// 官方 HAR:{ result: { activationCode, success: true, errorCode: 0 } }
|
||||
if (!data.result || !data.result.activationCode) {
|
||||
throw new Error(data.message || t('simyo.esim.errors.notFound'));
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export class StateManager {
|
||||
// 认证相关(密码仅在登录请求时使用,不进入状态)
|
||||
sessionToken: "",
|
||||
phoneNumber: "",
|
||||
mfaStatus: "",
|
||||
mfaMethod: "",
|
||||
|
||||
// eSIM相关
|
||||
activationCode: "",
|
||||
@@ -96,6 +98,8 @@ export class StateManager {
|
||||
const sessionData = {
|
||||
sessionToken: this.state.sessionToken,
|
||||
phoneNumber: this.state.phoneNumber,
|
||||
mfaStatus: this.state.mfaStatus,
|
||||
mfaMethod: this.state.mfaMethod,
|
||||
activationCode: this.state.activationCode,
|
||||
validationCode: this.state.validationCode,
|
||||
isDeviceChange: this.state.isDeviceChange,
|
||||
@@ -130,6 +134,8 @@ export class StateManager {
|
||||
...this.state,
|
||||
sessionToken: data.sessionToken || "",
|
||||
phoneNumber: data.phoneNumber || "",
|
||||
mfaStatus: data.mfaStatus || "",
|
||||
mfaMethod: data.mfaMethod || "",
|
||||
activationCode: data.activationCode || "",
|
||||
validationCode: data.validationCode || "",
|
||||
isDeviceChange: typeof data.isDeviceChange === 'boolean' ? data.isDeviceChange : false,
|
||||
@@ -154,6 +160,8 @@ export class StateManager {
|
||||
this.state = {
|
||||
sessionToken: "",
|
||||
phoneNumber: "",
|
||||
mfaStatus: "",
|
||||
mfaMethod: "",
|
||||
activationCode: "",
|
||||
validationCode: "",
|
||||
isDeviceChange: false,
|
||||
|
||||
@@ -295,11 +295,18 @@ class SimyoApp {
|
||||
|
||||
Logger.log('[Simyo] 调用 deviceChangeHandler.applyNewEsim()...');
|
||||
const result = await deviceChangeHandler.applyNewEsim();
|
||||
Logger.log('[Simyo] 设备更换请求成功:', result.message);
|
||||
Logger.log('[Simyo] 设备更换请求成功:', result.message, result.eSimStatus || '');
|
||||
|
||||
uiController.showStatus(statusEl, result.message || t('simyo.app.status.applySuccess'), "success");
|
||||
|
||||
// 新版流程使用邮箱验证码:引导用户直接输入验证码
|
||||
// 已验证完成:可直接跳过验证码进入获取 eSIM
|
||||
if (result.readyForDownload) {
|
||||
await delay(1500);
|
||||
uiController.skipDeviceChange();
|
||||
return;
|
||||
}
|
||||
|
||||
// 邮箱验证码:引导用户输入(含已下单仅等待验证码)
|
||||
const codeInput = document.getElementById('validationCodeInput');
|
||||
const verifyBtn = document.getElementById('verifyCodeBtn');
|
||||
if (codeInput) {
|
||||
@@ -308,7 +315,6 @@ class SimyoApp {
|
||||
if (verifyBtn) verifyBtn.disabled = !hasValidCode;
|
||||
}
|
||||
|
||||
// 显示下一步提示
|
||||
if (result.nextStep) {
|
||||
await delay(3000);
|
||||
uiController.showStatus(statusEl, result.nextStep, "info");
|
||||
|
||||
@@ -33,11 +33,11 @@ function isAllowedTarget(urlStr) {
|
||||
}
|
||||
}
|
||||
|
||||
// Simyo API配置(与 client-identity.js 同步:iOS 4.28.0 / iOS 27.0 / iPhone16,1)
|
||||
// Simyo API配置(与 client-identity.js 同步:iOS 4.28.0 / iOS 18.2 / iPhone12,8)
|
||||
const crypto = require('crypto');
|
||||
const SIMYO_CLIENT_VERSION = process.env.SIMYO_CLIENT_VERSION || '4.28.0';
|
||||
const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '27.0';
|
||||
const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone16,1';
|
||||
const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '18.2';
|
||||
const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone12,8';
|
||||
const SIMYO_USER_AGENT =
|
||||
process.env.SIMYO_USER_AGENT ||
|
||||
`MijnSimyoFT/${SIMYO_CLIENT_VERSION} (iOS ${SIMYO_IOS_VERSION}; ${SIMYO_DEVICE_MODEL})`;
|
||||
|
||||
@@ -22,17 +22,17 @@ import {
|
||||
|
||||
const UUID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/;
|
||||
/** 官方抓包 UA:版本号后恰好两个空格 */
|
||||
const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)';
|
||||
const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)';
|
||||
|
||||
describe('Simyo client-identity', () => {
|
||||
it('版本 / 设备 / UA 应与 4.28.0 抓包完全一致', () => {
|
||||
it('版本 / 设备 / UA 应与 4.28.0 eSIM 更换抓包一致', () => {
|
||||
expect(SIMYO_CLIENT_VERSION).toBe('4.28.0');
|
||||
expect(SIMYO_CLIENT_PLATFORM).toBe('ios');
|
||||
expect(SIMYO_IOS_VERSION).toBe('27.0');
|
||||
expect(SIMYO_DEVICE_MODEL).toBe('iPhone16,1');
|
||||
expect(SIMYO_IOS_VERSION).toBe('18.2');
|
||||
expect(SIMYO_DEVICE_MODEL).toBe('iPhone12,8');
|
||||
expect(SIMYO_USER_AGENT).toBe(OFFICIAL_UA);
|
||||
// 版本号与括号之间必须是两个空格(不是一个)
|
||||
expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 27\.0; iPhone16,1\)$/);
|
||||
expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 18\.2; iPhone12,8\)$/);
|
||||
expect(simyoClientIdentity.userAgent).toBe(OFFICIAL_UA);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,12 @@ describe('Simyo mapSimyoErrorMessage / handleApiResponse', () => {
|
||||
expect(msg).not.toBe('missing X-Device-ID');
|
||||
});
|
||||
|
||||
it('bad session 应映射为会话无效,而非账号密码错误', () => {
|
||||
const msg = mapSimyoErrorMessage(401, { message: 'Unauthorised - bad session' });
|
||||
expect(msg.toLowerCase()).toMatch(/session|会话|mfa|bad session/);
|
||||
expect(msg).not.toMatch(/密码不正确|incorrect phone number or password/i);
|
||||
});
|
||||
|
||||
it('401 凭据错误原文应优先识别为账号密码错误', () => {
|
||||
const msg = mapSimyoErrorMessage(401, { message: 'Invalid credentials' });
|
||||
expect(msg.toLowerCase()).toMatch(/password|密码|phone|手机号|incorrect|不正确/);
|
||||
@@ -100,6 +106,23 @@ describe('Simyo mapSimyoErrorMessage / handleApiResponse', () => {
|
||||
await expect(handleApiResponse(response)).rejects.toThrow(/升级|upgrade|客户端|client version|refresh|刷新/i);
|
||||
});
|
||||
|
||||
it('handleApiResponse 官方登录体(无 result.success)应视为成功', async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
result: {
|
||||
sessionToken: 'abc',
|
||||
mfaStatus: 'DISABLED_BY_CUSTOMER'
|
||||
}
|
||||
})
|
||||
};
|
||||
const data = await handleApiResponse(response);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.result.sessionToken).toBe('abc');
|
||||
});
|
||||
|
||||
it('handleApiResponse 成功路径仍返回 result', async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Simyo AuthHandler 冒烟测试
|
||||
*/
|
||||
|
||||
import { authHandler } from '../../src/simyo/js/modules/auth-handler.js';
|
||||
import { authHandler, isMfaDisabledOrComplete } from '../../src/simyo/js/modules/auth-handler.js';
|
||||
import { stateManager } from '../../src/simyo/js/modules/state-manager.js';
|
||||
|
||||
describe('Simyo AuthHandler', () => {
|
||||
@@ -24,12 +24,17 @@ describe('Simyo AuthHandler', () => {
|
||||
});
|
||||
|
||||
it('登录成功应写入 token/手机号,且不保留 password', async () => {
|
||||
// 官方 HAR 形态:无顶层 success,result 内无 success 字段
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
result: { sessionToken: 'sess-abc' }
|
||||
result: {
|
||||
sessionToken: 'sess-abc',
|
||||
mfaStatus: 'DISABLED_BY_CUSTOMER',
|
||||
mfaMethod: '',
|
||||
methodHint: ''
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -39,11 +44,30 @@ describe('Simyo AuthHandler', () => {
|
||||
expect(result.sessionToken).toBe('sess-abc');
|
||||
expect(stateManager.get('sessionToken')).toBe('sess-abc');
|
||||
expect(stateManager.get('phoneNumber')).toBe('0612345678');
|
||||
expect(stateManager.get('mfaStatus')).toBe('DISABLED_BY_CUSTOMER');
|
||||
expect(stateManager.get('password')).toBeUndefined();
|
||||
expect(Object.prototype.hasOwnProperty.call(stateManager.getState(), 'password')).toBe(false);
|
||||
expect(authHandler.isLoggedIn()).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA 未关闭时应阻断并提示', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
result: {
|
||||
sessionToken: 'sess-mfa',
|
||||
mfaStatus: 'REQUIRED',
|
||||
mfaMethod: 'EMAIL',
|
||||
methodHint: 'email'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
await expect(authHandler.login('0612345678', 'secret')).rejects.toThrow(/MFA|二次验证|mfa/i);
|
||||
expect(stateManager.get('sessionToken')).toBe('sess-mfa');
|
||||
});
|
||||
|
||||
it('响应缺少 sessionToken 应抛错', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -58,4 +82,10 @@ describe('Simyo AuthHandler', () => {
|
||||
await expect(authHandler.login('0612345678', 'secret')).rejects.toThrow();
|
||||
expect(stateManager.get('sessionToken')).toBe('');
|
||||
});
|
||||
|
||||
it('isMfaDisabledOrComplete 识别官方 DISABLED_BY_CUSTOMER', () => {
|
||||
expect(isMfaDisabledOrComplete('DISABLED_BY_CUSTOMER')).toBe(true);
|
||||
expect(isMfaDisabledOrComplete('REQUIRED')).toBe(false);
|
||||
expect(isMfaDisabledOrComplete('')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Simyo 设备更换处理模块集成测试
|
||||
* Simyo 设备更换处理模块集成测试(对齐 HAR 状态机,仅 EMAIL)
|
||||
*/
|
||||
|
||||
import { deviceChangeHandler } from '../../src/simyo/js/modules/device-change-handler.js';
|
||||
import { deviceChangeHandler, ESIM_STATUS } from '../../src/simyo/js/modules/device-change-handler.js';
|
||||
import { stateManager } from '../../src/simyo/js/modules/state-manager.js';
|
||||
|
||||
describe('Simyo DeviceChangeHandler 集成覆盖', () => {
|
||||
@@ -12,27 +12,40 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
|
||||
global.fetch.mockReset();
|
||||
});
|
||||
|
||||
it('应完成设备更换主流程:applyNewEsim -> verifyCode', async () => {
|
||||
const jsonHeaders = { get: (name) => name === 'content-type' ? 'application/json' : null };
|
||||
it('应完成设备更换主流程:GET simcard → POST simcard → verifyCode', async () => {
|
||||
const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) };
|
||||
global.fetch
|
||||
// GET status
|
||||
.mockResolvedValueOnce({
|
||||
headers: jsonHeaders,
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
result: {
|
||||
message: 'apply success',
|
||||
eSimStatus: ESIM_STATUS.START_REQUEST,
|
||||
canCreateESim: 'Available',
|
||||
remainingNumberOfTries: 3
|
||||
}
|
||||
})
|
||||
})
|
||||
// POST apply
|
||||
.mockResolvedValueOnce({
|
||||
headers: jsonHeaders,
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
result: {
|
||||
message: 'verify success',
|
||||
success: true,
|
||||
reason: 'Available',
|
||||
remainingNumberOfTries: 3
|
||||
}
|
||||
})
|
||||
})
|
||||
// verify
|
||||
.mockResolvedValueOnce({
|
||||
headers: jsonHeaders,
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: {
|
||||
success: true,
|
||||
remainingNumberOfTries: 2
|
||||
}
|
||||
})
|
||||
@@ -42,18 +55,69 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
|
||||
const verifyResult = await deviceChangeHandler.verifyCode('123456');
|
||||
|
||||
expect(applyResult.success).toBe(true);
|
||||
expect(applyResult.reason).toBe('Available');
|
||||
expect(verifyResult.success).toBe(true);
|
||||
expect(stateManager.get('validationCode')).toBe('123456');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/simyo/settings/simcard',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/simyo/settings/simcard',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ initialValidationMethod: 'EMAIL', esim: true })
|
||||
})
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/simyo/esim/verify-code',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('已在等待验证码时应跳过重复 POST', async () => {
|
||||
const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) };
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
headers: jsonHeaders,
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: {
|
||||
eSimStatus: ESIM_STATUS.WAITING_FOR_VALIDATION_CODE,
|
||||
canCreateESim: 'AlreadyOrderedSimcardEsim',
|
||||
remainingNumberOfTries: 2
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const applyResult = await deviceChangeHandler.applyNewEsim();
|
||||
expect(applyResult.success).toBe(true);
|
||||
expect(applyResult.alreadyPending).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/simyo/settings/simcard',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('已 READY_FOR_DOWNLOAD 时应提示可直接获取 eSIM', async () => {
|
||||
const jsonHeaders = { get: (name) => (name === 'content-type' ? 'application/json' : null) };
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
headers: jsonHeaders,
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: {
|
||||
eSimStatus: ESIM_STATUS.READY_FOR_DOWNLOAD,
|
||||
remainingNumberOfTries: 2
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const applyResult = await deviceChangeHandler.applyNewEsim();
|
||||
expect(applyResult.readyForDownload).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,8 +540,8 @@
|
||||
'X-Client-Platform': 'ios',
|
||||
'X-Client-Version': '4.28.0',
|
||||
'X-Device-ID': 'E766D17B-BDF5-43B8-8807-35E43BA129E2',
|
||||
// 官方抓包:版本号后两个空格 + iOS 27.0 + iPhone16,1
|
||||
'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)',
|
||||
// 官方 eSIM 更换抓包:版本号后两个空格 + iOS 18.2 + iPhone12,8
|
||||
'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 18.2; iPhone12,8)',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Connection': 'keep-alive',
|
||||
|
||||
Reference in New Issue
Block a user