mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 00:22:09 +03:00
feat(volcengine): phone/SMS auto-login for console with MFA + identity selection
- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
This commit is contained in:
979
open-sse/services/volcengineConsoleAutoLogin.ts
Normal file
979
open-sse/services/volcengineConsoleAutoLogin.ts
Normal file
@@ -0,0 +1,979 @@
|
||||
/**
|
||||
* VolcengineConsoleAutoLogin — session-based phone/SMS-code login for the
|
||||
* Volcano Engine console.
|
||||
*
|
||||
* Unlike InAppLoginService (which opens a headful browser and requires the
|
||||
* operator to complete login inside a browser on the server machine), this
|
||||
* service drives a headless Chromium through the console's 手机号登录 (phone +
|
||||
* SMS verification code) flow:
|
||||
*
|
||||
* 1. startLogin(phone) — navigate to the login page, switch to the phone
|
||||
* tab, fill the phone number, click 获取验证码. If the console demands an
|
||||
* image captcha, a screenshot is captured for the dashboard to render.
|
||||
* 2. submitCode(code, captcha?) — fill the SMS code (and image captcha when
|
||||
* requested), click 登录 / 注册, then poll the browser context for the
|
||||
* console session cookies (digest / AccountID / csrfToken / userInfo).
|
||||
* 3. cancel() / resendCode() — lifecycle helpers.
|
||||
*
|
||||
* The service only extracts credentials; persisting/binding them to provider
|
||||
* connections stays in the dashboard API layer (volcenginePlanBinding.ts).
|
||||
*
|
||||
* Selector strategy: the console login page is built with Arco Design and
|
||||
* exposes stable element ids (#Tel_input, #Code_input, #VerificatonCodeInput).
|
||||
* Every interaction goes through multi-candidate selector lists so a single
|
||||
* frontend rename does not break the flow. When a candidate list misses or
|
||||
* risk-control (slider) is detected, the session degrades to
|
||||
* `fallback_manual` and the caller can fall back to the pre-existing
|
||||
* headful-browser flow.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type VolcLoginPhase =
|
||||
| "starting"
|
||||
| "sending_code"
|
||||
| "waiting_code"
|
||||
| "captcha_required"
|
||||
| "submitting"
|
||||
| "mfa_waiting"
|
||||
| "identity_required"
|
||||
| "success"
|
||||
| "error"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "fallback_manual";
|
||||
|
||||
export interface VolcLoginSessionView {
|
||||
sessionId: string;
|
||||
phase: VolcLoginPhase;
|
||||
phoneMasked: string;
|
||||
error: string | null;
|
||||
/** data:image/png;base64 screenshot of the image captcha, when required */
|
||||
captchaImage: string | null;
|
||||
/** epoch ms — earliest time a resend should be offered */
|
||||
resendAvailableAt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
/** True while the console demands an MFA step-up code (second SMS code) */
|
||||
mfaRequired?: boolean;
|
||||
/** Identity options scraped from /auth/login/select_identity, when required */
|
||||
identityOptions?: Array<{ index: number; label: string }>;
|
||||
/** Credentials (console cookies) — only present after success */
|
||||
credentials?: Record<string, string>;
|
||||
/** Set by the API layer after binding plans (not part of this service) */
|
||||
binding?: unknown;
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
/** Total session timeout in ms (default 300_000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface SubmitCodeOptions {
|
||||
/** Extra wait for cookie polling after submit (default 90_000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/** Injectable delays — tests shrink these to keep the suite fast. */
|
||||
export interface ServiceDelays {
|
||||
pageSettleMs?: number;
|
||||
tabSwitchMs?: number;
|
||||
sendCodeSettleMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
resendCooldownMs?: number;
|
||||
}
|
||||
|
||||
// ─── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LOGIN_URL = "https://console.volcengine.com/auth/login";
|
||||
/** Landing page the manual headful flow uses — the console app issues the
|
||||
* remaining session cookies (AccountID/userInfo) once it runs. */
|
||||
const ARK_CONSOLE_URL =
|
||||
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
|
||||
|
||||
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
|
||||
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
|
||||
|
||||
const DEFAULT_SESSION_TIMEOUT = 300_000;
|
||||
const SUBMIT_COOKIE_TIMEOUT = 90_000;
|
||||
const CAPTURE_POLL_INTERVAL = 1_000;
|
||||
const RESEND_COOLDOWN_MS = 60_000;
|
||||
const MAX_ACTIVE_SESSIONS = 2;
|
||||
|
||||
/** Multi-candidate selectors — first visible candidate wins. */
|
||||
const SELECTORS = {
|
||||
phoneTab: ['.arco-tabs-header-title:has-text("手机号登录")', "text=手机号登录"],
|
||||
phoneInput: ["#Tel_input", 'input[name="Tel"]', 'input[placeholder*="手机号"]'],
|
||||
smsCodeInput: ["#Code_input", 'input[placeholder*="请输入验证码"]'],
|
||||
sendCodeButton: ['button:has-text("获取验证码")', "text=获取验证码"],
|
||||
loginButton: ['button:has-text("登录 / 注册")', 'button:has-text("登录")'],
|
||||
imageCaptchaInput: ["#VerificatonCodeInput", "input.verify-input"],
|
||||
captchaShot: [".arco-modal", '[class*="captcha"]', '[class*="verify"]'],
|
||||
/** Risk-control slider / popup heuristics */
|
||||
riskControl: [
|
||||
'[class*="secsdk-captcha"]',
|
||||
"#captcha_popup",
|
||||
'[class*="captcha-slider"]',
|
||||
'[class*="drag"] [class*="slider"]',
|
||||
],
|
||||
/** MFA step-up modal (需要额外认证): a SECOND 6-digit SMS code is required */
|
||||
mfaModal: ['.arco-modal:has-text("需要额外认证")', "text=需要额外认证"],
|
||||
mfaInput: ["#VerificatonCodeInput", ".arco-modal input.verify-input", ".arco-modal input"],
|
||||
mfaConfirmButton: ['button:has-text("好的")', '.arco-modal button:has-text("确定")'],
|
||||
mfaResendButton: ['button:has-text("重发校验码")'],
|
||||
/** TOTP binding modal (绑定MFA设备) — needs interactive Google Authenticator setup */
|
||||
mfaBindModal: ['.arco-modal:has-text("绑定MFA设备")'],
|
||||
/** Identity selection page (/auth/login/select_identity) — the phone maps to
|
||||
* multiple accounts; the user must pick which identity to log in as.
|
||||
* Structure verified against the real auth bundle (vconsole-auth 1.0.0.2837,
|
||||
* module 12173 + chunk 202): ul[class*=accountUl] > li[class*=accountLi] >
|
||||
* div[class*=item] (click target) with the identity text in [class*=identity];
|
||||
* submit is button[type=submit] ("登录") inside [class*=selectPlatformIdentity].
|
||||
* .arco-list-item is kept as a fallback for future Arco-based redesigns. */
|
||||
identityList: ['ul[class*="accountUl"] li[class*="accountLi"]', ".arco-list-item"],
|
||||
identityItem: ['li[class*="accountLi"] > [class*="item"]', ".arco-list-item"],
|
||||
identitySubmitButton: [
|
||||
'[class*="selectPlatformIdentity"] button[type="submit"]',
|
||||
'button[type="submit"]:has-text("登录")',
|
||||
'button:has-text("登录")',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** URL marker for the console's identity-selection page */
|
||||
const IDENTITY_URL_PATTERN = /\/auth\/login\/select_identity/i;
|
||||
|
||||
const BROWSER_CONTEXT_OPTIONS = {
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
// ─── Minimal playwright structural types ──────────────────────────────────
|
||||
// Playwright is an optional runtime dep (dynamically imported), so we model
|
||||
// only the API surface this service drives instead of importing its types.
|
||||
|
||||
interface PwLocator {
|
||||
first(): PwLocator;
|
||||
isVisible(options?: { timeout?: number }): Promise<boolean>;
|
||||
click(options?: unknown): Promise<void>;
|
||||
fill(value: string): Promise<void>;
|
||||
isDisabled(): Promise<boolean>;
|
||||
screenshot(options?: { type?: string }): Promise<Buffer>;
|
||||
textContent(options?: { timeout?: number }): Promise<string | null>;
|
||||
count(): Promise<number>;
|
||||
nth(index: number): PwLocator;
|
||||
}
|
||||
|
||||
interface PwPage {
|
||||
setDefaultTimeout(timeout: number): void;
|
||||
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>;
|
||||
locator(selector: string): PwLocator;
|
||||
screenshot(options?: { type?: string }): Promise<Buffer>;
|
||||
url(): string;
|
||||
content(): Promise<string>;
|
||||
}
|
||||
|
||||
interface PwContext {
|
||||
newPage(): Promise<PwPage>;
|
||||
cookies(): Promise<Array<{ name: string; domain: string; value: string }>>;
|
||||
}
|
||||
|
||||
interface PwBrowser {
|
||||
newContext(options?: Record<string, unknown>): Promise<PwContext>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PwModule {
|
||||
chromium: {
|
||||
launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise<PwBrowser>;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Session record (internal) ──────────────────────────────────────────────
|
||||
|
||||
interface ActiveSession {
|
||||
sessionId: string;
|
||||
phone: string;
|
||||
phase: VolcLoginPhase;
|
||||
error: string | null;
|
||||
captchaImage: string | null;
|
||||
resendAvailableAt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
timeoutMs: number;
|
||||
credentials: Record<string, string> | null;
|
||||
/** Binding outcome set by the API layer via withBinding() */
|
||||
binding?: unknown;
|
||||
cancelled: boolean;
|
||||
/** Identity options scraped from the select_identity page */
|
||||
identityOptions: Array<{ index: number; label: string }> | null;
|
||||
// Playwright handles — never serialized
|
||||
browser: PwBrowser | null;
|
||||
context: PwContext | null;
|
||||
page: PwPage | null;
|
||||
}
|
||||
|
||||
export function maskPhone(phone: string): string {
|
||||
if (phone.length < 7) return "***";
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Normalize a CN mobile number: strip +86/86 prefix, spaces, dashes. */
|
||||
export function normalizePhone(raw: string): string | null {
|
||||
const trimmed = String(raw || "")
|
||||
.trim()
|
||||
.replace(/[\s-]/g, "");
|
||||
const bare = trimmed.replace(/^\+?86/, "");
|
||||
return /^1\d{10}$/.test(bare) ? bare : null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class VolcengineConsoleAutoLoginService {
|
||||
private sessions = new Map<string, ActiveSession>();
|
||||
/** sessionId → bind promise set by the API layer to dedupe lazy binding */
|
||||
private bindInFlight = new Map<string, Promise<unknown>>();
|
||||
/** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */
|
||||
private readonly loadPlaywright: () => Promise<PwModule>;
|
||||
private readonly delays: Required<ServiceDelays>;
|
||||
|
||||
constructor(
|
||||
loadPlaywright: () => Promise<PwModule> = async () => import("playwright"),
|
||||
delays: ServiceDelays = {}
|
||||
) {
|
||||
this.loadPlaywright = loadPlaywright;
|
||||
this.delays = {
|
||||
pageSettleMs: delays.pageSettleMs ?? 2_500,
|
||||
tabSwitchMs: delays.tabSwitchMs ?? 1_000,
|
||||
sendCodeSettleMs: delays.sendCodeSettleMs ?? 2_000,
|
||||
pollIntervalMs: delays.pollIntervalMs ?? CAPTURE_POLL_INTERVAL,
|
||||
resendCooldownMs: delays.resendCooldownMs ?? RESEND_COOLDOWN_MS,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
getActiveSessionCount(): number {
|
||||
let count = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
if (!isTerminal(session.phase)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
getStatus(sessionId: string): VolcLoginSessionView | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy binding hook used by the API layer: the route stores a promise here
|
||||
* so concurrent status polls do not double-bind the same credentials.
|
||||
*/
|
||||
async withBinding<T>(
|
||||
sessionId: string,
|
||||
bind: (credentials: Record<string, string>) => Promise<T>
|
||||
): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.phase !== "success" || !session.credentials) {
|
||||
return this.toView(session);
|
||||
}
|
||||
if (session.binding !== undefined) return this.toView(session);
|
||||
|
||||
let inFlight = this.bindInFlight.get(sessionId);
|
||||
if (!inFlight) {
|
||||
inFlight = bind(session.credentials)
|
||||
.then((binding: unknown) => {
|
||||
session.binding = binding;
|
||||
return binding;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Persist the failure so status polls do not retry forever.
|
||||
session.binding = { error: errorMessage(error) };
|
||||
return session.binding;
|
||||
})
|
||||
.finally(() => {
|
||||
this.bindInFlight.delete(sessionId);
|
||||
});
|
||||
this.bindInFlight.set(sessionId, inFlight);
|
||||
}
|
||||
await inFlight;
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
async startLogin(
|
||||
phone: string,
|
||||
options?: StartOptions
|
||||
): Promise<{ ok: true; session: VolcLoginSessionView } | { ok: false; error: string }> {
|
||||
const normalized = normalizePhone(phone);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "Invalid phone number (expected an 11-digit CN mobile number)" };
|
||||
}
|
||||
|
||||
this.expireSessions();
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.phone === normalized && !isTerminal(session.phase)) {
|
||||
await this.cancel(session.sessionId);
|
||||
}
|
||||
}
|
||||
if (this.getActiveSessionCount() >= MAX_ACTIVE_SESSIONS) {
|
||||
return { ok: false, error: "Too many concurrent Volcano login sessions" };
|
||||
}
|
||||
|
||||
let playwright: PwModule;
|
||||
try {
|
||||
playwright = await this.loadPlaywright();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Playwright is not installed. Use manual browser login instead.",
|
||||
};
|
||||
}
|
||||
|
||||
const session: ActiveSession = {
|
||||
sessionId: randomUUID(),
|
||||
phone: normalized,
|
||||
phase: "starting",
|
||||
error: null,
|
||||
captchaImage: null,
|
||||
resendAvailableAt: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
timeoutMs: options?.timeout || DEFAULT_SESSION_TIMEOUT,
|
||||
credentials: null,
|
||||
cancelled: false,
|
||||
identityOptions: null,
|
||||
browser: null,
|
||||
context: null,
|
||||
page: null,
|
||||
};
|
||||
this.sessions.set(session.sessionId, session);
|
||||
|
||||
try {
|
||||
// Prefer the playwright-managed Chromium; fall back to the system Chrome
|
||||
// channel on machines without `npx playwright install` browsers (dev laptops).
|
||||
try {
|
||||
session.browser = await playwright.chromium.launch({
|
||||
headless: true,
|
||||
args: ["--disable-blink-features=AutomationControlled"],
|
||||
});
|
||||
} catch (launchError) {
|
||||
if (!/Executable doesn't exist/.test(String(launchError))) throw launchError;
|
||||
session.browser = await playwright.chromium.launch({
|
||||
headless: true,
|
||||
channel: "chrome",
|
||||
args: ["--disable-blink-features=AutomationControlled"],
|
||||
});
|
||||
}
|
||||
session.context = await session.browser.newContext(BROWSER_CONTEXT_OPTIONS);
|
||||
session.page = await session.context.newPage();
|
||||
session.page.setDefaultTimeout(15_000);
|
||||
|
||||
await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
await sleep(this.delays.pageSettleMs);
|
||||
|
||||
// Switch to the phone-code login tab
|
||||
const tab = await this.firstVisible(session.page, SELECTORS.phoneTab);
|
||||
if (!tab) throw new SelectorMissError("phone tab");
|
||||
await tab.click();
|
||||
await sleep(this.delays.tabSwitchMs);
|
||||
|
||||
// Fill the phone number
|
||||
const phoneInput = await this.firstVisible(session.page, SELECTORS.phoneInput);
|
||||
if (!phoneInput) throw new SelectorMissError("phone input");
|
||||
await phoneInput.fill(normalized);
|
||||
|
||||
// Send the SMS code
|
||||
const sendBtn = await this.firstVisible(session.page, SELECTORS.sendCodeButton);
|
||||
if (!sendBtn) throw new SelectorMissError("send-code button");
|
||||
await sendBtn.click();
|
||||
|
||||
session.phase = "sending_code";
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
await sleep(this.delays.sendCodeSettleMs);
|
||||
|
||||
// Risk-control slider → degrade to the manual headful flow
|
||||
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
|
||||
if (risk) {
|
||||
session.captchaImage = await this.shot(session.page);
|
||||
session.phase = "fallback_manual";
|
||||
session.error =
|
||||
"Volcano risk control (slider captcha) was triggered in headless mode. Use manual browser login.";
|
||||
await this.closeBrowser(session);
|
||||
return { ok: true, session: this.toView(session) };
|
||||
}
|
||||
|
||||
// Image captcha may be required before the SMS is sent
|
||||
const captchaInput = await this.firstVisible(session.page, SELECTORS.imageCaptchaInput);
|
||||
if (captchaInput) {
|
||||
session.captchaImage = await this.shot(session.page);
|
||||
session.phase = "captcha_required";
|
||||
} else {
|
||||
session.phase = "waiting_code";
|
||||
}
|
||||
return { ok: true, session: this.toView(session) };
|
||||
} catch (error) {
|
||||
await this.closeBrowser(session);
|
||||
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
|
||||
session.error = errorMessage(error);
|
||||
if (session.phase === "fallback_manual") {
|
||||
session.error = `${session.error}. The login page layout may have changed — use manual browser login.`;
|
||||
}
|
||||
return { ok: true, session: this.toView(session) };
|
||||
}
|
||||
}
|
||||
|
||||
async submitCode(
|
||||
sessionId: string,
|
||||
code: string,
|
||||
captcha?: string,
|
||||
options?: SubmitCodeOptions
|
||||
): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
const fromMfa = session.phase === "mfa_waiting";
|
||||
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const smsCode = String(code || "").trim();
|
||||
if (!/^\d{4,6}$/.test(smsCode)) {
|
||||
session.error = "Invalid SMS code";
|
||||
return this.toView(session);
|
||||
}
|
||||
if (session.phase === "captcha_required" && !String(captcha || "").trim()) {
|
||||
session.error = "Image captcha is required";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
try {
|
||||
if (fromMfa) {
|
||||
// MFA step-up (需要额外认证): fill the SECOND code into the modal
|
||||
// input and confirm with 好的.
|
||||
const mfaInput = await this.firstVisible(page, SELECTORS.mfaInput);
|
||||
if (!mfaInput) throw new SelectorMissError("mfa code input");
|
||||
await mfaInput.fill(smsCode);
|
||||
|
||||
const confirmBtn = await this.firstVisible(page, SELECTORS.mfaConfirmButton);
|
||||
if (!confirmBtn) throw new SelectorMissError("mfa confirm button");
|
||||
await confirmBtn.click();
|
||||
} else {
|
||||
const codeInput = await this.firstVisible(page, SELECTORS.smsCodeInput);
|
||||
if (!codeInput) throw new SelectorMissError("sms code input");
|
||||
await codeInput.fill(smsCode);
|
||||
|
||||
if (captcha) {
|
||||
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
|
||||
if (captchaInput) await captchaInput.fill(String(captcha).trim());
|
||||
}
|
||||
|
||||
const loginBtn = await this.firstVisible(page, SELECTORS.loginButton);
|
||||
if (!loginBtn) throw new SelectorMissError("login button");
|
||||
await loginBtn.click();
|
||||
}
|
||||
|
||||
session.phase = "submitting";
|
||||
session.error = null;
|
||||
session.captchaImage = null;
|
||||
|
||||
return await this.pollUntilResolved(session, {
|
||||
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
|
||||
fromMfa,
|
||||
detectIdentity: true,
|
||||
});
|
||||
} catch (error) {
|
||||
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
|
||||
session.error = errorMessage(error);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an identity on the console's /auth/login/select_identity page and
|
||||
* finish the login. `index` maps to the identityOptions list previously
|
||||
* returned in the session view.
|
||||
*/
|
||||
async selectIdentity(
|
||||
sessionId: string,
|
||||
index: number,
|
||||
options?: SubmitCodeOptions
|
||||
): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.phase !== "identity_required") {
|
||||
return this.toView(session);
|
||||
}
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
try {
|
||||
// Click the requested identity card (the page pre-selects the first one,
|
||||
// so only non-zero indexes need an explicit click).
|
||||
if (index > 0) {
|
||||
const itemSelector = await this.identityItemSelector(page);
|
||||
if (!itemSelector) throw new SelectorMissError("identity item");
|
||||
const items = page.locator(itemSelector);
|
||||
const count = await items.count();
|
||||
if (index < 0 || index >= count) {
|
||||
session.error = `Identity index ${index} is out of range (${count} options)`;
|
||||
return this.toView(session);
|
||||
}
|
||||
await items.nth(index).click();
|
||||
await sleep(this.delays.tabSwitchMs);
|
||||
}
|
||||
|
||||
// Submit the selection (button[type=submit] “登录” on the identity card)
|
||||
const submitBtn = await this.firstVisible(page, SELECTORS.identitySubmitButton);
|
||||
if (!submitBtn) throw new SelectorMissError("identity submit button");
|
||||
await submitBtn.click();
|
||||
|
||||
session.phase = "submitting";
|
||||
session.error = null;
|
||||
session.identityOptions = null;
|
||||
|
||||
return await this.pollUntilResolved(session, {
|
||||
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
|
||||
fromMfa: false,
|
||||
detectIdentity: false,
|
||||
});
|
||||
} catch (error) {
|
||||
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
|
||||
session.error = errorMessage(error);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
/** First clickable identity-item selector that matches at least one element. */
|
||||
private async identityItemSelector(page: PwPage): Promise<string | null> {
|
||||
for (const selector of SELECTORS.identityItem) {
|
||||
try {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) return selector;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared post-submit loop: waits for console cookies, watching for MFA
|
||||
* step-up, identity selection, TOTP binding, and console error toasts.
|
||||
*/
|
||||
private async pollUntilResolved(
|
||||
session: ActiveSession,
|
||||
opts: { timeoutMs: number; fromMfa: boolean; detectIdentity: boolean }
|
||||
): Promise<VolcLoginSessionView> {
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
let pollCount = 0;
|
||||
let navigatedAfterLogin = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (session.cancelled) {
|
||||
session.phase = "cancelled";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
if (Date.now() - session.createdAt > session.timeoutMs) {
|
||||
session.phase = "timeout";
|
||||
session.error = "Login timed out";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const cookies = await session.context.cookies();
|
||||
const credentials: Record<string, string> = {};
|
||||
for (const cookie of cookies as Array<{ name: string; domain: string; value: string }>) {
|
||||
if (
|
||||
REQUIRED_COOKIES.includes(cookie.name as (typeof REQUIRED_COOKIES)[number]) &&
|
||||
cookie.domain.includes("volcengine.com")
|
||||
) {
|
||||
credentials[cookie.name] = cookie.value;
|
||||
}
|
||||
}
|
||||
if (REQUIRED_COOKIES.every((name) => credentials[name])) {
|
||||
session.credentials = credentials;
|
||||
session.phase = "success";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// TOTP binding modal (绑定MFA设备) — needs interactive Google
|
||||
// Authenticator setup that cannot be driven headlessly.
|
||||
const bindModal = await this.firstVisible(page, SELECTORS.mfaBindModal);
|
||||
if (bindModal) {
|
||||
session.phase = "fallback_manual";
|
||||
session.error =
|
||||
"The console requires binding an MFA device (Google Authenticator). Use manual browser login to complete the one-time setup.";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// MFA step-up modal (需要额外认证) — a second SMS code is required;
|
||||
// hand control back to the user instead of timing out.
|
||||
if (!opts.fromMfa) {
|
||||
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
|
||||
if (mfaModal) {
|
||||
session.phase = "mfa_waiting";
|
||||
session.error = null;
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
return this.toView(session);
|
||||
}
|
||||
} else if (pollCount >= 5) {
|
||||
// Wrong MFA code → the modal stays up; after a grace window hand
|
||||
// control back so the user can enter the latest code.
|
||||
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
|
||||
if (mfaModal) {
|
||||
session.phase = "mfa_waiting";
|
||||
session.error = "The MFA code was not accepted — enter the latest code";
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
// Identity selection page (/auth/login/select_identity) — the phone
|
||||
// maps to multiple accounts; scrape the options and let the user pick.
|
||||
if (opts.detectIdentity && IDENTITY_URL_PATTERN.test(page.url())) {
|
||||
const options = await this.scrapeIdentityOptions(page);
|
||||
if (options.length > 0) {
|
||||
session.phase = "identity_required";
|
||||
session.error = null;
|
||||
session.identityOptions = options;
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
// Login redirected away from /auth/login but cookies are incomplete →
|
||||
// the console app may need to run once to issue AccountID/userInfo.
|
||||
// Give it the same landing page the manual flow uses.
|
||||
if (!navigatedAfterLogin && pollCount >= 2 && !page.url().includes("/auth/login")) {
|
||||
navigatedAfterLogin = true;
|
||||
try {
|
||||
await page.goto(ARK_CONSOLE_URL, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
// navigation is best-effort; keep polling cookies
|
||||
}
|
||||
}
|
||||
|
||||
// Console error toast (e.g. wrong SMS code) → surface it early
|
||||
const toast = await page
|
||||
.locator('.arco-message-error, [class*="message-error"]')
|
||||
.first()
|
||||
.textContent({ timeout: 250 })
|
||||
.catch(() => null);
|
||||
if (toast && /验证码|密码|错误|失败|频繁/.test(toast)) {
|
||||
session.phase = "error";
|
||||
session.error = toast.trim().slice(0, 120);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
await sleep(this.delays.pollIntervalMs);
|
||||
pollCount++;
|
||||
}
|
||||
|
||||
session.phase = "timeout";
|
||||
session.error = await this.timeoutDiagnostics(session);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
/** First identity-list selector that matches at least one element. */
|
||||
private async identityListSelector(page: PwPage): Promise<string | null> {
|
||||
for (const selector of SELECTORS.identityList) {
|
||||
try {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) return selector;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scrape identity options from the select_identity page, in document order. */
|
||||
private async scrapeIdentityOptions(
|
||||
page: PwPage
|
||||
): Promise<Array<{ index: number; label: string }>> {
|
||||
const selector = await this.identityListSelector(page);
|
||||
if (!selector) return [];
|
||||
const items = page.locator(selector);
|
||||
const count = await items.count();
|
||||
const options: Array<{ index: number; label: string }> = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text =
|
||||
(await items
|
||||
.nth(i)
|
||||
.textContent()
|
||||
.catch(() => "")) || "";
|
||||
const label = text.replace(/\s+/g, " ").trim();
|
||||
if (label) options.push({ index: i, label: label.slice(0, 100) });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a diagnostic message for the cookie-poll timeout: page URL, cookies
|
||||
* collected so far, and any blocking modal. Keeps future debugging cheap.
|
||||
* When stuck on the identity-selection page, also dumps the page HTML to
|
||||
* /tmp so a selector miss can be fixed from ground truth in one shot.
|
||||
*/
|
||||
private async timeoutDiagnostics(session: ActiveSession): Promise<string> {
|
||||
const parts = ["Timed out waiting for the console session cookies"];
|
||||
try {
|
||||
if (session.page) {
|
||||
parts.push(`url=${session.page.url()}`);
|
||||
const cookies = (await session.context.cookies()) as Array<{
|
||||
name: string;
|
||||
domain: string;
|
||||
}>;
|
||||
const present = REQUIRED_COOKIES.filter((name) =>
|
||||
cookies.some((c) => c.name === name && c.domain.includes("volcengine.com"))
|
||||
);
|
||||
parts.push(
|
||||
`cookies=[${present.join(",") || "none of digest/AccountID/csrfToken/userInfo"}]`
|
||||
);
|
||||
const bindModal = await this.firstVisible(session.page, SELECTORS.mfaBindModal);
|
||||
if (bindModal) parts.push("blocked by 绑定MFA设备 modal");
|
||||
const mfaModal = await this.firstVisible(session.page, SELECTORS.mfaModal);
|
||||
if (mfaModal) parts.push("blocked by 需要额外认证 modal");
|
||||
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
|
||||
if (risk) parts.push("blocked by risk-control slider");
|
||||
if (IDENTITY_URL_PATTERN.test(session.page.url())) {
|
||||
const dump = await this.dumpPageHtml(session);
|
||||
if (dump) parts.push(`identityPageHtml=${dump}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// diagnostics are best-effort
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Best-effort page HTML dump for debugging selector misses. */
|
||||
private async dumpPageHtml(session: ActiveSession): Promise<string | null> {
|
||||
try {
|
||||
const { writeFile } = await import("fs/promises");
|
||||
const path = `/tmp/omniroute-volc-select-identity-${session.sessionId.slice(0, 8)}.html`;
|
||||
await writeFile(path, await session.page.content(), "utf8");
|
||||
return path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async resendCode(sessionId: string): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
const fromMfa = session.phase === "mfa_waiting";
|
||||
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
|
||||
return this.toView(session);
|
||||
}
|
||||
if (Date.now() < session.resendAvailableAt) {
|
||||
return this.toView(session);
|
||||
}
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
try {
|
||||
// In the MFA step-up modal the button is 重发校验码; on the login form
|
||||
// it counts down ("60s后重发" etc.) — try the fresh label first, then
|
||||
// any 重发/重新获取 variant.
|
||||
const resendSelectors = fromMfa
|
||||
? [...SELECTORS.mfaResendButton]
|
||||
: [
|
||||
'button:has-text("获取验证码")',
|
||||
'button:has-text("重发")',
|
||||
'button:has-text("重新获取")',
|
||||
'button:has-text("重新发送")',
|
||||
];
|
||||
const btn = await this.firstVisible(page, resendSelectors);
|
||||
if (!btn) throw new SelectorMissError("resend button");
|
||||
const disabled = await btn.isDisabled().catch(() => false);
|
||||
if (disabled) {
|
||||
session.error = "Resend is still cooling down on the login page";
|
||||
return this.toView(session);
|
||||
}
|
||||
await btn.click();
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
await sleep(this.delays.sendCodeSettleMs);
|
||||
|
||||
if (fromMfa) {
|
||||
// Stay in mfa_waiting — the modal persists until a valid code lands.
|
||||
session.phase = "mfa_waiting";
|
||||
session.error = null;
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
|
||||
if (captchaInput) {
|
||||
session.captchaImage = await this.shot(page);
|
||||
session.phase = "captcha_required";
|
||||
} else {
|
||||
session.captchaImage = null;
|
||||
session.phase = "waiting_code";
|
||||
}
|
||||
session.error = null;
|
||||
return this.toView(session);
|
||||
} catch (error) {
|
||||
session.phase = "error";
|
||||
session.error = errorMessage(error);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(sessionId: string): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
if (isTerminal(session.phase)) return this.toView(session);
|
||||
session.cancelled = true;
|
||||
session.phase = "cancelled";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// ─── Internals ───────────────────────────────────────────────────────────
|
||||
|
||||
private toView(session: ActiveSession): VolcLoginSessionView {
|
||||
const view: VolcLoginSessionView = {
|
||||
sessionId: session.sessionId,
|
||||
phase: session.phase,
|
||||
phoneMasked: maskPhone(session.phone),
|
||||
error: session.error,
|
||||
captchaImage: session.phase === "captcha_required" ? session.captchaImage : null,
|
||||
resendAvailableAt: session.resendAvailableAt,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
if (session.phase === "mfa_waiting") view.mfaRequired = true;
|
||||
if (session.phase === "identity_required" && session.identityOptions) {
|
||||
view.identityOptions = session.identityOptions;
|
||||
}
|
||||
if (session.phase === "success" && session.credentials) view.credentials = session.credentials;
|
||||
if (session.binding !== undefined) view.binding = session.binding;
|
||||
return view;
|
||||
}
|
||||
|
||||
private async closeBrowser(session: ActiveSession): Promise<void> {
|
||||
try {
|
||||
await session.browser?.close?.();
|
||||
} catch {
|
||||
// browser may already be gone
|
||||
} finally {
|
||||
session.browser = null;
|
||||
session.context = null;
|
||||
session.page = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Screenshot for captcha rendering; null when capture fails. */
|
||||
private async shot(page: PwPage): Promise<string | null> {
|
||||
try {
|
||||
const target = await this.firstVisible(page, SELECTORS.captchaShot);
|
||||
const buffer: Buffer | null = target
|
||||
? await target.screenshot({ type: "png" })
|
||||
: await page.screenshot({ type: "png" });
|
||||
return buffer ? `data:image/png;base64,${buffer.toString("base64")}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async firstVisible(
|
||||
page: PwPage,
|
||||
selectors: readonly string[]
|
||||
): Promise<PwLocator | null> {
|
||||
for (const selector of selectors) {
|
||||
try {
|
||||
const locator = page.locator(selector).first();
|
||||
if (await locator.isVisible({ timeout: 2_000 })) return locator;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Close and drop sessions past their TTL; keep terminal ones briefly for status reads. */
|
||||
private expireSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of this.sessions) {
|
||||
const age = now - session.createdAt;
|
||||
const terminal = isTerminal(session.phase);
|
||||
if (terminal && age > 10 * 60_000) {
|
||||
this.sessions.delete(id);
|
||||
} else if (!terminal && age > session.timeoutMs + 60_000) {
|
||||
session.phase = "timeout";
|
||||
session.error = "Session expired";
|
||||
void this.closeBrowser(session);
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
class SelectorMissError extends Error {
|
||||
constructor(element: string) {
|
||||
super(`Login page element not found: ${element}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(phase: VolcLoginPhase): boolean {
|
||||
return (
|
||||
phase === "success" ||
|
||||
phase === "error" ||
|
||||
phase === "timeout" ||
|
||||
phase === "cancelled" ||
|
||||
phase === "fallback_manual"
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// ─── Singleton ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const volcengineConsoleAutoLoginService = new VolcengineConsoleAutoLoginService();
|
||||
@@ -57,6 +57,7 @@ import CustomModelsSection from "./components/CustomModelsSection";
|
||||
import ConnectionsListPanel from "./components/ConnectionsListPanel";
|
||||
import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
|
||||
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
|
||||
import VolcengineConnectModal from "./components/VolcengineConnectModal";
|
||||
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
|
||||
import ZedImportCard from "./components/ZedImportCard";
|
||||
import CursorAgentNudge from "./components/CursorAgentNudge";
|
||||
@@ -79,6 +80,7 @@ export default function ProviderDetailPageClient() {
|
||||
const [showOAuthModal, _setShowOAuthModal] = useState(false);
|
||||
const [reauthConnection, setReauthConnection] = useState<ConnectionRowConnection | null>(null);
|
||||
const [showKimiAuthMethodModal, setShowKimiAuthMethodModal] = useState(false);
|
||||
const [showVolcengineConnectModal, setShowVolcengineConnectModal] = useState(false);
|
||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||
const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false);
|
||||
const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState<string | undefined>();
|
||||
@@ -382,7 +384,9 @@ export default function ProviderDetailPageClient() {
|
||||
openApiKeyAddFlow();
|
||||
}, [providerId, isOAuth, openApiKeyAddFlow]);
|
||||
|
||||
const connectVolcengineAccount = useCallback(async () => {
|
||||
// Legacy manual flow: headful browser login on the machine running OmniRoute.
|
||||
// Kept as the fallback for the phone/SMS auto-login modal.
|
||||
const connectVolcengineAccountManually = useCallback(async () => {
|
||||
setConnectingVolcengineAccount(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers/volcengine-plan/connect", {
|
||||
@@ -413,6 +417,10 @@ export default function ProviderDetailPageClient() {
|
||||
}
|
||||
}, [fetchConnections, notify]);
|
||||
|
||||
const connectVolcengineAccount = useCallback(() => {
|
||||
setShowVolcengineConnectModal(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
commandCodeAuthState,
|
||||
handleCloseAddApiKeyModal,
|
||||
@@ -902,6 +910,16 @@ export default function ProviderDetailPageClient() {
|
||||
setShowTutorialModal={setShowTutorialModal}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Volcano Engine console phone/SMS auto-login (falls back to manual browser login) */}
|
||||
<VolcengineConnectModal
|
||||
isOpen={showVolcengineConnectModal}
|
||||
onClose={() => setShowVolcengineConnectModal(false)}
|
||||
onFallbackManual={connectVolcengineAccountManually}
|
||||
onConnected={fetchConnections}
|
||||
notify={notify}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Input, Modal } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
/**
|
||||
* VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console.
|
||||
*
|
||||
* Drives the session-based auto login API:
|
||||
* POST /api/providers/volcengine-plan/connect {phone}
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/code {code, captcha?}
|
||||
* GET /api/providers/volcengine-plan/connect/{id}/status
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/resend
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/cancel
|
||||
*
|
||||
* Falls back to the legacy manual headful-browser flow (same POST /connect
|
||||
* endpoint without a phone) when risk control or a layout change degrades
|
||||
* the headless session.
|
||||
*/
|
||||
|
||||
type SessionPhase =
|
||||
| "starting"
|
||||
| "sending_code"
|
||||
| "waiting_code"
|
||||
| "captcha_required"
|
||||
| "submitting"
|
||||
| "mfa_waiting"
|
||||
| "identity_required"
|
||||
| "success"
|
||||
| "error"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "fallback_manual";
|
||||
|
||||
interface SessionView {
|
||||
sessionId: string;
|
||||
phase: SessionPhase;
|
||||
phoneMasked: string;
|
||||
error: string | null;
|
||||
captchaImage: string | null;
|
||||
resendAvailableAt: number;
|
||||
mfaRequired?: boolean;
|
||||
identityOptions?: Array<{ index: number; label: string }>;
|
||||
binding?: {
|
||||
results?: Array<{
|
||||
plan: string;
|
||||
available: boolean;
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
}>;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PHONE_STORAGE_KEY = "omniroute.volcengine.phone";
|
||||
const TERMINAL_PHASES: SessionPhase[] = [
|
||||
"success",
|
||||
"error",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"fallback_manual",
|
||||
];
|
||||
|
||||
function isTerminal(phase: SessionPhase | undefined): boolean {
|
||||
return !!phase && TERMINAL_PHASES.includes(phase);
|
||||
}
|
||||
|
||||
type VolcengineConnectModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Legacy headful-browser login (opens on the server machine) */
|
||||
onFallbackManual: () => void;
|
||||
/** Refresh connections after a successful bind */
|
||||
onConnected: () => void | Promise<void>;
|
||||
notify: {
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
};
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
export default function VolcengineConnectModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onFallbackManual,
|
||||
onConnected,
|
||||
notify,
|
||||
t,
|
||||
}: VolcengineConnectModalProps) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [session, setSession] = useState<SessionView | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [submittingCode, setSubmittingCode] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [selectingIdentity, setSelectingIdentity] = useState(false);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
const stopTimers = useCallback(() => {
|
||||
if (pollTimer.current) {
|
||||
clearInterval(pollTimer.current);
|
||||
pollTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopTimers();
|
||||
setSession(null);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
setResendCountdown(0);
|
||||
}, [stopTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Leaving the modal cancels an in-flight session server-side.
|
||||
const active = session && !isTerminal(session.phase) ? session : null;
|
||||
if (active) {
|
||||
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
}).catch(() => {});
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
|
||||
if (saved) setPhone(saved);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => stopTimers, [stopTimers]);
|
||||
|
||||
// resend countdown ticker
|
||||
const resendAvailableAt = session?.resendAvailableAt ?? 0;
|
||||
const sessionId = session?.sessionId;
|
||||
const sessionPhase = session?.phase;
|
||||
useEffect(() => {
|
||||
if (!sessionId || isTerminal(sessionPhase)) return;
|
||||
const tick = () => {
|
||||
setResendCountdown(Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000)));
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [sessionId, sessionPhase, resendAvailableAt]);
|
||||
|
||||
// ── status polling ──────────────────────────────────────────────────────
|
||||
|
||||
const startPolling = useCallback(
|
||||
(sessionId: string) => {
|
||||
stopTimers();
|
||||
pollTimer.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${sessionId}/status`
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (isTerminal(data.session.phase)) {
|
||||
stopTimers();
|
||||
if (data.session.phase === "success") void onConnected();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// transient network error — keep polling until phase resolves
|
||||
}
|
||||
}, 1500);
|
||||
},
|
||||
[stopTimers, onConnected]
|
||||
);
|
||||
|
||||
// ── actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
const trimmed = phone.trim();
|
||||
if (!trimmed) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers/volcengine-plan/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: trimmed }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success || !data?.session) {
|
||||
throw new Error(data?.error || "Failed to start Volcano login");
|
||||
}
|
||||
setSession(data.session);
|
||||
setResendCountdown(
|
||||
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
|
||||
);
|
||||
localStorage.setItem(PHONE_STORAGE_KEY, trimmed);
|
||||
if (data.session.phase === "starting" || data.session.phase === "sending_code") {
|
||||
startPolling(data.session.sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to start Volcano login");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [phone, notify, startPolling]);
|
||||
|
||||
const handleSubmitCode = useCallback(async () => {
|
||||
if (!session) return;
|
||||
setSubmittingCode(true);
|
||||
try {
|
||||
const payload: { code: string; captcha?: string } = { code: code.trim() };
|
||||
if (session.phase === "captcha_required" && captcha.trim()) {
|
||||
payload.captcha = captcha.trim();
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (data.session.phase === "mfa_waiting") {
|
||||
// A NEW code is required for the MFA step — clear the stale input.
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
}
|
||||
if (
|
||||
data.session.phase === "starting" ||
|
||||
data.session.phase === "sending_code" ||
|
||||
data.session.phase === "submitting"
|
||||
) {
|
||||
startPolling(data.session.sessionId);
|
||||
} else if (data.session.phase === "success") {
|
||||
void onConnected();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data?.error || "Failed to submit verification code");
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to submit verification code");
|
||||
} finally {
|
||||
setSubmittingCode(false);
|
||||
}
|
||||
}, [session, code, captcha, notify, startPolling, onConnected]);
|
||||
|
||||
const handleSelectIdentity = useCallback(
|
||||
async (index: number) => {
|
||||
if (!session) return;
|
||||
setSelectingIdentity(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/identity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index }),
|
||||
}
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (
|
||||
data.session.phase === "starting" ||
|
||||
data.session.phase === "sending_code" ||
|
||||
data.session.phase === "submitting"
|
||||
) {
|
||||
startPolling(data.session.sessionId);
|
||||
} else if (data.session.phase === "success") {
|
||||
void onConnected();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data?.error || "Failed to select identity");
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to select identity");
|
||||
} finally {
|
||||
setSelectingIdentity(false);
|
||||
}
|
||||
},
|
||||
[session, notify, startPolling, onConnected]
|
||||
);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (!session || resendCountdown > 0) return;
|
||||
setResending(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/resend`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
setResendCountdown(
|
||||
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
|
||||
);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to resend verification code");
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}, [session, resendCountdown, notify]);
|
||||
|
||||
const handleCancelSession = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await fetch(`/api/providers/volcengine-plan/connect/${session.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
reset();
|
||||
}, [session, reset]);
|
||||
|
||||
// ── derived UI state ────────────────────────────────────────────────────
|
||||
|
||||
const phase = session?.phase;
|
||||
const showPhoneStep = !session;
|
||||
const showCodeStep =
|
||||
phase === "waiting_code" ||
|
||||
phase === "captcha_required" ||
|
||||
phase === "mfa_waiting" ||
|
||||
phase === "identity_required";
|
||||
const showPolling = phase === "starting" || phase === "sending_code" || phase === "submitting";
|
||||
const done = isTerminal(phase);
|
||||
const mfaStep = phase === "mfa_waiting";
|
||||
|
||||
const bindingResults = session?.binding?.results || [];
|
||||
const connectedPlans = bindingResults.filter((r) => r?.ok);
|
||||
const bindingError = session?.binding?.error;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{showPhoneStep && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"volcAutoLoginDesc",
|
||||
"Enter your phone number. OmniRoute sends a verification code via the Volcano Engine console and extracts the session cookies automatically — no browser interaction needed."
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
label={providerText(t, "volcPhoneLabel", "Phone number")}
|
||||
placeholder="13800000000"
|
||||
value={phone}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") void handleStart();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleClose}>
|
||||
{providerText(t, "cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button size="sm" loading={starting} disabled={!phone.trim()} onClick={handleStart}>
|
||||
{providerText(t, "volcSendCode", "Send verification code")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCodeStep && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{mfaStep
|
||||
? providerText(
|
||||
t,
|
||||
"volcMfaDesc",
|
||||
"Additional verification required (MFA). A NEW 6-digit code was sent to {phone} — enter it below to finish login.",
|
||||
{ phone: session?.phoneMasked || "your phone" }
|
||||
)
|
||||
: phase === "identity_required"
|
||||
? providerText(
|
||||
t,
|
||||
"volcIdentityDesc",
|
||||
"Your phone number is linked to multiple Volcano Engine identities. Pick the one you want to log in with:"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"volcCodeSent",
|
||||
"A verification code was sent to {phone}. Enter it below to finish login.",
|
||||
{ phone: session?.phoneMasked || "your phone" }
|
||||
)}
|
||||
</p>
|
||||
|
||||
{phase === "identity_required" && session?.identityOptions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{session.identityOptions.map((option) => (
|
||||
<button
|
||||
key={option.index}
|
||||
type="button"
|
||||
disabled={selectingIdentity}
|
||||
onClick={() => handleSelectIdentity(option.index)}
|
||||
className="w-full rounded-lg border border-border p-3 text-left text-sm transition-colors hover:bg-sidebar disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{selectingIdentity ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{option.label}
|
||||
</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{phase === "captcha_required" && session?.captchaImage && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{providerText(
|
||||
t,
|
||||
"volcCaptchaLabel",
|
||||
"Image captcha (required by the console)"
|
||||
)}
|
||||
</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={session.captchaImage}
|
||||
alt="captcha"
|
||||
className="max-h-40 rounded border border-border"
|
||||
/>
|
||||
<Input
|
||||
placeholder={providerText(t, "volcCaptchaPlaceholder", "Captcha characters")}
|
||||
value={captcha}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCaptcha(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={
|
||||
mfaStep
|
||||
? providerText(t, "volcMfaCodeLabel", "MFA verification code")
|
||||
: providerText(t, "volcCodeLabel", "Verification code")
|
||||
}
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") void handleSubmitCode();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
/>
|
||||
|
||||
{session?.error && <p className="text-sm text-red-500">{session.error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={resending}
|
||||
disabled={resendCountdown > 0}
|
||||
onClick={handleResend}
|
||||
>
|
||||
{resendCountdown > 0
|
||||
? providerText(t, "volcResendIn", "Resend in {s}s", { s: resendCountdown })
|
||||
: providerText(t, "volcResend", "Resend code")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleCancelSession}>
|
||||
{providerText(t, "back", "Back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={submittingCode}
|
||||
disabled={code.trim().length < 4}
|
||||
onClick={handleSubmitCode}
|
||||
>
|
||||
{providerText(t, "volcLogin", "Log in")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPolling && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-text-muted">
|
||||
{phase === "submitting"
|
||||
? providerText(
|
||||
t,
|
||||
"volcSubmitting",
|
||||
"Submitting code and extracting console cookies..."
|
||||
)
|
||||
: providerText(t, "volcStarting", "Starting Volcano login...")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && phase === "success" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-green-600">
|
||||
{providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")}
|
||||
</p>
|
||||
{bindingError ? (
|
||||
<p className="text-sm text-red-500">
|
||||
{providerText(t, "volcBindError", "Plan binding failed: {error}", {
|
||||
error: bindingError,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 text-sm">
|
||||
{connectedPlans.length > 0 ? (
|
||||
connectedPlans.map((item) => (
|
||||
<p key={item.plan} className="text-green-600">
|
||||
✓ {item.plan} plan connected
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"volcNoPlans",
|
||||
"No Agent/Coding plans were detected on this account."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleClose}>
|
||||
{providerText(t, "done", "Done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && phase !== "success" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-red-500">
|
||||
{session?.error ||
|
||||
(phase === "timeout"
|
||||
? providerText(t, "volcTimeout", "Login timed out")
|
||||
: phase === "cancelled"
|
||||
? providerText(t, "volcCancelled", "Login cancelled")
|
||||
: providerText(t, "volcFailed", "Login failed"))}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={reset}>
|
||||
{providerText(t, "retry", "Retry")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onFallbackManual();
|
||||
}}
|
||||
>
|
||||
{providerText(t, "volcManualLogin", "Manual browser login")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/cancel
|
||||
* Cancel an auto phone login session and close its headless browser.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const session = await volcengineConsoleAutoLoginService.cancel(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, session });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Cancel failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/code
|
||||
* Submit the SMS verification code (plus image captcha when required) for an
|
||||
* auto phone login session. Returns the session view; binding runs lazily on
|
||||
* the next status poll once credentials are extracted.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const session = await volcengineConsoleAutoLoginService.submitCode(
|
||||
sessionId,
|
||||
String(body.code ?? ""),
|
||||
typeof body.captcha === "string" ? body.captcha : undefined,
|
||||
{ timeout }
|
||||
);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials ready → bind immediately so the response carries the outcome.
|
||||
if (session.phase === "success") {
|
||||
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
return NextResponse.json({ success: true, session: bound ?? session });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano code submission failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/identity
|
||||
* Pick an identity on the console's select_identity page (the phone maps to
|
||||
* multiple accounts) and finish the login + plan binding.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const index = Number(body.index);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid identity index" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, {
|
||||
timeout,
|
||||
});
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials ready → bind immediately so the response carries the outcome.
|
||||
if (session.phase === "success") {
|
||||
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
return NextResponse.json({ success: true, session: bound ?? session });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano identity selection failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/resend
|
||||
* Re-trigger the SMS verification code for an active login session.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const session = await volcengineConsoleAutoLoginService.resendCode(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, session });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Resend failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* GET /api/providers/volcengine-plan/connect/[sessionId]/status
|
||||
* Poll an auto phone login session. When credentials have been extracted, the
|
||||
* plan binding runs lazily (deduped) and its result is attached to the view.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
const session = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: session.phase === "success", session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano login status failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,26 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
|
||||
// Auto flow: phone present → start a session-based headless phone/SMS login.
|
||||
if (typeof body.phone === "string" && body.phone.trim()) {
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout });
|
||||
if (!started.ok) {
|
||||
return NextResponse.json({ success: false, error: started.error }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: true, session: started.session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano auto login failed to start: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy manual flow: headful browser login on the server machine.
|
||||
try {
|
||||
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
|
||||
const login = await inAppLoginService.startLogin("volcengine-console", { timeout });
|
||||
|
||||
@@ -95,7 +95,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
*/
|
||||
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/,
|
||||
/^\/api\/providers\/volcengine-plan\/connect\/?$/,
|
||||
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // manual headful flow + session-based phone/SMS auto-login (both spawn Playwright)
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/,
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/,
|
||||
];
|
||||
|
||||
@@ -52,7 +52,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
*/
|
||||
export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list
|
||||
/^\/api\/providers\/volcengine-plan\/connect\/?$/, // launches Playwright to bind a Volcano Engine console session
|
||||
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // launches Playwright to bind a Volcano Engine console session — covers the manual headful flow AND the session-based phone/SMS auto-login sub-routes (/code, /status, /cancel, /resend)
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, // spawns via getTunnelRuntimeStatus() → spawnSync("...","runtimes status") (open-sse/executors/chatgpt-web-codex/tunnelClient.ts). Mirrors LOCAL_ONLY_API_PATTERNS in routeGuard.ts; keep the two in sync (GHSA-9q3h-mjm5-f4gj).
|
||||
|
||||
802
tests/unit/services/volcengine-console-auto-login.test.ts
Normal file
802
tests/unit/services/volcengine-console-auto-login.test.ts
Normal file
@@ -0,0 +1,802 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
VolcengineConsoleAutoLoginService,
|
||||
maskPhone,
|
||||
normalizePhone,
|
||||
} from "../../../open-sse/services/volcengineConsoleAutoLogin.ts";
|
||||
|
||||
// ─── Fake playwright ────────────────────────────────────────────────────────
|
||||
|
||||
interface FakeState {
|
||||
visible: Set<string>;
|
||||
disabled: Set<string>;
|
||||
fills: Record<string, string>;
|
||||
clicks: string[];
|
||||
/** Returns the cookie jar; tests swap this to simulate login progress */
|
||||
cookiesFn: () => Array<{ name: string; domain: string; value: string }>;
|
||||
toastText: string | null;
|
||||
browserClosed: boolean;
|
||||
/** Current page URL — tests move it off /auth/login to simulate redirect */
|
||||
url: string;
|
||||
gotoCalls: string[];
|
||||
/** selector → list of item texts (identity list etc.) */
|
||||
lists: Record<string, string[]>;
|
||||
}
|
||||
|
||||
function makeFakePlaywright() {
|
||||
const state: FakeState = {
|
||||
visible: new Set<string>(),
|
||||
disabled: new Set<string>(),
|
||||
fills: {},
|
||||
clicks: [],
|
||||
cookiesFn: () => [],
|
||||
toastText: null,
|
||||
browserClosed: false,
|
||||
url: "https://console.volcengine.com/auth/login",
|
||||
gotoCalls: [],
|
||||
lists: {},
|
||||
};
|
||||
|
||||
class FakeLocator {
|
||||
constructor(
|
||||
private page: FakePage,
|
||||
private selector: string,
|
||||
private idx = -1
|
||||
) {}
|
||||
first() {
|
||||
return this;
|
||||
}
|
||||
nth(index: number) {
|
||||
return new FakeLocator(this.page, this.selector, index);
|
||||
}
|
||||
async count() {
|
||||
return (this.page.state.lists[this.selector] || []).length;
|
||||
}
|
||||
async isVisible() {
|
||||
return this.page.state.visible.has(this.selector);
|
||||
}
|
||||
async isDisabled() {
|
||||
return this.page.state.disabled.has(this.selector);
|
||||
}
|
||||
async click() {
|
||||
const suffix = this.idx >= 0 ? `[${this.idx}]` : "";
|
||||
this.page.state.clicks.push(`${this.selector}${suffix}`);
|
||||
}
|
||||
async fill(value: string) {
|
||||
this.page.state.fills[this.selector] = value;
|
||||
}
|
||||
async screenshot() {
|
||||
return Buffer.from("fake-png");
|
||||
}
|
||||
async textContent() {
|
||||
if (this.idx >= 0) return (this.page.state.lists[this.selector] || [])[this.idx] ?? null;
|
||||
return this.page.state.toastText;
|
||||
}
|
||||
}
|
||||
|
||||
class FakePage {
|
||||
constructor(public state: FakeState) {}
|
||||
setDefaultTimeout() {}
|
||||
async goto(url: string) {
|
||||
this.state.gotoCalls.push(url);
|
||||
this.state.url = url;
|
||||
}
|
||||
url() {
|
||||
return this.state.url;
|
||||
}
|
||||
locator(selector: string) {
|
||||
return new FakeLocator(this, selector);
|
||||
}
|
||||
async screenshot() {
|
||||
return Buffer.from("fake-page-png");
|
||||
}
|
||||
}
|
||||
|
||||
const page = new FakePage(state);
|
||||
|
||||
const context = {
|
||||
newPage: async () => page,
|
||||
cookies: async () => state.cookiesFn(),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
newContext: async () => context,
|
||||
close: async () => {
|
||||
state.browserClosed = true;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
chromium: { launch: async () => browser },
|
||||
__state: state,
|
||||
};
|
||||
}
|
||||
|
||||
function fastService(fake: ReturnType<typeof makeFakePlaywright>) {
|
||||
return new VolcengineConsoleAutoLoginService(async () => fake, {
|
||||
pageSettleMs: 1,
|
||||
tabSwitchMs: 1,
|
||||
sendCodeSettleMs: 1,
|
||||
pollIntervalMs: 1,
|
||||
resendCooldownMs: 20,
|
||||
});
|
||||
}
|
||||
|
||||
const PHONE_TAB = '.arco-tabs-header-title:has-text("手机号登录")';
|
||||
const PHONE_INPUT = "#Tel_input";
|
||||
const SEND_CODE_BTN = 'button:has-text("获取验证码")';
|
||||
const SMS_CODE_INPUT = "#Code_input";
|
||||
const LOGIN_BTN = 'button:has-text("登录 / 注册")';
|
||||
const CAPTCHA_INPUT = "#VerificatonCodeInput";
|
||||
const CAPTCHA_MODAL = ".arco-modal";
|
||||
const MFA_MODAL = '.arco-modal:has-text("需要额外认证")';
|
||||
const MFA_INPUT = "#VerificatonCodeInput";
|
||||
const MFA_CONFIRM_BTN = 'button:has-text("好的")';
|
||||
const MFA_RESEND_BTN = 'button:has-text("重发校验码")';
|
||||
const MFA_BIND_MODAL = '.arco-modal:has-text("绑定MFA设备")';
|
||||
const IDENTITY_LIST = 'ul[class*="accountUl"] li[class*="accountLi"]';
|
||||
const IDENTITY_ITEM = 'li[class*="accountLi"] > [class*="item"]';
|
||||
const IDENTITY_SUBMIT = '[class*="selectPlatformIdentity"] button[type="submit"]';
|
||||
|
||||
const FULL_COOKIES = [
|
||||
{ name: "digest", domain: ".volcengine.com", value: "d1" },
|
||||
{ name: "AccountID", domain: ".volcengine.com", value: "a1" },
|
||||
{ name: "csrfToken", domain: ".volcengine.com", value: "c1" },
|
||||
{ name: "userInfo", domain: ".volcengine.com", value: "u1" },
|
||||
];
|
||||
|
||||
function happyPathVisible(fake: ReturnType<typeof makeFakePlaywright>) {
|
||||
fake.__state.visible.add(PHONE_TAB);
|
||||
fake.__state.visible.add(PHONE_INPUT);
|
||||
fake.__state.visible.add(SEND_CODE_BTN);
|
||||
fake.__state.visible.add(SMS_CODE_INPUT);
|
||||
fake.__state.visible.add(LOGIN_BTN);
|
||||
}
|
||||
|
||||
// ─── Pure helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
test("normalizePhone strips +86/86 prefixes, spaces and dashes", () => {
|
||||
assert.equal(normalizePhone("+8613800000000"), "13800000000");
|
||||
assert.equal(normalizePhone("8613800000000"), "13800000000");
|
||||
assert.equal(normalizePhone("138-0000 0000"), "13800000000");
|
||||
assert.equal(normalizePhone(" 13800000000 "), "13800000000");
|
||||
assert.equal(normalizePhone("12345"), null);
|
||||
assert.equal(normalizePhone("23800000000"), null);
|
||||
assert.equal(normalizePhone(""), null);
|
||||
});
|
||||
|
||||
test("maskPhone keeps only head/tail digits", () => {
|
||||
assert.equal(maskPhone("13800000000"), "138****0000");
|
||||
assert.equal(maskPhone("1234567"), "123****4567");
|
||||
assert.equal(maskPhone("123"), "***");
|
||||
});
|
||||
|
||||
// ─── startLogin ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("startLogin rejects an invalid phone number", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
const service = fastService(fake);
|
||||
const result = await service.startLogin("not-a-phone");
|
||||
assert.equal(result.ok, false);
|
||||
assert.match((result as { error: string }).error, /Invalid phone/i);
|
||||
});
|
||||
|
||||
test("startLogin drives the phone tab and sends the SMS code", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const result = await service.startLogin("+8613800000000");
|
||||
assert.equal(result.ok, true);
|
||||
const session = (result as { session: { sessionId: string; phase: string } }).session;
|
||||
assert.equal(session.phase, "waiting_code");
|
||||
|
||||
assert.equal(fake.__state.fills[PHONE_INPUT], "13800000000");
|
||||
assert.ok(fake.__state.clicks.includes(PHONE_TAB));
|
||||
assert.ok(fake.__state.clicks.includes(SEND_CODE_BTN));
|
||||
});
|
||||
|
||||
test("startLogin degrades to fallback_manual when selectors miss", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
// nothing visible → phone tab not found
|
||||
const service = fastService(fake);
|
||||
|
||||
const result = await service.startLogin("13800000000");
|
||||
assert.equal(result.ok, true);
|
||||
const session = (result as { session: { phase: string } }).session;
|
||||
assert.equal(session.phase, "fallback_manual");
|
||||
assert.ok(fake.__state.browserClosed, "browser must close on fallback");
|
||||
});
|
||||
|
||||
test("startLogin reports captcha_required with a screenshot when the console demands one", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
fake.__state.visible.add(CAPTCHA_INPUT);
|
||||
fake.__state.visible.add(CAPTCHA_MODAL);
|
||||
const service = fastService(fake);
|
||||
|
||||
const result = await service.startLogin("13800000000");
|
||||
assert.equal(result.ok, true);
|
||||
const session = (result as { session: { phase: string; captchaImage: string | null } }).session;
|
||||
assert.equal(session.phase, "captcha_required");
|
||||
assert.match(session.captchaImage || "", /^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
test("startLogin degrades to fallback_manual on risk-control slider", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
fake.__state.visible.add('[class*="secsdk-captcha"]');
|
||||
const service = fastService(fake);
|
||||
|
||||
const result = await service.startLogin("13800000000");
|
||||
assert.equal(result.ok, true);
|
||||
const session = (result as { session: { phase: string; error: string | null } }).session;
|
||||
assert.equal(session.phase, "fallback_manual");
|
||||
assert.match(session.error || "", /risk control/i);
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
});
|
||||
|
||||
test("startLogin replaces a stale session for the same phone", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const first = await service.startLogin("13800000000");
|
||||
const firstId = (first as { session: { sessionId: string } }).session.sessionId;
|
||||
const second = await service.startLogin("13800000000");
|
||||
const secondId = (second as { session: { sessionId: string } }).session.sessionId;
|
||||
|
||||
assert.notEqual(firstId, secondId);
|
||||
assert.equal(service.getStatus(firstId)?.phase, "cancelled");
|
||||
assert.equal(service.getStatus(secondId)?.phase, "waiting_code");
|
||||
});
|
||||
|
||||
// ─── submitCode ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("submitCode completes login when all console cookies land", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
// Cookies complete after the first poll
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
|
||||
const session = await service.submitCode(started.session.sessionId, "123456");
|
||||
assert.equal(session?.phase, "success");
|
||||
assert.deepEqual(Object.keys(session?.credentials || {}).sort(), [
|
||||
"AccountID",
|
||||
"csrfToken",
|
||||
"digest",
|
||||
"userInfo",
|
||||
]);
|
||||
assert.equal(fake.__state.fills[SMS_CODE_INPUT], "123456");
|
||||
assert.ok(fake.__state.clicks.includes(LOGIN_BTN));
|
||||
assert.ok(fake.__state.browserClosed, "browser must close after success");
|
||||
});
|
||||
|
||||
test("submitCode rejects a malformed code without touching the page", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
const before = fake.__state.clicks.length;
|
||||
|
||||
const session = await service.submitCode(started.session.sessionId, "abc");
|
||||
assert.equal(session?.phase, "waiting_code");
|
||||
assert.equal(session?.error, "Invalid SMS code");
|
||||
assert.equal(fake.__state.clicks.length, before, "no click on malformed code");
|
||||
});
|
||||
|
||||
test("submitCode requires the image captcha in captcha_required phase", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
fake.__state.visible.add(CAPTCHA_INPUT);
|
||||
fake.__state.visible.add(CAPTCHA_MODAL);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
const session = await service.submitCode(started.session.sessionId, "123456");
|
||||
assert.equal(session?.phase, "captcha_required");
|
||||
assert.equal(session?.error, "Image captcha is required");
|
||||
});
|
||||
|
||||
test("submitCode surfaces console error toasts early", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.toastText = "验证码错误,请重新输入";
|
||||
|
||||
const session = await service.submitCode(started.session.sessionId, "000000", undefined, {
|
||||
timeout: 500,
|
||||
});
|
||||
assert.equal(session?.phase, "error");
|
||||
assert.match(session?.error || "", /验证码错误/);
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
});
|
||||
|
||||
test("submitCode times out when cookies never arrive", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
const session = await service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 50,
|
||||
});
|
||||
assert.equal(session?.phase, "timeout");
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
});
|
||||
|
||||
// ─── MFA step-up (需要额外认证) ──────────────────────────────────────────
|
||||
|
||||
test("submitCode transitions to mfa_waiting when the console demands MFA", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
// After the login click the MFA step-up modal appears (no cookies yet).
|
||||
const originalClicks = fake.__state.clicks;
|
||||
fake.__state.cookiesFn = () => [
|
||||
{ name: "digest", domain: ".volcengine.com", value: "d1" },
|
||||
{ name: "csrfToken", domain: ".volcengine.com", value: "c1" },
|
||||
];
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
// Simulate: login button clicked → MFA modal opens
|
||||
const loginBtn = fake.__state.clicks;
|
||||
assert.ok(loginBtn.length > 0);
|
||||
fake.__state.visible.add(MFA_MODAL);
|
||||
fake.__state.visible.add(MFA_INPUT);
|
||||
fake.__state.visible.add(MFA_CONFIRM_BTN);
|
||||
|
||||
const session = await service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(session?.phase, "mfa_waiting");
|
||||
assert.equal(session?.mfaRequired, true);
|
||||
assert.equal(session?.error, null);
|
||||
assert.ok(!fake.__state.browserClosed, "browser must stay open while MFA is pending");
|
||||
});
|
||||
|
||||
test("submitCode completes login from mfa_waiting with the second code", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
// First submit → MFA modal opens
|
||||
fake.__state.visible.add(MFA_MODAL);
|
||||
fake.__state.visible.add(MFA_INPUT);
|
||||
fake.__state.visible.add(MFA_CONFIRM_BTN);
|
||||
fake.__state.cookiesFn = () => [
|
||||
{ name: "digest", domain: ".volcengine.com", value: "d1" },
|
||||
{ name: "csrfToken", domain: ".volcengine.com", value: "c1" },
|
||||
];
|
||||
const mfa = await service.submitCode(started.session.sessionId, "111111", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(mfa?.phase, "mfa_waiting");
|
||||
|
||||
// Second submit from mfa_waiting: modal closes, all cookies land
|
||||
fake.__state.visible.delete(MFA_MODAL);
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
const done = await service.submitCode(started.session.sessionId, "222222");
|
||||
assert.equal(done?.phase, "success");
|
||||
assert.equal(fake.__state.fills[MFA_INPUT], "222222");
|
||||
assert.ok(fake.__state.clicks.includes(MFA_CONFIRM_BTN));
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
});
|
||||
|
||||
test("submitCode returns to mfa_waiting when the MFA code is rejected", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.visible.add(MFA_MODAL);
|
||||
fake.__state.visible.add(MFA_INPUT);
|
||||
fake.__state.visible.add(MFA_CONFIRM_BTN);
|
||||
fake.__state.cookiesFn = () => [];
|
||||
const mfa = await service.submitCode(started.session.sessionId, "111111", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(mfa?.phase, "mfa_waiting");
|
||||
|
||||
// Modal still up after submitting a wrong second code → retry state
|
||||
const retry = await service.submitCode(started.session.sessionId, "222222", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(retry?.phase, "mfa_waiting");
|
||||
assert.match(retry?.error || "", /not accepted/i);
|
||||
});
|
||||
|
||||
test("submitCode degrades to fallback_manual for the TOTP binding modal", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.visible.add(MFA_BIND_MODAL);
|
||||
fake.__state.cookiesFn = () => [];
|
||||
const session = await service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(session?.phase, "fallback_manual");
|
||||
assert.match(session?.error || "", /binding an MFA device/i);
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
});
|
||||
|
||||
test("submitCode navigates to the ark console page when the redirect leaves cookies incomplete", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
// Login redirected to the console home, cookies only complete AFTER the
|
||||
// console app runs (simulated by completing the jar on goto).
|
||||
fake.__state.url = "https://console.volcengine.com/";
|
||||
fake.__state.cookiesFn = () => [
|
||||
{ name: "digest", domain: ".volcengine.com", value: "d1" },
|
||||
{ name: "csrfToken", domain: ".volcengine.com", value: "c1" },
|
||||
];
|
||||
|
||||
const submitPromise = service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
// Complete the cookies once the service navigates to the ark page
|
||||
const waitNav = new Promise<void>((resolve) => {
|
||||
const iv = setInterval(() => {
|
||||
if (fake.__state.gotoCalls.some((u) => u.includes("/ark/"))) {
|
||||
clearInterval(iv);
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
resolve();
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
await waitNav;
|
||||
const session = await submitPromise;
|
||||
assert.equal(session?.phase, "success");
|
||||
assert.ok(
|
||||
fake.__state.gotoCalls.some((u) => u.includes("/ark/")),
|
||||
"must navigate to the ark console page to finish cookie issuance"
|
||||
);
|
||||
});
|
||||
|
||||
test("resendCode from mfa_waiting clicks the modal resend button and stays in mfa", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake); // resendCooldownMs: 20ms
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.visible.add(MFA_MODAL);
|
||||
fake.__state.visible.add(MFA_INPUT);
|
||||
fake.__state.visible.add(MFA_CONFIRM_BTN);
|
||||
fake.__state.cookiesFn = () => [];
|
||||
const mfa = await service.submitCode(started.session.sessionId, "111111", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(mfa?.phase, "mfa_waiting");
|
||||
|
||||
// Wait out the 20ms cooldown, then resend must click 重发校验码 (not 获取验证码)
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
fake.__state.visible.add(MFA_RESEND_BTN);
|
||||
const resent = await service.resendCode(started.session.sessionId);
|
||||
assert.equal(resent?.phase, "mfa_waiting");
|
||||
assert.ok(fake.__state.clicks.includes(MFA_RESEND_BTN), "must click the MFA resend button");
|
||||
});
|
||||
|
||||
test("submitCode ignores unknown sessions", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
const service = fastService(fake);
|
||||
assert.equal(await service.submitCode("missing", "123456"), null);
|
||||
});
|
||||
|
||||
// ─── Identity selection (/auth/login/select_identity) ───────────────────
|
||||
|
||||
test("submitCode transitions to identity_required on the select_identity page", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
// SMS code accepted → redirected to identity selection with the REAL page
|
||||
// structure: ul[class*=accountUl] > li[class*=accountLi]
|
||||
fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/";
|
||||
fake.__state.lists[IDENTITY_LIST] = [
|
||||
"主账号 company-main (ID:1000)",
|
||||
"子账号 yangsiyuan (ID:2000)",
|
||||
];
|
||||
fake.__state.cookiesFn = () => [
|
||||
{ name: "digest", domain: ".volcengine.com", value: "d1" },
|
||||
{ name: "csrfToken", domain: ".volcengine.com", value: "c1" },
|
||||
];
|
||||
|
||||
const session = await service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(session?.phase, "identity_required");
|
||||
assert.deepEqual(session?.identityOptions, [
|
||||
{ index: 0, label: "主账号 company-main (ID:1000)" },
|
||||
{ index: 1, label: "子账号 yangsiyuan (ID:2000)" },
|
||||
]);
|
||||
assert.ok(!fake.__state.browserClosed, "browser must stay open while identity is pending");
|
||||
});
|
||||
|
||||
test("selectIdentity clicks the chosen identity and the submit button, then completes login", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/";
|
||||
fake.__state.lists[IDENTITY_LIST] = [
|
||||
"主账号 company-main (ID:1000)",
|
||||
"子账号 yangsiyuan (ID:2000)",
|
||||
];
|
||||
fake.__state.lists[IDENTITY_ITEM] = ["item-0", "item-1"];
|
||||
fake.__state.visible.add(IDENTITY_SUBMIT);
|
||||
const select = await service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(select?.phase, "identity_required");
|
||||
|
||||
// Choosing identity #1: item click + submit click fire, cookies complete
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
fake.__state.url = "https://console.volcengine.com/console/home";
|
||||
const done = await service.selectIdentity(started.session.sessionId, 1);
|
||||
assert.equal(done?.phase, "success");
|
||||
assert.ok(fake.__state.clicks.includes(`${IDENTITY_ITEM}[1]`), "must click identity item 1");
|
||||
assert.ok(fake.__state.clicks.includes(IDENTITY_SUBMIT), "must click the submit button");
|
||||
assert.equal(done?.identityOptions, undefined);
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
});
|
||||
|
||||
test("selectIdentity with index 0 skips the item click (page pre-selects the first identity)", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/";
|
||||
fake.__state.lists[IDENTITY_LIST] = [
|
||||
"主账号 company-main (ID:1000)",
|
||||
"子账号 yangsiyuan (ID:2000)",
|
||||
];
|
||||
fake.__state.lists[IDENTITY_ITEM] = ["item-0", "item-1"];
|
||||
fake.__state.visible.add(IDENTITY_SUBMIT);
|
||||
await service.submitCode(started.session.sessionId, "123456", undefined, { timeout: 2_000 });
|
||||
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
fake.__state.url = "https://console.volcengine.com/console/home";
|
||||
const done = await service.selectIdentity(started.session.sessionId, 0);
|
||||
assert.equal(done?.phase, "success");
|
||||
assert.ok(
|
||||
!fake.__state.clicks.some((c) => c.startsWith(IDENTITY_ITEM)),
|
||||
"index 0 must not click an item — the page pre-selects it"
|
||||
);
|
||||
assert.ok(fake.__state.clicks.includes(IDENTITY_SUBMIT));
|
||||
});
|
||||
|
||||
test("selectIdentity rejects an out-of-range index", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/";
|
||||
fake.__state.lists[IDENTITY_LIST] = ["主账号 company-main (ID:1000)"];
|
||||
fake.__state.lists[IDENTITY_ITEM] = ["item-0"];
|
||||
fake.__state.visible.add(IDENTITY_SUBMIT);
|
||||
const select = await service.submitCode(started.session.sessionId, "123456", undefined, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
assert.equal(select?.phase, "identity_required");
|
||||
|
||||
const session = await service.selectIdentity(started.session.sessionId, 5);
|
||||
assert.equal(session?.phase, "identity_required");
|
||||
assert.match(session?.error || "", /out of range/i);
|
||||
assert.ok(!fake.__state.browserClosed, "session must survive a bad index");
|
||||
});
|
||||
|
||||
test("selectIdentity surfaces an MFA step-up triggered by the identity submit", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/";
|
||||
fake.__state.lists[IDENTITY_LIST] = ["主账号 company-main (ID:1000)"];
|
||||
fake.__state.lists[IDENTITY_ITEM] = ["item-0"];
|
||||
fake.__state.visible.add(IDENTITY_SUBMIT);
|
||||
await service.submitCode(started.session.sessionId, "123456", undefined, { timeout: 2_000 });
|
||||
|
||||
// Identity submit triggers ANOTHER MFA step-up
|
||||
fake.__state.visible.add(MFA_MODAL);
|
||||
fake.__state.visible.add(MFA_INPUT);
|
||||
fake.__state.visible.add(MFA_CONFIRM_BTN);
|
||||
fake.__state.cookiesFn = () => [];
|
||||
const session = await service.selectIdentity(started.session.sessionId, 0);
|
||||
assert.equal(session?.phase, "mfa_waiting");
|
||||
assert.equal(session?.mfaRequired, true);
|
||||
});
|
||||
|
||||
test("selectIdentity is ignored outside the identity_required phase", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
const session = await service.selectIdentity(started.session.sessionId, 0);
|
||||
assert.equal(session?.phase, "waiting_code");
|
||||
});
|
||||
|
||||
// ─── cancel / resend ────────────────────────────────────────────────────────
|
||||
|
||||
test("cancel aborts an active session and closes the browser", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
const session = await service.cancel(started.session.sessionId);
|
||||
assert.equal(session?.phase, "cancelled");
|
||||
assert.ok(fake.__state.browserClosed);
|
||||
assert.equal(service.getStatus(started.session.sessionId)?.phase, "cancelled");
|
||||
});
|
||||
|
||||
test("resendCode respects the cooldown window", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
const clicksBefore = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length;
|
||||
|
||||
const session = await service.resendCode(started.session.sessionId);
|
||||
assert.equal(session?.phase, "waiting_code");
|
||||
const clicksAfter = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length;
|
||||
assert.equal(clicksAfter, clicksBefore, "resend must not click during cooldown");
|
||||
});
|
||||
|
||||
test("resendCode clicks again once the cooldown passed", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake); // resendCooldownMs: 20ms
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
|
||||
// Still inside the 20ms cooldown → no second click
|
||||
await service.resendCode(started.session.sessionId);
|
||||
let clicks = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length;
|
||||
assert.equal(clicks, 1, "resend must not click during cooldown");
|
||||
|
||||
// Cooldown elapsed → click fires and phase resets to waiting_code
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const session = await service.resendCode(started.session.sessionId);
|
||||
clicks = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length;
|
||||
assert.equal(clicks, 2, "resend clicks the send-code button after cooldown");
|
||||
assert.equal(session?.phase, "waiting_code");
|
||||
assert.equal(session?.error, null);
|
||||
});
|
||||
|
||||
// ─── withBinding ────────────────────────────────────────────────────────────
|
||||
|
||||
test("withBinding binds once and reuses the result across polls", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
const submitted = await service.submitCode(started.session.sessionId, "123456");
|
||||
assert.equal(submitted?.phase, "success");
|
||||
|
||||
let bindCalls = 0;
|
||||
const bind = async () => {
|
||||
bindCalls++;
|
||||
return { results: [{ plan: "coding", ok: true }] };
|
||||
};
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
service.withBinding(started.session.sessionId, bind),
|
||||
service.withBinding(started.session.sessionId, bind),
|
||||
]);
|
||||
await service.withBinding(started.session.sessionId, bind);
|
||||
|
||||
assert.equal(bindCalls, 1, "concurrent bind calls are deduped");
|
||||
assert.deepEqual((a as { binding: unknown }).binding, {
|
||||
results: [{ plan: "coding", ok: true }],
|
||||
});
|
||||
assert.deepEqual((b as { binding: unknown }).binding, {
|
||||
results: [{ plan: "coding", ok: true }],
|
||||
});
|
||||
});
|
||||
|
||||
test("withBinding records bind failures without retrying forever", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
fake.__state.cookiesFn = () => FULL_COOKIES;
|
||||
await service.submitCode(started.session.sessionId, "123456");
|
||||
|
||||
let bindCalls = 0;
|
||||
const view = await service.withBinding(started.session.sessionId, async () => {
|
||||
bindCalls++;
|
||||
throw new Error("boom");
|
||||
});
|
||||
await service.withBinding(started.session.sessionId, async () => {
|
||||
bindCalls++;
|
||||
throw new Error("boom-2");
|
||||
});
|
||||
|
||||
assert.equal(bindCalls, 1, "failed bind is recorded, not retried");
|
||||
assert.deepEqual((view as { binding: unknown }).binding, { error: "boom" });
|
||||
});
|
||||
|
||||
test("withBinding returns the view unchanged before success", async () => {
|
||||
const fake = makeFakePlaywright();
|
||||
happyPathVisible(fake);
|
||||
const service = fastService(fake);
|
||||
|
||||
const started = (await service.startLogin("13800000000")) as {
|
||||
session: { sessionId: string };
|
||||
};
|
||||
let bindCalls = 0;
|
||||
const view = await service.withBinding(started.session.sessionId, async () => {
|
||||
bindCalls++;
|
||||
return { results: [] };
|
||||
});
|
||||
assert.equal(bindCalls, 0);
|
||||
assert.equal(view?.phase, "waiting_code");
|
||||
});
|
||||
Reference in New Issue
Block a user