280 lines
8.7 KiB
TypeScript
280 lines
8.7 KiB
TypeScript
import { ToolsService } from '@/common';
|
|
import { QqbotNapcatAccountRuntimeService } from '../../../../src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service';
|
|
import { NapcatLoginEventService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-login-event.service';
|
|
import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service';
|
|
|
|
/**
|
|
* Creates a TypeORM-like repository mock for login-event service tests.
|
|
* @returns Repository mock that captures created and saved login-event payloads.
|
|
*/
|
|
const createRepository = () => ({
|
|
create: jest.fn((input) => input),
|
|
findOne: jest.fn(),
|
|
save: jest.fn(async (input) => input),
|
|
update: jest.fn(),
|
|
});
|
|
|
|
/**
|
|
* Creates the chained query builder shape used by account runtime joins.
|
|
* @param rows - Rows returned by `getMany()` for the current query.
|
|
* @returns Query builder mock with chainable filter and ordering methods.
|
|
*/
|
|
const createManyQueryBuilder = <T>(rows: T[]) => ({
|
|
addOrderBy: jest.fn().mockReturnThis(),
|
|
addSelect: jest.fn().mockReturnThis(),
|
|
andWhere: jest.fn().mockReturnThis(),
|
|
getMany: jest.fn().mockResolvedValue(rows),
|
|
orderBy: jest.fn().mockReturnThis(),
|
|
where: jest.fn().mockReturnThis(),
|
|
});
|
|
|
|
describe('NapCat login event and recovery lease', () => {
|
|
it('records quick and password attempts as login events, not send budgets', async () => {
|
|
const repository = createRepository();
|
|
const service = new NapcatLoginEventService(repository as any);
|
|
|
|
await service.record({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
eventKind: 'quick_attempt',
|
|
eventSource: 'watchdog',
|
|
eventStatus: 'success',
|
|
evidence: { method: 'quick' },
|
|
});
|
|
await service.record({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
eventKind: 'password_attempt',
|
|
eventSource: 'watchdog',
|
|
eventStatus: 'failed',
|
|
evidence: { method: 'password' },
|
|
});
|
|
|
|
expect(repository.save).toHaveBeenCalledTimes(2);
|
|
expect(JSON.stringify(repository.save.mock.calls)).not.toMatch(
|
|
/daily|hour|quota|budget/i,
|
|
);
|
|
});
|
|
|
|
it('suspends automatic recovery after captcha, new-device, or manual QR is required', async () => {
|
|
const repository = createRepository();
|
|
const service = new NapcatLoginEventService(repository as any);
|
|
|
|
await service.recordSuspended({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
evidence: { reason: 'new-device-required' },
|
|
reason: 'new_device_required',
|
|
source: 'watchdog',
|
|
});
|
|
|
|
expect(repository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventKind: 'recovery_suspended',
|
|
eventSource: 'watchdog',
|
|
eventStatus: 'blocked',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('blocks automatic recovery until a later successful connection exists', async () => {
|
|
const repository = createRepository();
|
|
repository.findOne.mockResolvedValue({
|
|
createTime: new Date('2026-06-18T08:00:00.000Z'),
|
|
eventKind: 'new_device_required',
|
|
});
|
|
const service = new NapcatLoginEventService(repository as any);
|
|
|
|
await expect(
|
|
service.canAttemptAutomaticRecovery({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
resetAfter: new Date('2026-06-18T07:59:00.000Z'),
|
|
}),
|
|
).resolves.toEqual({
|
|
allowed: false,
|
|
reason: 'new_device_required',
|
|
});
|
|
await expect(
|
|
service.canAttemptAutomaticRecovery({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
resetAfter: new Date('2026-06-18T08:01:00.000Z'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true });
|
|
});
|
|
});
|
|
|
|
describe('NapCat watchdog auto-login boundaries', () => {
|
|
it('does not call container auto-login when recovery is suspended', async () => {
|
|
const accountNapcatRepository = {
|
|
createQueryBuilder: jest.fn(() =>
|
|
createManyQueryBuilder([
|
|
{
|
|
accountId: 'account-1',
|
|
bindStatus: 'bound',
|
|
containerId: 'container-1',
|
|
isPrimary: true,
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
const containerRepository = {
|
|
createQueryBuilder: jest.fn(() =>
|
|
createManyQueryBuilder([
|
|
{
|
|
id: 'container-1',
|
|
lastCheckedAt: new Date(),
|
|
lastError: '账号状态变更为离线',
|
|
name: 'kt-qqbot-napcat-10001',
|
|
status: 'running',
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
const containerService = {
|
|
detectRuntimeOffline: jest.fn(),
|
|
tryAutoLogin: jest.fn(),
|
|
};
|
|
const loginEventService = {
|
|
canAttemptAutomaticRecovery: jest.fn().mockResolvedValue({
|
|
allowed: false,
|
|
reason: 'recovery_suspended',
|
|
}),
|
|
recordSuspended: jest.fn(),
|
|
};
|
|
const service = new QqbotNapcatAccountRuntimeService(
|
|
accountNapcatRepository as any,
|
|
containerRepository as any,
|
|
containerService as any,
|
|
new ToolsService(),
|
|
undefined,
|
|
loginEventService as any,
|
|
);
|
|
|
|
await service.appendRuntime(
|
|
[
|
|
{
|
|
connectStatus: 'online',
|
|
id: 'account-1',
|
|
lastConnectedAt: new Date('2026-06-18T00:00:00.000Z'),
|
|
selfId: '10001',
|
|
} as any,
|
|
],
|
|
{ autoLogin: true },
|
|
{
|
|
clearQqLoginError: jest.fn(),
|
|
getLoginPassword: jest.fn(),
|
|
markOnline: jest.fn(),
|
|
markQqLoginOffline: jest.fn(),
|
|
publishOfflineNotice: jest.fn(),
|
|
},
|
|
);
|
|
|
|
expect(containerService.tryAutoLogin).not.toHaveBeenCalled();
|
|
expect(loginEventService.recordSuspended).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
reason: 'recovery_suspended',
|
|
source: 'watchdog',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not create a manual QR session from watchdog auto-login', async () => {
|
|
const service = new QqbotNapcatContainerService(
|
|
{ get: jest.fn().mockReturnValue('') } as any,
|
|
{} as any,
|
|
{} as any,
|
|
new ToolsService(),
|
|
) as any;
|
|
service.getManagedMode = jest.fn().mockReturnValue('ssh');
|
|
service.findContainerWithToken = jest.fn().mockResolvedValue({
|
|
baseUrl: 'http://127.0.0.1:6100/',
|
|
id: 'container-1',
|
|
name: 'kt-qqbot-napcat-10001',
|
|
});
|
|
service.ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({
|
|
changed: false,
|
|
ok: true,
|
|
});
|
|
service.restartAndDetectLoginState = jest
|
|
.fn()
|
|
.mockResolvedValue({ offlineReason: '历史会话失效', state: 'offline' });
|
|
|
|
const result = await service.tryAutoLogin(
|
|
{ id: 'container-1', name: 'kt-qqbot-napcat-10001' },
|
|
{ selfId: '10001' },
|
|
);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(JSON.stringify(service.runProcess?.mock?.calls || [])).not.toContain(
|
|
'qrcode',
|
|
);
|
|
});
|
|
|
|
it('records password auto-login failure as suspended recovery', async () => {
|
|
const loginEventService = {
|
|
record: jest.fn(),
|
|
recordSuspended: jest.fn(),
|
|
};
|
|
const service = new QqbotNapcatContainerService(
|
|
{ get: jest.fn().mockReturnValue('') } as any,
|
|
{} as any,
|
|
{} as any,
|
|
new ToolsService(),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
loginEventService as any,
|
|
) as any;
|
|
service.getManagedMode = jest.fn().mockReturnValue('ssh');
|
|
service.findContainerWithToken = jest.fn().mockResolvedValue({
|
|
accountId: 'account-1',
|
|
baseUrl: 'http://127.0.0.1:6100/',
|
|
id: 'container-1',
|
|
name: 'kt-qqbot-napcat-10001',
|
|
});
|
|
service.ensureRuntimeLoginEnv = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({ changed: false, ok: true })
|
|
.mockResolvedValueOnce({ changed: true, ok: true })
|
|
.mockResolvedValueOnce({ changed: true, ok: true });
|
|
service.restartAndDetectLoginState = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
offlineReason: '历史会话失效',
|
|
state: 'offline',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
offlineReason: '密码登录失败',
|
|
state: 'offline',
|
|
});
|
|
|
|
const result = await service.tryAutoLogin(
|
|
{ accountId: 'account-1', id: 'container-1' } as any,
|
|
{
|
|
loginPassword: 'qq-password',
|
|
selfId: '10001',
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({ success: false });
|
|
expect(loginEventService.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventKind: 'password_attempt',
|
|
eventStatus: 'failed',
|
|
}),
|
|
);
|
|
expect(loginEventService.recordSuspended).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accountId: 'account-1',
|
|
containerId: 'container-1',
|
|
reason: 'recovery_suspended',
|
|
source: 'watchdog',
|
|
}),
|
|
);
|
|
});
|
|
});
|