Files
SnowLuma/packages/bridge/tests/qq-account-detect.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

242 lines
9.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
const mocks = vi.hoisted(() => ({
readLocalAccounts: vi.fn(),
getDatabaseHolders: vi.fn(),
}));
// The account list and the holder probe are covered on their own; this file is
// about how the two are joined.
vi.mock('../src/qq-account/login-db', () => ({ readLocalAccounts: mocks.readLocalAccounts }));
vi.mock('../src/qq-account/database-holders', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/qq-account/database-holders')>()),
getDatabaseHolders: mocks.getDatabaseHolders,
}));
import { detectAccountLogins, detectAccountLoginsForPids } from '../src/qq-account/detect';
import { loginDbPaths, ntMsgDbPath } from '../src/qq-account/paths';
const ALICE = { uin: '10001', uid: 'u_alice', nickname: 'Alice' };
const BOB = { uin: '10002', uid: 'u_bob', nickname: 'Bob' };
const roots: string[] = [];
const children: Array<ReturnType<typeof spawn>> = [];
afterEach(async () => {
mocks.readLocalAccounts.mockReset();
mocks.getDatabaseHolders.mockReset();
for (const child of children.splice(0)) {
child.kill();
}
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function makeRoot(): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-detect-'));
roots.push(root);
return root;
}
/**
* A live process whose `/proc/<pid>/environ` carries `HOME` (and any extra
* variables given).
*
* `XDG_CONFIG_HOME` is dropped unless the caller sets it: it outranks `HOME`
* when deriving the data root, and a CI runner exports its own, which would
* otherwise decide the result instead of the HOME under test.
*
* Resolves only once the child has run past `execve`, because `/proc/<pid>`
* still shows the parent's environment before that.
*/
function spawnWithHome(home: string, extra: Record<string, string> = {}): Promise<number> {
return new Promise((resolve, reject) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.XDG_CONFIG_HOME;
Object.assign(env, { HOME: home }, extra);
const child = spawn(
process.execPath,
['-e', 'console.log("ready"); setTimeout(() => {}, 30000)'],
{ env, stdio: ['ignore', 'pipe', 'ignore'] },
);
children.push(child);
child.stdout!.once('data', () => resolve(child.pid!));
child.once('error', reject);
});
}
describe('local account detection', () => {
it('labels each account with the process holding its database', async () => {
const root = await makeRoot();
mocks.readLocalAccounts.mockResolvedValue({ accounts: [ALICE, BOB], failures: [] });
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) => {
if (dbPath === ntMsgDbPath(root, ALICE)) {
return [{ pid: 4242, name: 'qq' }];
}
// A holder that is not the client must not be mistaken for one.
return [{ pid: 999, name: 'node' }];
});
await expect(detectAccountLogins([root])).resolves.toEqual([
{ ...ALICE, pid: 4242 },
{ ...BOB, pid: null },
]);
});
it('merges the accounts of every root instead of stopping at the first', async () => {
const own = await makeRoot();
const extra = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === own ? { accounts: [ALICE], failures: [] } : { accounts: [BOB], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) =>
dbPath === ntMsgDbPath(extra, BOB) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([own, extra])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...BOB, pid: 5151 },
]);
// An account held under an instance's own HOME is still labelled, which
// is what a single-root lookup cannot do.
expect(mocks.getDatabaseHolders).toHaveBeenCalledWith(ntMsgDbPath(extra, BOB));
});
it('keeps account records with the same uin scoped to their own root', async () => {
const first = await makeRoot();
const second = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === first
? { accounts: [ALICE], failures: [] }
: { accounts: [{ ...ALICE, nickname: 'Stale' }], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (file: string) =>
file === ntMsgDbPath(second, ALICE) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([first, second])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...ALICE, nickname: 'Stale', pid: 5151 },
]);
});
it('reports nothing when there are no roots', async () => {
await expect(detectAccountLogins([])).resolves.toEqual([]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
it('logs a named reason when a login.db is present but unreadable', async () => {
const root = await makeRoot();
const entries: LogEntry[] = [];
const previousLevel = getLogLevel();
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
mocks.readLocalAccounts.mockResolvedValue({
accounts: [],
failures: [
{
path: loginDbPaths(root)[0]!,
reason: 'undecryptable',
},
],
});
mocks.getDatabaseHolders.mockResolvedValue([]);
// An unreadable login.db must not look like "no accounts": an
// operator reading the log has to see why nothing was reported.
await detectAccountLogins([root]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
const warned = entries.filter(
(entry) => entry.level === 'warn' && entry.scope === 'QqAccount',
);
expect(warned).toHaveLength(1);
expect(warned[0]!.message).toContain('login.db unreadable');
expect(warned[0]!.message).toContain('undecryptable');
});
});
describe.runIf(process.platform === 'linux')('per-process data roots', () => {
it('reads each pid against the HOME that process runs under', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// `<HOME>/.config/QQ`, from the child's environment rather than ours.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(home, '.config', 'QQ'));
});
it('asks each root once when several processes share a HOME', async () => {
const home = await makeRoot();
const pids = [await spawnWithHome(home), await spawnWithHome(home)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledTimes(1);
});
it('honours XDG_CONFIG_HOME of the process over its HOME', async () => {
const home = await makeRoot();
const configHome = await makeRoot();
const pid = await spawnWithHome(home, { XDG_CONFIG_HOME: configHome });
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// The XDG variable outranks HOME, as it does for the client itself.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(configHome, 'QQ'));
});
it('keeps each process on its own root when the HOMEs differ', async () => {
const first = await makeRoot();
const second = await makeRoot();
const pids = [await spawnWithHome(first), await spawnWithHome(second)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(first, '.config', 'QQ'));
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(second, '.config', 'QQ'));
});
it('does not substitute another HOME when the pid is gone', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
// The child exits, so its environment is no longer readable.
await new Promise<void>((resolve) => {
const child = children[children.length - 1]!;
child.once('exit', () => resolve());
child.kill();
});
await detectAccountLoginsForPids([pid]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
});