import { getMetadataArgsStorage } from 'typeorm'; import { ToolsService } from '@/common'; import { NapcatAccountBinding, NapcatContainer, NapcatDeviceIdentity, NapcatDeviceIdentityService, NAPCAT_RUNTIME_DOMAIN_CONTRACT, NAPCAT_RUNTIME_ENTITIES, } from '../../../../src/modules/qqbot/napcat'; import { toNapcatDockerDeviceOptions } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options'; import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; 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 登录运行态对象或配置。 */ const createIdentityRepository = () => { const identities = new Map(); return { create: jest.fn((input: Partial) => ({ ...input, })), findOne: jest.fn(async ({ where }: { where: { accountId: string } }) => { return identities.get(where.accountId) || null; }), save: jest.fn(async (identity: NapcatDeviceIdentity) => { identities.set(identity.accountId, identity); return identity; }), /** * Seeds a persisted identity so migration tests can start from legacy Docker-style values. * @param identity - In-memory row keyed by account id for the repository fake. */ seedIdentity: jest.fn((identity: NapcatDeviceIdentity) => { identities.set(identity.accountId, identity); }), update: jest.fn( async ({ id }: { id: string }, input: Partial) => { const current = [...identities.values()].find((item) => item.id === id); if (current) Object.assign(current, input); return { affected: current ? 1 : 0 }; }, ), }; }; /** * 创建 NapCat 登录运行态对象或配置。 */ const createIdentityConfig = () => ({ get: jest.fn((key: string, defaultValue?: string) => { const values: Record = { QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat', QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances', }; return values[key] || defaultValue || ''; }), }) as any; describe('NapCat device identity persistence', () => { const schema = readRefactorV3SqlSchema(); it('declares Batch 7 NapCat runtime persistence tables', () => { expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual( expect.arrayContaining([ 'napcat_container', 'napcat_device_identity', 'napcat_account_binding', 'napcat_login_session', 'napcat_login_challenge', 'napcat_runtime_cleanup', 'napcat_runtime_profile', 'napcat_protocol_profile', 'napcat_session_behavior_profile', 'napcat_login_event', 'napcat_risk_mode', ]), ); for (const table of NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables) { expect(schema.hasTable(table)).toBe(true); } }); it('maps the device identity entity to the v3 SQL schema', () => { expect(NAPCAT_RUNTIME_ENTITIES).toContain(NapcatDeviceIdentity); const tableName = getEntityTableName(NapcatDeviceIdentity); const columns = getEntityColumnNames(NapcatDeviceIdentity); expect(tableName).toBe('napcat_device_identity'); schema.expectTableColumns(tableName || '', columns); expect(columns).toEqual( expect.arrayContaining([ 'account_id', 'container_id', 'data_dir', 'hostname', 'machine_id_path', 'mac_address', 'verification_status', 'last_login_evidence', ]), ); }); it('maps target container and account binding entities to the v3 SQL schema', () => { for (const entity of [NapcatContainer, NapcatAccountBinding]) { expect(NAPCAT_RUNTIME_ENTITIES).toContain(entity); const tableName = getEntityTableName(entity); const columns = getEntityColumnNames(entity); expect(tableName).toBeTruthy(); schema.expectTableColumns(tableName || '', columns); } }); it('reuses data dir, hostname, machine-id path, and MAC when rebuilding the same account container', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, createIdentityConfig(), ); const first = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-first', selfId: '10001', }); const second = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-rebuilt', selfId: '10001', }); expect(second.dataDir).toBe(first.dataDir); expect(second.hostname).toBe(first.hostname); expect(second.machineIdPath).toBe(first.machineIdPath); expect(second.macAddress).toBe(first.macAddress); expect(repository.save).toHaveBeenCalledTimes(1); expect(repository.update).toHaveBeenCalledWith( { id: first.id }, expect.objectContaining({ containerId: 'container-rebuilt', }), ); }); it('generates a short QQNT-visible hostname without QQBot or container words', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, createIdentityConfig(), ); const identity = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-first', selfId: '10001', }); expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/); expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i); }); it('generates a stable Docker bridge MAC that matches QQNT machine-info expectations', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, createIdentityConfig(), ); const identity = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-first', selfId: '10001', }); expect(identity.macAddress).toMatch(/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/); expect(identity.macAddress).not.toMatch(/^52:54:00/i); expect(identity.macAddress).not.toMatch( /^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i, ); expect(identity.macStrategy).toBe('docker-bridge-mac-v1'); }); it('records regression-repair evidence when an existing identity is realigned for QQNT', async () => { const repository = createIdentityRepository(); repository.seedIdentity({ accountId: 'account-10001', containerId: 'container-first', dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', hostname: 'kt-qqbot-napcat-10001', id: 'identity-1', lastLoginEvidence: null, macAddress: '02:42:aa:bb:cc:dd', machineIdPath: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001/machine-id', verificationStatus: 'pending', } as NapcatDeviceIdentity); const service = new NapcatDeviceIdentityService( repository as any, createIdentityConfig(), ); const identity = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-rebuilt', selfId: '10001', }); expect(identity.macAddress).toMatch(/^02:42:/i); expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd'); expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001'); expect(identity.lastLoginEvidence).toMatchObject({ migration: { fromMacAddress: '02:42:aa:bb:cc:dd', strategy: 'docker-bridge-mac-v1', trigger: 'qqnt-device-name-regression-repair', }, }); }); it('turns a persisted device identity into Docker device options', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, createIdentityConfig(), ); const identity = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-first', selfId: '10001', }); const dockerOptions = toNapcatDockerDeviceOptions(identity); expect(dockerOptions).toEqual( expect.objectContaining({ dataDir: identity.dataDir, deviceEnvPath: `${identity.dataDir}/device.env`, hostname: identity.hostname, machineIdPath: identity.machineIdPath, machineInfoPath: `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`, macAddress: identity.macAddress, macAddressHyphen: identity.macAddress.replace(/:/g, '-'), }), ); expect(dockerOptions.runFlags).toEqual( expect.arrayContaining([ '--hostname "$NAPCAT_HOSTNAME"', '--mac-address "$NAPCAT_MAC_ADDRESS"', '-v "$MACHINE_ID_PATH:/etc/machine-id:ro"', ]), ); }); it('applies persisted device options to the NapCat docker create script', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, createIdentityConfig(), ); const identity = await service.resolveForAccount({ accountId: 'account-10001', containerId: 'container-first', selfId: '10001', }); const containerService = new QqbotNapcatContainerService( { get: jest.fn().mockReturnValue('') } as any, {} as any, {} as any, new ToolsService(), ) as any; const script = containerService.buildRemoteCreateScript({ dataDir: identity.dataDir, deviceIdentity: toNapcatDockerDeviceOptions(identity), image: 'mlikiowa/napcat-docker:latest', name: 'kt-qqbot-napcat-10001', port: 6100, reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', token: 'token-test', }); expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`); expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`); expect(script).toContain(`MACHINE_ID_PATH='${identity.machineIdPath}'`); expect(script).toContain( `MACHINE_INFO_PATH='${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info'`, ); expect(script).toContain( `NAPCAT_MAC_HYPHEN='${identity.macAddress.replace(/:/g, '-')}'`, ); expect(script).toContain('cat > "$DEVICE_ENV_PATH" < { const identityRepository = createIdentityRepository(); const identityService = new NapcatDeviceIdentityService( identityRepository as any, createIdentityConfig(), ); const bindingRepository = { createQueryBuilder: jest.fn(() => ({ andWhere: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(null), where: jest.fn().mockReturnThis(), })), findOne: jest.fn().mockResolvedValue(null), update: jest.fn(), }; const containerRepository = { create: jest.fn((input) => input), find: jest.fn().mockResolvedValue([]), save: jest.fn(async (input) => ({ ...input, id: 'container-created', })), update: jest.fn(), }; const containerService = new QqbotNapcatContainerService( { get: jest.fn((key: string, defaultValue?: string) => { const values: Record = { QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat', QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest', QQBOT_NAPCAT_PORT_START: '6100', QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances', QQBOT_NAPCAT_SSH_TARGET: 'nas', }; return values[key] || defaultValue || ''; }), } as any, containerRepository as any, bindingRepository as any, new ToolsService(), identityService, ) as any; containerService.runProcess = jest.fn().mockResolvedValue({ stderr: '', stdout: '', }); const runtime = await containerService.prepareAccountContainer({ id: 'account-10001', selfId: '10001', }); expect(runtime).toEqual( expect.objectContaining({ dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', id: 'container-created', name: 'kt-qqbot-napcat-10001', }), ); expect(identityRepository.update).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String) }), expect.objectContaining({ containerId: 'container-created' }), ); expect(containerRepository.save).toHaveBeenCalledWith( expect.objectContaining({ dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', }), ); const createScript = containerService.runProcess.mock.calls[0][2]; expect(bindingRepository.update).toHaveBeenCalledWith( expect.objectContaining({ accountId: 'account-10001', containerId: 'container-created', }), expect.objectContaining({ deviceIdentityId: expect.any(String), }), ); expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"'); }); it('reuses persisted device identity when rebuilding an existing account container login env', async () => { const identityRepository = createIdentityRepository(); const identityService = new NapcatDeviceIdentityService( identityRepository as any, createIdentityConfig(), ); const identity = await identityService.resolveForAccount({ accountId: 'account-10001', containerId: 'container-1', selfId: '10001', }); const bindingRepository = { findOne: jest.fn().mockResolvedValue({ accountId: 'account-10001', containerId: 'container-1', isDeleted: false, isPrimary: true, }), }; const containerRepository = { createQueryBuilder: jest.fn(() => ({ addSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue({ dataDir: identity.dataDir, id: 'container-1', image: 'mlikiowa/napcat-docker:latest', name: 'kt-qqbot-napcat-10001', reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', webuiPort: 6100, webuiToken: 'token-x', }), where: jest.fn().mockReturnThis(), })), update: jest.fn(), }; const containerService = new QqbotNapcatContainerService( { get: jest.fn((key: string, defaultValue?: string) => { const values: Record = { QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat', QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest', QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances', QQBOT_NAPCAT_SSH_TARGET: 'nas', }; return values[key] || defaultValue || ''; }), } as any, containerRepository as any, bindingRepository as any, new ToolsService(), identityService, ) as any; containerService.runProcess = jest .fn() .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=10001\nNAPCAT_QUICK_PASSWORD=old-password\n', }) .mockResolvedValueOnce({ stderr: '', stdout: '' }) .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=10001\n' }); const recreated = await containerService.ensureRuntimeLoginEnv( { id: 'container-1', name: 'kt-qqbot-napcat-10001' }, { clearLoginPassword: true, selfId: '10001', }, ); expect(recreated).toEqual({ changed: true, ok: true }); expect(bindingRepository.findOne).toHaveBeenCalledWith({ where: { bindStatus: 'bound', containerId: 'container-1', isDeleted: false, isPrimary: true, }, }); const createScript = containerService.runProcess.mock.calls[1][2]; expect(createScript).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`); expect(createScript).toContain( `NAPCAT_MAC_ADDRESS='${identity.macAddress}'`, ); expect(createScript).toContain( `MACHINE_ID_PATH='${identity.machineIdPath}'`, ); expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); }); });