SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { SqliteDatabase } from '@snowluma/sqlite';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detectAlgorithm: vi.fn(),
|
||||
decryptDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
// The page crypto is covered on its own; account reading stands on top of it, so
|
||||
// the fixtures below hand over plaintext images and the decrypt step is stubbed
|
||||
// to pass them through.
|
||||
vi.mock('../src/qq-account/sqlcipher', () => ({
|
||||
detectAlgorithm: mocks.detectAlgorithm,
|
||||
decryptDatabase: mocks.decryptDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('../src/qq-account/paths', () => ({
|
||||
loginDbPaths: (root: string) => [path.join(root, PRIMARY), path.join(root, SECONDARY)],
|
||||
}));
|
||||
|
||||
import { readLocalAccounts } from '../src/qq-account/login-db';
|
||||
|
||||
const PRIMARY = path.join('global', 'nt_db', 'login.db');
|
||||
const SECONDARY = path.join('nt_qq', 'global', 'nt_db', 'login.db');
|
||||
|
||||
/** Write a plaintext database with QQ's account table at `file`. */
|
||||
async function writeLoginDb(
|
||||
file: string,
|
||||
rows: ReadonlyArray<readonly [string, string, string]>,
|
||||
): Promise<void> {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await rm(file, { force: true });
|
||||
await rm(`${file}-wal`, { force: true });
|
||||
const db = new SqliteDatabase(file);
|
||||
|
||||
try {
|
||||
await db.exec('CREATE TABLE login_table("1000" TEXT, "1001" TEXT, "1007" TEXT)');
|
||||
|
||||
for (const [uin, uid, nickname] of rows) {
|
||||
await db.prepare('INSERT INTO login_table VALUES (?, ?, ?)').run(uin, uid, nickname);
|
||||
}
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function makeRoot(
|
||||
rows: ReadonlyArray<readonly [string, string, string]>,
|
||||
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
|
||||
): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
|
||||
await writeLoginDb(path.join(root, PRIMARY), rows);
|
||||
|
||||
if (secondaryRows.length > 0) {
|
||||
await writeLoginDb(path.join(root, SECONDARY), secondaryRows);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
|
||||
mocks.decryptDatabase.mockImplementation((image: Buffer) => ({ image, walFrames: 0 }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mocks.detectAlgorithm.mockReset();
|
||||
mocks.decryptDatabase.mockReset();
|
||||
|
||||
for (const root of roots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function trackedRoot(
|
||||
rows: ReadonlyArray<readonly [string, string, string]>,
|
||||
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
|
||||
): Promise<string> {
|
||||
const root = await makeRoot(rows, secondaryRows);
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('local account list', () => {
|
||||
it('reads the accounts QQ cached locally', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
|
||||
await expect(readLocalAccounts(root)).resolves.toEqual({
|
||||
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
|
||||
failures: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('merges both login.db locations, letting the primary win on a clash', async () => {
|
||||
const root = await trackedRoot(
|
||||
[
|
||||
['10001', 'u_a', 'Alice'],
|
||||
['10002', 'u_b', 'Bob'],
|
||||
],
|
||||
[
|
||||
['10001', 'u_a_stale', 'AliceStale'],
|
||||
['10003', 'u_c', 'Carol'],
|
||||
],
|
||||
);
|
||||
|
||||
expect(await readLocalAccounts(root)).toEqual({
|
||||
accounts: [
|
||||
{ uin: '10001', uid: 'u_a', nickname: 'Alice' },
|
||||
{ uin: '10002', uid: 'u_b', nickname: 'Bob' },
|
||||
{ uin: '10003', uid: 'u_c', nickname: 'Carol' },
|
||||
],
|
||||
failures: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('re-decrypts only when the database or its sidecar changes', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
const file = path.join(root, PRIMARY);
|
||||
|
||||
await readLocalAccounts(root);
|
||||
await readLocalAccounts(root);
|
||||
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A login that just happened rewrites the database; the new rows have to
|
||||
// show up instead of the cached list.
|
||||
await writeLoginDb(file, [
|
||||
['10001', 'u_a', 'Alice'],
|
||||
['10002', 'u_b', 'Bob'],
|
||||
]);
|
||||
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
|
||||
expect((await readLocalAccounts(root)).accounts).toHaveLength(2);
|
||||
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The sidecar counts as a change too: a batch committed to the WAL only
|
||||
// leaves login.db untouched.
|
||||
await writeFile(`${file}-wal`, Buffer.alloc(0));
|
||||
await readLocalAccounts(root);
|
||||
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('shares concurrent reads of the same database image', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
const reports = await Promise.all([readLocalAccounts(root), readLocalAccounts(root)]);
|
||||
expect(reports[0]).toEqual(reports[1]);
|
||||
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports an unreadable WAL instead of reading a stale base image', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
const file = path.join(root, PRIMARY);
|
||||
await mkdir(`${file}-wal`);
|
||||
const report = await readLocalAccounts(root);
|
||||
expect(report.accounts).toEqual([]);
|
||||
expect(report.failures).toMatchObject([{ path: file, reason: 'read-failed' }]);
|
||||
expect(report.failures[0]!.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('reports missing schema columns instead of accepting quoted column names as identities', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
const file = path.join(root, PRIMARY);
|
||||
const db = new SqliteDatabase(file);
|
||||
|
||||
try {
|
||||
await db.exec('ALTER TABLE login_table DROP COLUMN "1001"');
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
const report = await readLocalAccounts(root);
|
||||
expect(report.accounts).toEqual([]);
|
||||
expect(report.failures).toMatchObject([{ path: file, reason: 'query-failed' }]);
|
||||
expect(report.failures[0]!.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('reports no accounts when the file cannot be decrypted, and retries later', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
mocks.detectAlgorithm.mockReturnValue(null);
|
||||
|
||||
await expect(readLocalAccounts(root)).resolves.toEqual({
|
||||
accounts: [],
|
||||
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
|
||||
});
|
||||
|
||||
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
|
||||
const file = path.join(root, PRIMARY);
|
||||
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
|
||||
|
||||
await expect(readLocalAccounts(root)).resolves.toEqual({
|
||||
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
|
||||
failures: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('names an unreadable login.db instead of reporting "no accounts"', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
|
||||
mocks.detectAlgorithm.mockReturnValue(null);
|
||||
|
||||
// A login.db that cannot be decrypted is not the same as a machine with
|
||||
// no accounts, and the caller has to be able to tell them apart.
|
||||
await expect(readLocalAccounts(root)).resolves.toEqual({
|
||||
accounts: [],
|
||||
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an unreadable sidecar from hiding the accounts a good database has', async () => {
|
||||
const root = await trackedRoot([['10001', 'u_a', 'Alice']], [['10002', 'u_b', 'Bob']]);
|
||||
const secondary = path.join(root, SECONDARY);
|
||||
const bogus = Buffer.alloc(8192, 7);
|
||||
mocks.detectAlgorithm.mockImplementation((image: Buffer) =>
|
||||
image[0] === 7 ? null : { pageHmac: 'sha1', kdfHmac: 'sha512' },
|
||||
);
|
||||
await writeFile(secondary, bogus);
|
||||
|
||||
const report = await readLocalAccounts(root);
|
||||
expect(report.accounts.map((account) => account.uin)).toEqual(['10001']);
|
||||
expect(report.failures).toEqual([{ path: secondary, reason: 'undecryptable' }]);
|
||||
});
|
||||
|
||||
it('skips rows that carry no usable identity', async () => {
|
||||
const root = await trackedRoot([
|
||||
['10001', 'u_a', 'Alice'],
|
||||
['10002', '', 'Broken'],
|
||||
['', 'u_d', 'Nameless'],
|
||||
['../10001', 'u_e', 'Invalid UIN'],
|
||||
]);
|
||||
|
||||
await expect(readLocalAccounts(root)).resolves.toEqual({
|
||||
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
|
||||
failures: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no accounts when login.db is missing', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
|
||||
roots.push(root);
|
||||
|
||||
// Neither candidate file exists: nothing cached, and nothing to report.
|
||||
await expect(readLocalAccounts(root)).resolves.toEqual({ accounts: [], failures: [] });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user