forked from boos/BoosAPI
Add tests for accounts login shield and proxy log retention service
- Implement tests for accounts login shield detection, verifying behavior when login fails with HTML JSON parse syntax error. - Create tests for proxy log retention service, ensuring expired logs are deleted based on retention days and that cleanup is skipped when retention is disabled. - Add tests for token router cache, validating that route snapshots are maintained within TTL until explicitly invalidated. - Introduce necessary mock setups and database seeding for comprehensive testing.
This commit is contained in:
@@ -16,6 +16,13 @@ const CHECKIN_ALREADY_TOKEN = 'checkin-already-token';
|
||||
const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
|
||||
const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token';
|
||||
const BALANCE_FAIL_TOKEN = 'balance-fail-token';
|
||||
const SHIELD_LOGIN_USERNAME = 'shield-user';
|
||||
const SHIELD_LOGIN_PASSWORD = 'shield-pass';
|
||||
const SHIELD_LOGIN_TOKEN = 'login-session-token';
|
||||
const SHIELD_LOGIN_COOKIE = 'challenge-seed';
|
||||
const COOKIE_ONLY_LOGIN_USERNAME = 'cookie-only-user';
|
||||
const COOKIE_ONLY_LOGIN_PASSWORD = 'cookie-only-pass';
|
||||
const COOKIE_ONLY_LOGIN_SESSION = 'cookie-only-session';
|
||||
const COOKIE_SHIELDED_TOKEN = Buffer.from(
|
||||
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
|
||||
).toString('base64');
|
||||
@@ -57,6 +64,66 @@ describe('NewApiAdapter', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/login' && req.method === 'POST') {
|
||||
let bodyRaw = '';
|
||||
req.on('data', (chunk) => {
|
||||
bodyRaw += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(bodyRaw || '{}');
|
||||
} catch {}
|
||||
|
||||
const isShieldLogin =
|
||||
payload.username === SHIELD_LOGIN_USERNAME &&
|
||||
payload.password === SHIELD_LOGIN_PASSWORD;
|
||||
const isCookieOnlyLogin =
|
||||
payload.username === COOKIE_ONLY_LOGIN_USERNAME &&
|
||||
payload.password === COOKIE_ONLY_LOGIN_PASSWORD;
|
||||
if (!isShieldLogin && !isCookieOnlyLogin) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'invalid credentials' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const cookieHeader = typeof req.headers.cookie === 'string' ? req.headers.cookie : '';
|
||||
if (!cookieHeader.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`)) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Set-Cookie': `cdn_sec_tc=${SHIELD_LOGIN_COOKIE}; Path=/; HttpOnly`,
|
||||
});
|
||||
res.end(ANYROUTER_CHALLENGE_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cookieHeader.includes(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`)) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing shield cookie' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCookieOnlyLogin) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `session=${COOKIE_ONLY_LOGIN_SESSION}; Path=/; HttpOnly`,
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: {},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: { token: SHIELD_LOGIN_TOKEN },
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/models') {
|
||||
if (req.headers['new-api-user'] !== '11494') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
@@ -322,6 +389,40 @@ describe('NewApiAdapter', () => {
|
||||
expect(token).toBe('api-key-from-token-list');
|
||||
});
|
||||
|
||||
it('solves anyrouter acw challenge for account-password login', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.login(baseUrl, SHIELD_LOGIN_USERNAME, SHIELD_LOGIN_PASSWORD);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.accessToken).toBe(SHIELD_LOGIN_TOKEN);
|
||||
expect(
|
||||
requests.some(
|
||||
(r) =>
|
||||
r.url === '/api/user/login' &&
|
||||
typeof r.headers.cookie === 'string' &&
|
||||
r.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some(
|
||||
(r) =>
|
||||
r.url === '/api/user/login' &&
|
||||
typeof r.headers.cookie === 'string' &&
|
||||
r.headers.cookie.includes(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses session cookie as access credential when login success has no token payload', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.login(baseUrl, COOKIE_ONLY_LOGIN_USERNAME, COOKIE_ONLY_LOGIN_PASSWORD);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.accessToken || '').toContain(`session=${COOKIE_ONLY_LOGIN_SESSION}`);
|
||||
expect(result.accessToken || '').toContain(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`);
|
||||
expect(result.accessToken || '').toContain(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`);
|
||||
});
|
||||
|
||||
it('detects cookie session values as session cookies for anyrouter-like deployments', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
|
||||
|
||||
@@ -211,6 +211,24 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return { balance: quota, used, quota: total, todayIncome, todayQuotaConsumption };
|
||||
}
|
||||
|
||||
private extractLoginAccessToken(payload: any): string | null {
|
||||
const candidates: unknown[] = [
|
||||
payload?.data,
|
||||
payload?.token,
|
||||
payload?.accessToken,
|
||||
payload?.access_token,
|
||||
payload?.data?.token,
|
||||
payload?.data?.accessToken,
|
||||
payload?.data?.access_token,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const token = candidate.trim();
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): Record<string, unknown> {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
@@ -429,6 +447,32 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return output;
|
||||
}
|
||||
|
||||
private hasUsableSessionCookie(cookieHeader: string): boolean {
|
||||
if (!cookieHeader) return false;
|
||||
const ignored = new Set(['acw_tc', 'acw_sc__v2', 'cdn_sec_tc']);
|
||||
const pairs = cookieHeader.split(';').map((part) => part.trim()).filter(Boolean);
|
||||
for (const pair of pairs) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const name = pair.slice(0, eq).trim().toLowerCase();
|
||||
if (!name || ignored.has(name)) continue;
|
||||
if (
|
||||
name === 'session'
|
||||
|| name === 'token'
|
||||
|| name === 'auth_token'
|
||||
|| name === 'access_token'
|
||||
|| name === 'jwt'
|
||||
|| name === 'jwt_token'
|
||||
|| name.includes('session')
|
||||
|| name.includes('token')
|
||||
|| name.includes('auth')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private shouldFallbackToCookieCheckin(message?: string | null): boolean {
|
||||
if (!message) return true;
|
||||
const text = message.toLowerCase();
|
||||
@@ -449,7 +493,10 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchJsonRaw<T>(url: string, options?: UndiciRequestInit): Promise<T | null> {
|
||||
private async fetchJsonRawWithCookie<T>(
|
||||
url: string,
|
||||
options?: UndiciRequestInit,
|
||||
): Promise<{ data: T | null; cookieHeader: string }> {
|
||||
const { fetch } = await import('undici');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -472,30 +519,34 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
|
||||
const res = await fetch(url, proxiedRequestOptions);
|
||||
const text = await res.text();
|
||||
const parsed = this.parseJsonSafe<T>(text);
|
||||
if (parsed) return parsed;
|
||||
|
||||
if (!this.isShieldChallenge(res.headers.get('content-type') || '', text)) {
|
||||
return null;
|
||||
}
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getSetCookie = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||
if (typeof getSetCookie === 'function') {
|
||||
cookieHeader = this.mergeSetCookiePairs(cookieHeader, getSetCookie.call(res.headers) || []);
|
||||
}
|
||||
const parsed = this.parseJsonSafe<T>(text);
|
||||
if (parsed) return { data: parsed, cookieHeader };
|
||||
|
||||
if (!this.isShieldChallenge(res.headers.get('content-type') || '', text)) {
|
||||
return { data: null, cookieHeader };
|
||||
}
|
||||
if (!cookieHeader) {
|
||||
return { data: null, cookieHeader };
|
||||
}
|
||||
|
||||
const acwScV2 = this.solveAcwScV2(text);
|
||||
if (!acwScV2) {
|
||||
return null;
|
||||
return { data: null, cookieHeader };
|
||||
}
|
||||
cookieHeader = this.upsertCookie(cookieHeader, 'acw_sc__v2', acwScV2);
|
||||
headers['Cookie'] = cookieHeader;
|
||||
}
|
||||
|
||||
return null;
|
||||
return { data: null, cookieHeader };
|
||||
}
|
||||
|
||||
private async fetchJsonRaw<T>(url: string, options?: UndiciRequestInit): Promise<T | null> {
|
||||
const result = await this.fetchJsonRawWithCookie<T>(url, options);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private async fetchUserSelfByCookie(
|
||||
@@ -622,6 +673,51 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return null;
|
||||
}
|
||||
|
||||
override async login(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; accessToken?: string; username?: string; message?: string }> {
|
||||
try {
|
||||
const { data: res, cookieHeader } = await this.fetchJsonRawWithCookie<any>(`${baseUrl}/api/user/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
if (!res) {
|
||||
return { success: false, message: 'shield challenge blocked login' };
|
||||
}
|
||||
|
||||
const accessToken = this.extractLoginAccessToken(res);
|
||||
if (res?.success && accessToken) {
|
||||
return {
|
||||
success: true,
|
||||
accessToken,
|
||||
username,
|
||||
};
|
||||
}
|
||||
if (res?.success && this.hasUsableSessionCookie(cookieHeader)) {
|
||||
return {
|
||||
success: true,
|
||||
accessToken: cookieHeader,
|
||||
username,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: this.extractResponseMessage(res) || '登录失败:未获取到可用会话凭据,请改用 Cookie/Token 导入',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: this.formatRequestErrorMessage(err) || err?.message || '登录请求失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
override async verifyToken(baseUrl: string, token: string, platformUserId?: number): Promise<TokenVerifyResult> {
|
||||
const openAiModels = await this.getOpenAiModels(baseUrl, token);
|
||||
if (openAiModels.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user