442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
import { existsSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { getMetadataArgsStorage } from 'typeorm';
|
|
import {
|
|
NapcatLoginChallengeEntity,
|
|
NapcatLoginSession,
|
|
NapcatLoginStateStoreService,
|
|
NapcatRuntimeCleanup,
|
|
NAPCAT_RUNTIME_ENTITIES,
|
|
} from '../../../../src/modules/qqbot/napcat';
|
|
import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper';
|
|
|
|
const repoRoot = join(__dirname, '../../../..');
|
|
const schema = readRefactorV3SqlSchema();
|
|
|
|
/**
|
|
* 读取 NapCat 登录运行态资源。
|
|
* @param relativePath - 相对文件路径;读取本地文件内容。
|
|
*/
|
|
const readSource = (relativePath: string) =>
|
|
readFileSync(join(repoRoot, relativePath), 'utf8');
|
|
|
|
type EntityClass = new (...args: never[]) => unknown;
|
|
|
|
/**
|
|
* 查询 NapCat 登录运行态数据。
|
|
* @param entity - entity 输入;驱动 `getMetadataArgsStorage()` 的 NapCat步骤。
|
|
*/
|
|
const getEntityTableName = (entity: EntityClass) => {
|
|
return getMetadataArgsStorage().tables.find(
|
|
(table) => table.target === entity,
|
|
)?.name;
|
|
};
|
|
|
|
/**
|
|
* 查询 NapCat 登录运行态数据。
|
|
* @param entity - entity 输入;驱动 `getMetadataArgsStorage()` 的 NapCat步骤。
|
|
*/
|
|
const getEntityColumnNames = (entity: EntityClass) => {
|
|
return getMetadataArgsStorage()
|
|
.columns.filter((column) => column.target === entity)
|
|
.map((column) => `${column.options.name || column.propertyName}`);
|
|
};
|
|
|
|
/**
|
|
* 查询 NapCat 登录运行态数据。
|
|
* @param entity - entity 输入;驱动 `getMetadataArgsStorage()` 的 NapCat步骤。
|
|
* @param propertyName - propertyName 输入;驱动 `getMetadataArgsStorage()` 的 NapCat步骤。
|
|
*/
|
|
const getEntityColumnOptions = (entity: EntityClass, propertyName: string) => {
|
|
return getMetadataArgsStorage().columns.find(
|
|
(column) =>
|
|
column.target === entity && column.propertyName === propertyName,
|
|
)?.options;
|
|
};
|
|
|
|
/**
|
|
* 查询 NapCat 登录运行态数据。
|
|
* @param tableName - tableName 输入;驱动 `getTableColumns()` 的 NapCat步骤。
|
|
* @param columnName - columnName 输入;驱动 `getTableColumns()` 的 NapCat步骤。
|
|
*/
|
|
const getSchemaColumnDefinition = (tableName: string, columnName: string) => {
|
|
return schema
|
|
.getTableColumns(tableName)
|
|
.find((column) => column.name === columnName)?.definition;
|
|
};
|
|
|
|
/**
|
|
* 创建 NapCat 登录运行态对象或配置。
|
|
*/
|
|
const createRepository = <T extends Record<string, any>>() => {
|
|
const rows: T[] = [];
|
|
return {
|
|
create: jest.fn((input: Partial<T>) => ({ ...input }) as T),
|
|
findOne: jest.fn(async ({ where }: { where: Record<string, any> }) => {
|
|
return (
|
|
rows.find((row) =>
|
|
Object.entries(where).every(([key, value]) => row[key] === value),
|
|
) || null
|
|
);
|
|
}),
|
|
rows,
|
|
save: jest.fn(async (input: T) => {
|
|
rows.push(input);
|
|
return input;
|
|
}),
|
|
update: jest.fn(async (where: Record<string, any>, input: Partial<T>) => {
|
|
const row = rows.find((item) =>
|
|
Object.entries(where).every(([key, value]) => item[key] === value),
|
|
);
|
|
if (row) Object.assign(row, input);
|
|
return { affected: row ? 1 : 0 };
|
|
}),
|
|
};
|
|
};
|
|
|
|
describe('NapCat persistent login state contract', () => {
|
|
it('owns the approved third-phase persistence tables', () => {
|
|
const persistenceRoot = join(
|
|
repoRoot,
|
|
'src/modules/qqbot/napcat/infrastructure/persistence',
|
|
);
|
|
const sqlSchema = readSource('sql/refactor-v3/00-full-schema.sql');
|
|
const persistenceFiles = existsSync(persistenceRoot)
|
|
? readSource(
|
|
'src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts',
|
|
) +
|
|
'\n' +
|
|
readSource(
|
|
'src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts',
|
|
)
|
|
: '';
|
|
const source = `${sqlSchema}\n${persistenceFiles}`;
|
|
|
|
const missing = [
|
|
'napcat_container',
|
|
'napcat_device_identity',
|
|
'napcat_account_binding',
|
|
'napcat_login_session',
|
|
'napcat_login_challenge',
|
|
'napcat_runtime_cleanup',
|
|
].filter((tableName) => !source.includes(tableName));
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it('keeps login truth in persistence rather than in-memory scan sessions', () => {
|
|
const loginSource = readSource(
|
|
'src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts',
|
|
);
|
|
|
|
const bannedMemorySignals = ['new Map', 'scanSessions'].filter((signal) =>
|
|
loginSource.includes(signal),
|
|
);
|
|
const missingPersistenceSignals = [
|
|
'loginSessionStore',
|
|
'recordCaptchaChallenge',
|
|
'recordNewDeviceChallenge',
|
|
].filter((signal) => !loginSource.includes(signal));
|
|
|
|
expect(bannedMemorySignals).toEqual([]);
|
|
expect(missingPersistenceSignals).toEqual([]);
|
|
});
|
|
|
|
it('maps login session, challenge, and cleanup entities to the v3 SQL schema', () => {
|
|
expect(NAPCAT_RUNTIME_ENTITIES).toEqual(
|
|
expect.arrayContaining([
|
|
NapcatLoginSession,
|
|
NapcatLoginChallengeEntity,
|
|
NapcatRuntimeCleanup,
|
|
]),
|
|
);
|
|
|
|
for (const entity of [
|
|
NapcatLoginSession,
|
|
NapcatLoginChallengeEntity,
|
|
NapcatRuntimeCleanup,
|
|
]) {
|
|
const tableName = getEntityTableName(entity);
|
|
const columns = getEntityColumnNames(entity);
|
|
expect(tableName).toBeTruthy();
|
|
schema.expectTableColumns(tableName || '', columns);
|
|
}
|
|
});
|
|
|
|
it('stores challenge and cleanup session ids as uuid-safe text', () => {
|
|
expect(
|
|
getEntityColumnOptions(NapcatLoginChallengeEntity, 'sessionId'),
|
|
).toMatchObject({ length: 64, type: 'varchar' });
|
|
expect(
|
|
getEntityColumnOptions(NapcatRuntimeCleanup, 'sessionId'),
|
|
).toMatchObject({ length: 64, type: 'varchar' });
|
|
|
|
expect(
|
|
getSchemaColumnDefinition('napcat_login_challenge', 'session_id'),
|
|
).toMatch(/^session_id VARCHAR\(64\) NOT NULL/i);
|
|
expect(
|
|
getSchemaColumnDefinition('napcat_runtime_cleanup', 'session_id'),
|
|
).toMatch(/^session_id VARCHAR\(64\) NOT NULL/i);
|
|
|
|
const qqbotInitSql = readSource('sql/qqbot-init.sql');
|
|
expect(qqbotInitSql).toContain('`session_id` varchar(64) NOT NULL');
|
|
});
|
|
|
|
it('keeps NapCat runtime tables covered by the v3 verification SQL', () => {
|
|
const verifySql = readSource('sql/refactor-v3/99-verify.sql');
|
|
const requiredSignals = [
|
|
'napcat_container',
|
|
'napcat_device_identity',
|
|
'napcat_account_binding',
|
|
'napcat_login_session',
|
|
'napcat_login_challenge',
|
|
'napcat_runtime_cleanup',
|
|
'uk_napcat_login_session_key',
|
|
'idx_napcat_login_challenge_session',
|
|
'idx_napcat_runtime_cleanup_session',
|
|
];
|
|
|
|
expect(
|
|
requiredSignals.filter((signal) => !verifySql.includes(signal)),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('persists session snapshots, challenge state, and cleanup blockers', async () => {
|
|
const loginSessionRepository = createRepository<NapcatLoginSession>();
|
|
const loginChallengeRepository =
|
|
createRepository<NapcatLoginChallengeEntity>();
|
|
const runtimeCleanupRepository = createRepository<NapcatRuntimeCleanup>();
|
|
const store = new NapcatLoginStateStoreService(
|
|
loginSessionRepository as any,
|
|
loginChallengeRepository as any,
|
|
runtimeCleanupRepository as any,
|
|
);
|
|
|
|
const session = {
|
|
captchaUrl: 'https://captcha.example/proof',
|
|
containerId: 'container-1',
|
|
containerName: 'kt-qqbot-napcat-10001',
|
|
createdAt: 1000,
|
|
errorMessage: '需要验证码',
|
|
expiresAt: Date.now() + 60_000,
|
|
expectedSelfId: '10001',
|
|
id: 'scan-session-1',
|
|
mode: 'refresh',
|
|
passwordMd5: 'md5',
|
|
status: 'pending',
|
|
webuiPort: 6099,
|
|
} as const;
|
|
|
|
store.set(session);
|
|
store.recordCaptchaChallenge(session);
|
|
store.recordRuntimeCleanup(session, {
|
|
cleanupType: 'password-login-env',
|
|
errorMessage: '运行态清理失败',
|
|
status: 'failed',
|
|
});
|
|
|
|
await store.flushSessionWrites('scan-session-1');
|
|
await Promise.resolve();
|
|
|
|
expect(loginSessionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
loginStage: 'captcha',
|
|
sessionKey: 'scan-session-1',
|
|
sessionPayload: session,
|
|
status: 'pending',
|
|
}),
|
|
);
|
|
expect(loginChallengeRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
challengeType: 'captcha',
|
|
challengeUrl: 'https://captcha.example/proof',
|
|
sessionId: 'scan-session-1',
|
|
status: 'pending',
|
|
}),
|
|
);
|
|
expect(runtimeCleanupRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cleanupType: 'password-login-env',
|
|
errorMessage: '运行态清理失败',
|
|
sessionId: 'scan-session-1',
|
|
status: 'failed',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('recovers captcha, new-device, and cleanup blockers after cache miss', async () => {
|
|
const loginSessionRepository = createRepository<NapcatLoginSession>();
|
|
const loginChallengeRepository =
|
|
createRepository<NapcatLoginChallengeEntity>();
|
|
const runtimeCleanupRepository = createRepository<NapcatRuntimeCleanup>();
|
|
const store = new NapcatLoginStateStoreService(
|
|
loginSessionRepository as any,
|
|
loginChallengeRepository as any,
|
|
runtimeCleanupRepository as any,
|
|
);
|
|
|
|
loginSessionRepository.rows.push({
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
sessionKey: 'captcha-recover',
|
|
sessionPayload: {
|
|
containerId: 'container-captcha',
|
|
containerName: 'kt-qqbot-napcat-captcha',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 60_000,
|
|
id: 'captcha-recover',
|
|
mode: 'refresh',
|
|
passwordMd5: 'md5',
|
|
status: 'pending',
|
|
webuiPort: 6099,
|
|
},
|
|
status: 'pending',
|
|
} as any);
|
|
loginChallengeRepository.rows.push({
|
|
challengePayload: { expectedSelfId: '10001' },
|
|
challengeType: 'captcha',
|
|
challengeUrl: 'https://captcha.example/proof',
|
|
sessionId: 'captcha-recover',
|
|
status: 'pending',
|
|
} as any);
|
|
|
|
loginSessionRepository.rows.push({
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
sessionKey: 'new-device-recover',
|
|
sessionPayload: {
|
|
containerId: 'container-device',
|
|
containerName: 'kt-qqbot-napcat-device',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 60_000,
|
|
id: 'new-device-recover',
|
|
mode: 'refresh',
|
|
status: 'pending',
|
|
webuiPort: 6099,
|
|
},
|
|
status: 'pending',
|
|
} as any);
|
|
loginChallengeRepository.rows.push({
|
|
challengePayload: {
|
|
deviceVerifyUrl: 'https://ti.qq.com/new-device/verify',
|
|
newDeviceBytesToken: 'bytes-new-device',
|
|
newDevicePullQrCodeSig: { key: 'sig-new-device' },
|
|
newDeviceQrcode: 'data:image/png;base64,new-device-qrcode',
|
|
},
|
|
challengeType: 'new-device',
|
|
challengeUrl: 'data:image/png;base64,new-device-qrcode',
|
|
sessionId: 'new-device-recover',
|
|
status: 'qr-pending',
|
|
} as any);
|
|
|
|
loginSessionRepository.rows.push({
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
sessionKey: 'cleanup-recover',
|
|
sessionPayload: {
|
|
containerId: 'container-cleanup',
|
|
containerName: 'kt-qqbot-napcat-cleanup',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 60_000,
|
|
id: 'cleanup-recover',
|
|
mode: 'refresh',
|
|
passwordMd5: 'md5',
|
|
status: 'pending',
|
|
webuiPort: 6099,
|
|
},
|
|
status: 'pending',
|
|
} as any);
|
|
runtimeCleanupRepository.rows.push({
|
|
cleanupType: 'password-login-env',
|
|
errorMessage: '运行态密码清理失败',
|
|
sessionId: 'cleanup-recover',
|
|
status: 'failed',
|
|
} as any);
|
|
|
|
await expect(store.get('captcha-recover')).resolves.toEqual(
|
|
expect.objectContaining({
|
|
captchaUrl: 'https://captcha.example/proof',
|
|
expectedSelfId: '10001',
|
|
id: 'captcha-recover',
|
|
status: 'pending',
|
|
}),
|
|
);
|
|
await expect(store.get('new-device-recover')).resolves.toEqual(
|
|
expect.objectContaining({
|
|
deviceVerifyUrl: 'https://ti.qq.com/new-device/verify',
|
|
newDeviceBytesToken: 'bytes-new-device',
|
|
newDevicePullQrCodeSig: { key: 'sig-new-device' },
|
|
newDeviceQrcode: 'data:image/png;base64,new-device-qrcode',
|
|
newDeviceStatus: 'qr-pending',
|
|
}),
|
|
);
|
|
await expect(store.get('cleanup-recover')).resolves.toEqual(
|
|
expect.objectContaining({
|
|
errorMessage: '运行态密码清理失败',
|
|
passwordMd5: undefined,
|
|
status: 'error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not recover resolved new-device challenges as actionable QR state', async () => {
|
|
const loginSessionRepository = createRepository<NapcatLoginSession>();
|
|
const loginChallengeRepository =
|
|
createRepository<NapcatLoginChallengeEntity>();
|
|
const store = new NapcatLoginStateStoreService(
|
|
loginSessionRepository as any,
|
|
loginChallengeRepository as any,
|
|
);
|
|
|
|
loginSessionRepository.rows.push({
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
sessionKey: 'new-device-failed',
|
|
sessionPayload: {
|
|
containerId: 'container-device',
|
|
containerName: 'kt-qqbot-napcat-device',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 60_000,
|
|
id: 'new-device-failed',
|
|
mode: 'refresh',
|
|
status: 'pending',
|
|
webuiPort: 6099,
|
|
},
|
|
status: 'pending',
|
|
} as any);
|
|
loginChallengeRepository.rows.push({
|
|
challengePayload: {
|
|
deviceVerifyUrl: 'https://ti.qq.com/new-device/verify',
|
|
newDevicePullQrCodeSig: 'stale-sig',
|
|
newDeviceQrcode: 'data:image/png;base64,stale-qrcode',
|
|
},
|
|
challengeType: 'new-device',
|
|
challengeUrl: 'data:image/png;base64,stale-qrcode',
|
|
sessionId: 'new-device-failed',
|
|
status: 'failed',
|
|
} as any);
|
|
|
|
const recovered = await store.get('new-device-failed');
|
|
|
|
expect(recovered).toEqual(
|
|
expect.objectContaining({
|
|
id: 'new-device-failed',
|
|
status: 'pending',
|
|
}),
|
|
);
|
|
expect(recovered?.deviceVerifyUrl).toBeUndefined();
|
|
expect(recovered?.newDevicePullQrCodeSig).toBeUndefined();
|
|
expect(recovered?.newDeviceQrcode).toBeUndefined();
|
|
expect(recovered?.newDeviceStatus).toBeUndefined();
|
|
});
|
|
|
|
it('keeps captcha and new-device challenges recoverable and separate', () => {
|
|
const stateMachine = readSource(
|
|
'src/modules/qqbot/napcat/domain/login/napcat-login-state-machine.ts',
|
|
);
|
|
|
|
expect(stateMachine).toEqual(expect.stringContaining("type: 'captcha'"));
|
|
expect(stateMachine).toEqual(expect.stringContaining("type: 'new-device'"));
|
|
expect(stateMachine).toEqual(expect.stringContaining('qr-pending'));
|
|
expect(stateMachine).toEqual(expect.stringContaining('scanned'));
|
|
expect(stateMachine).toEqual(expect.stringContaining('confirming'));
|
|
expect(stateMachine).toEqual(expect.stringContaining('cleanup-failed'));
|
|
});
|
|
});
|