@aster110/cc2wechat 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_REVIEW.md +182 -0
- package/EXPERIENCE.md +42 -1
- package/TEST_REVIEW.md +55 -0
- package/dist/auth.js +3 -1
- package/dist/auth.js.map +1 -1
- package/dist/daemon.js +7 -154
- package/dist/daemon.js.map +1 -1
- package/dist/handlers/pipe.d.ts +3 -0
- package/dist/handlers/pipe.js +47 -0
- package/dist/handlers/pipe.js.map +1 -0
- package/dist/handlers/terminal.d.ts +3 -0
- package/dist/handlers/terminal.js +95 -0
- package/dist/handlers/terminal.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +31 -0
- package/dist/utils.js.map +1 -0
- package/package.json +4 -2
- package/src/auth.ts +3 -1
- package/src/daemon.ts +8 -160
- package/src/handlers/pipe.ts +56 -0
- package/src/handlers/terminal.ts +104 -0
- package/src/utils.ts +30 -0
- package/tests/store.test.ts +112 -0
- package/tests/utils.test.ts +116 -0
- package/tests/wechat-api.test.ts +223 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
// We need to mock the CHANNEL_DIR to use a temp directory
|
|
7
|
+
// Since CHANNEL_DIR is a module-level const, we mock os.homedir before importing
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc2wechat-test-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Since store.ts computes CHANNEL_DIR at module load time using os.homedir(),
|
|
19
|
+
// we need to re-import with a mocked homedir each time.
|
|
20
|
+
async function importStore(homeDir: string) {
|
|
21
|
+
vi.doMock('node:os', async () => {
|
|
22
|
+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
|
|
23
|
+
return { ...actual, default: { ...actual, homedir: () => homeDir }, homedir: () => homeDir };
|
|
24
|
+
});
|
|
25
|
+
// Force fresh import
|
|
26
|
+
const mod = await import('../src/store.js');
|
|
27
|
+
return mod;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('store', () => {
|
|
31
|
+
let store: Awaited<ReturnType<typeof importStore>>;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
store = await importStore(tmpDir);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('saveAccount / getActiveAccount / loadAccounts', () => {
|
|
43
|
+
it('returns null when no accounts saved', () => {
|
|
44
|
+
expect(store.getActiveAccount()).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('saves and retrieves an account', () => {
|
|
48
|
+
const account = {
|
|
49
|
+
accountId: 'acc1',
|
|
50
|
+
token: 'tok_abc',
|
|
51
|
+
baseUrl: 'https://example.com',
|
|
52
|
+
savedAt: '2026-01-01T00:00:00Z',
|
|
53
|
+
};
|
|
54
|
+
store.saveAccount(account);
|
|
55
|
+
const active = store.getActiveAccount();
|
|
56
|
+
expect(active).not.toBeNull();
|
|
57
|
+
expect(active!.accountId).toBe('acc1');
|
|
58
|
+
expect(active!.token).toBe('tok_abc');
|
|
59
|
+
expect(active!.baseUrl).toBe('https://example.com');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns the last saved account as active', () => {
|
|
63
|
+
store.saveAccount({ accountId: 'a1', token: 't1', savedAt: '2026-01-01' });
|
|
64
|
+
store.saveAccount({ accountId: 'a2', token: 't2', savedAt: '2026-01-02' });
|
|
65
|
+
const active = store.getActiveAccount();
|
|
66
|
+
expect(active!.accountId).toBe('a2');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('deduplicates by accountId on save', () => {
|
|
70
|
+
store.saveAccount({ accountId: 'a1', token: 'old', savedAt: '2026-01-01' });
|
|
71
|
+
store.saveAccount({ accountId: 'a1', token: 'new', savedAt: '2026-01-02' });
|
|
72
|
+
const accounts = store.loadAccounts();
|
|
73
|
+
expect(accounts).toHaveLength(1);
|
|
74
|
+
expect(accounts[0]!.token).toBe('new');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('removeAccount', () => {
|
|
79
|
+
it('removes an existing account', () => {
|
|
80
|
+
store.saveAccount({ accountId: 'a1', token: 't1', savedAt: '2026-01-01' });
|
|
81
|
+
store.saveAccount({ accountId: 'a2', token: 't2', savedAt: '2026-01-02' });
|
|
82
|
+
store.removeAccount('a1');
|
|
83
|
+
const accounts = store.loadAccounts();
|
|
84
|
+
expect(accounts).toHaveLength(1);
|
|
85
|
+
expect(accounts[0]!.accountId).toBe('a2');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does nothing when removing a non-existent account', () => {
|
|
89
|
+
store.saveAccount({ accountId: 'a1', token: 't1', savedAt: '2026-01-01' });
|
|
90
|
+
store.removeAccount('nonexistent');
|
|
91
|
+
const accounts = store.loadAccounts();
|
|
92
|
+
expect(accounts).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('loadSyncBuf / saveSyncBuf', () => {
|
|
97
|
+
it('returns empty string when no buf saved', () => {
|
|
98
|
+
expect(store.loadSyncBuf('nonexistent')).toBe('');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('saves and loads sync buf consistently', () => {
|
|
102
|
+
store.saveSyncBuf('acc1', 'some_cursor_data_12345');
|
|
103
|
+
expect(store.loadSyncBuf('acc1')).toBe('some_cursor_data_12345');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('overwrites previous buf', () => {
|
|
107
|
+
store.saveSyncBuf('acc1', 'first');
|
|
108
|
+
store.saveSyncBuf('acc1', 'second');
|
|
109
|
+
expect(store.loadSyncBuf('acc1')).toBe('second');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractText, userIdToSessionUUID, sleep } from '../src/utils.js';
|
|
3
|
+
import { MessageItemType } from '../src/types.js';
|
|
4
|
+
import type { WeixinMessage } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
describe('extractText', () => {
|
|
7
|
+
it('extracts text from a text message', () => {
|
|
8
|
+
const msg: WeixinMessage = {
|
|
9
|
+
item_list: [{ type: MessageItemType.TEXT, text_item: { text: 'hello world' } }],
|
|
10
|
+
};
|
|
11
|
+
expect(extractText(msg)).toBe('hello world');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns [Image] for image messages', () => {
|
|
15
|
+
const msg: WeixinMessage = {
|
|
16
|
+
item_list: [{ type: MessageItemType.IMAGE }],
|
|
17
|
+
};
|
|
18
|
+
expect(extractText(msg)).toBe('[Image]');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('extracts voice text', () => {
|
|
22
|
+
const msg: WeixinMessage = {
|
|
23
|
+
item_list: [{ type: MessageItemType.VOICE, voice_item: { text: 'voice content' } }],
|
|
24
|
+
};
|
|
25
|
+
expect(extractText(msg)).toBe('[Voice] voice content');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns [Video] for video messages', () => {
|
|
29
|
+
const msg: WeixinMessage = {
|
|
30
|
+
item_list: [{ type: MessageItemType.VIDEO }],
|
|
31
|
+
};
|
|
32
|
+
expect(extractText(msg)).toBe('[Video]');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('extracts file name', () => {
|
|
36
|
+
const msg: WeixinMessage = {
|
|
37
|
+
item_list: [{ type: MessageItemType.FILE, file_item: { file_name: 'doc.pdf' } }],
|
|
38
|
+
};
|
|
39
|
+
expect(extractText(msg)).toBe('[File: doc.pdf]');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns [Empty message] for empty item_list', () => {
|
|
43
|
+
const msg: WeixinMessage = { item_list: [] };
|
|
44
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns [Empty message] when item_list is undefined', () => {
|
|
48
|
+
const msg: WeixinMessage = {};
|
|
49
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('joins multiple items with newline', () => {
|
|
53
|
+
const msg: WeixinMessage = {
|
|
54
|
+
item_list: [
|
|
55
|
+
{ type: MessageItemType.TEXT, text_item: { text: 'line1' } },
|
|
56
|
+
{ type: MessageItemType.IMAGE },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
expect(extractText(msg)).toBe('line1\n[Image]');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns [Empty message] for VOICE without text', () => {
|
|
63
|
+
const msg: WeixinMessage = {
|
|
64
|
+
item_list: [{ type: MessageItemType.VOICE, voice_item: {} }],
|
|
65
|
+
};
|
|
66
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns [Empty message] for FILE without file_name', () => {
|
|
70
|
+
const msg: WeixinMessage = {
|
|
71
|
+
item_list: [{ type: MessageItemType.FILE, file_item: {} }],
|
|
72
|
+
};
|
|
73
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('skips items with unknown type', () => {
|
|
77
|
+
const msg: WeixinMessage = {
|
|
78
|
+
item_list: [{ type: 99 }],
|
|
79
|
+
};
|
|
80
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('userIdToSessionUUID', () => {
|
|
85
|
+
it('is deterministic (same input same output)', () => {
|
|
86
|
+
const a = userIdToSessionUUID('user123');
|
|
87
|
+
const b = userIdToSessionUUID('user123');
|
|
88
|
+
expect(a).toBe(b);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns valid UUID v4 format', () => {
|
|
92
|
+
const uuid = userIdToSessionUUID('testuser');
|
|
93
|
+
// UUID format: 8-4-4-4-12
|
|
94
|
+
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('produces different outputs for different inputs', () => {
|
|
98
|
+
const a = userIdToSessionUUID('user_a');
|
|
99
|
+
const b = userIdToSessionUUID('user_b');
|
|
100
|
+
expect(a).not.toBe(b);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('sleep', () => {
|
|
105
|
+
it('resolves after the specified duration', async () => {
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
await sleep(50);
|
|
108
|
+
const elapsed = Date.now() - start;
|
|
109
|
+
expect(elapsed).toBeGreaterThanOrEqual(40); // allow small timing variance
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('resolves to undefined', async () => {
|
|
113
|
+
const result = await sleep(1);
|
|
114
|
+
expect(result).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
encryptAesEcb,
|
|
5
|
+
aesEcbPaddedSize,
|
|
6
|
+
getUpdates,
|
|
7
|
+
sendMessage,
|
|
8
|
+
getQRCode,
|
|
9
|
+
pollQRStatus,
|
|
10
|
+
sendTyping,
|
|
11
|
+
getConfig,
|
|
12
|
+
} from '../src/wechat-api.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Mock fetch helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function mockFetchJson(data: unknown, status = 200) {
|
|
19
|
+
return vi.fn().mockResolvedValue({
|
|
20
|
+
ok: status >= 200 && status < 300,
|
|
21
|
+
status,
|
|
22
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
23
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
24
|
+
json: () => Promise.resolve(data),
|
|
25
|
+
headers: new Headers(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockFetchText(text: string, status = 200) {
|
|
30
|
+
return vi.fn().mockResolvedValue({
|
|
31
|
+
ok: status >= 200 && status < 300,
|
|
32
|
+
status,
|
|
33
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
34
|
+
text: () => Promise.resolve(text),
|
|
35
|
+
json: () => Promise.resolve(JSON.parse(text)),
|
|
36
|
+
headers: new Headers(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mockFetchAbort() {
|
|
41
|
+
return vi.fn().mockRejectedValue(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// API function tests
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('getUpdates', () => {
|
|
49
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
50
|
+
|
|
51
|
+
it('returns parsed messages on success', async () => {
|
|
52
|
+
const resp = { ret: 0, msgs: [{ message_id: 1 }], get_updates_buf: 'buf2' };
|
|
53
|
+
vi.stubGlobal('fetch', mockFetchText(JSON.stringify(resp)));
|
|
54
|
+
|
|
55
|
+
const result = await getUpdates('tok', 'buf1');
|
|
56
|
+
expect(result.ret).toBe(0);
|
|
57
|
+
expect(result.msgs).toHaveLength(1);
|
|
58
|
+
expect(result.get_updates_buf).toBe('buf2');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns default value on AbortError (timeout)', async () => {
|
|
62
|
+
vi.stubGlobal('fetch', mockFetchAbort());
|
|
63
|
+
|
|
64
|
+
const result = await getUpdates('tok', 'buf1');
|
|
65
|
+
expect(result.ret).toBe(0);
|
|
66
|
+
expect(result.msgs).toEqual([]);
|
|
67
|
+
expect(result.get_updates_buf).toBe('buf1');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('sendMessage', () => {
|
|
72
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
73
|
+
|
|
74
|
+
it('sends message successfully (no throw)', async () => {
|
|
75
|
+
vi.stubGlobal('fetch', mockFetchText('{"ret":0}'));
|
|
76
|
+
await expect(sendMessage('tok', 'user1', 'hello', 'ctx1')).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('getQRCode', () => {
|
|
81
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
82
|
+
|
|
83
|
+
it('returns qrcode and qrcode_img_content', async () => {
|
|
84
|
+
const data = { qrcode: 'qr123', qrcode_img_content: 'base64img' };
|
|
85
|
+
vi.stubGlobal('fetch', mockFetchJson(data));
|
|
86
|
+
|
|
87
|
+
const result = await getQRCode();
|
|
88
|
+
expect(result.qrcode).toBe('qr123');
|
|
89
|
+
expect(result.qrcode_img_content).toBe('base64img');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('pollQRStatus', () => {
|
|
94
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
95
|
+
|
|
96
|
+
it('returns wait status', async () => {
|
|
97
|
+
vi.stubGlobal('fetch', mockFetchJson({ status: 'wait' }));
|
|
98
|
+
const result = await pollQRStatus('qr123');
|
|
99
|
+
expect(result.status).toBe('wait');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns confirmed status with token', async () => {
|
|
103
|
+
const data = { status: 'confirmed', bot_token: 'tok_abc', ilink_bot_id: 'bot1' };
|
|
104
|
+
vi.stubGlobal('fetch', mockFetchJson(data));
|
|
105
|
+
const result = await pollQRStatus('qr123');
|
|
106
|
+
expect(result.status).toBe('confirmed');
|
|
107
|
+
expect(result.bot_token).toBe('tok_abc');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns { status: "wait" } on AbortError (timeout)', async () => {
|
|
111
|
+
vi.stubGlobal('fetch', mockFetchAbort());
|
|
112
|
+
const result = await pollQRStatus('qr123');
|
|
113
|
+
expect(result.status).toBe('wait');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('sendTyping', () => {
|
|
118
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
119
|
+
|
|
120
|
+
it('sends typing successfully (no throw)', async () => {
|
|
121
|
+
vi.stubGlobal('fetch', mockFetchText('{"ret":0}'));
|
|
122
|
+
await expect(sendTyping('tok', 'user1', 'ticket1')).resolves.toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('getConfig', () => {
|
|
127
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
128
|
+
|
|
129
|
+
it('returns typing_ticket', async () => {
|
|
130
|
+
const data = { ret: 0, typing_ticket: 'ticket_xyz' };
|
|
131
|
+
vi.stubGlobal('fetch', mockFetchText(JSON.stringify(data)));
|
|
132
|
+
|
|
133
|
+
const result = await getConfig('tok', 'user1');
|
|
134
|
+
expect(result.typing_ticket).toBe('ticket_xyz');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Crypto tests
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
describe('encryptAesEcb', () => {
|
|
143
|
+
it('encrypt then decrypt produces original plaintext', () => {
|
|
144
|
+
const key = crypto.randomBytes(16);
|
|
145
|
+
const plaintext = Buffer.from('Hello, WeChat!');
|
|
146
|
+
|
|
147
|
+
const ciphertext = encryptAesEcb(plaintext, key);
|
|
148
|
+
|
|
149
|
+
// Decrypt with node:crypto to verify
|
|
150
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
151
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
152
|
+
|
|
153
|
+
expect(decrypted.toString()).toBe('Hello, WeChat!');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('produces ciphertext different from plaintext', () => {
|
|
157
|
+
const key = crypto.randomBytes(16);
|
|
158
|
+
const plaintext = Buffer.from('test data here');
|
|
159
|
+
const ciphertext = encryptAesEcb(plaintext, key);
|
|
160
|
+
expect(ciphertext.equals(plaintext)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('different keys produce different ciphertext', () => {
|
|
164
|
+
const key1 = crypto.randomBytes(16);
|
|
165
|
+
const key2 = crypto.randomBytes(16);
|
|
166
|
+
const plaintext = Buffer.from('same input');
|
|
167
|
+
|
|
168
|
+
const c1 = encryptAesEcb(plaintext, key1);
|
|
169
|
+
const c2 = encryptAesEcb(plaintext, key2);
|
|
170
|
+
expect(c1.equals(c2)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('ciphertext length matches aesEcbPaddedSize', () => {
|
|
174
|
+
const key = crypto.randomBytes(16);
|
|
175
|
+
const plaintext = Buffer.from('variable length content');
|
|
176
|
+
const ciphertext = encryptAesEcb(plaintext, key);
|
|
177
|
+
expect(ciphertext.length).toBe(aesEcbPaddedSize(plaintext.length));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('encrypts empty buffer without error', () => {
|
|
181
|
+
const key = crypto.randomBytes(16);
|
|
182
|
+
const ciphertext = encryptAesEcb(Buffer.alloc(0), key);
|
|
183
|
+
expect(ciphertext.length).toBe(16); // one full padding block
|
|
184
|
+
|
|
185
|
+
// Decrypt to verify
|
|
186
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
187
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
188
|
+
expect(decrypted.length).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('aesEcbPaddedSize', () => {
|
|
193
|
+
it('returns 16 for empty input (0 bytes)', () => {
|
|
194
|
+
// 0 bytes plaintext → 16 bytes padding (full block of padding)
|
|
195
|
+
expect(aesEcbPaddedSize(0)).toBe(16);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns 16 for 1 byte', () => {
|
|
199
|
+
expect(aesEcbPaddedSize(1)).toBe(16);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns 16 for 15 bytes', () => {
|
|
203
|
+
// 15 bytes + 1 padding = 16
|
|
204
|
+
expect(aesEcbPaddedSize(15)).toBe(16);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns 32 for 16 bytes (needs full padding block)', () => {
|
|
208
|
+
// 16 bytes plaintext → needs extra block for PKCS7 padding
|
|
209
|
+
expect(aesEcbPaddedSize(16)).toBe(32);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns 32 for 17 bytes', () => {
|
|
213
|
+
expect(aesEcbPaddedSize(17)).toBe(32);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns 32 for 31 bytes', () => {
|
|
217
|
+
expect(aesEcbPaddedSize(31)).toBe(32);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('returns 48 for 32 bytes', () => {
|
|
221
|
+
expect(aesEcbPaddedSize(32)).toBe(48);
|
|
222
|
+
});
|
|
223
|
+
});
|