@accounter/scraper-app 0.0.1
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/README.md +90 -0
- package/docs/plan.md +76 -0
- package/index.html +12 -0
- package/package.json +40 -0
- package/src/env.template +2 -0
- package/src/server/__tests__/accounts-routes.test.ts +133 -0
- package/src/server/__tests__/check-accounts.test.ts +305 -0
- package/src/server/__tests__/filter-payload.test.ts +193 -0
- package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
- package/src/server/__tests__/graphql-client.test.ts +508 -0
- package/src/server/__tests__/healthz.test.ts +22 -0
- package/src/server/__tests__/history.test.ts +111 -0
- package/src/server/__tests__/otp-manager.test.ts +132 -0
- package/src/server/__tests__/scrape-runner.test.ts +144 -0
- package/src/server/__tests__/settings-routes.test.ts +117 -0
- package/src/server/__tests__/sources-routes.test.ts +149 -0
- package/src/server/__tests__/validate-payload.test.ts +193 -0
- package/src/server/__tests__/vault-routes.test.ts +174 -0
- package/src/server/__tests__/vault.test.ts +33 -0
- package/src/server/__tests__/websocket.test.ts +151 -0
- package/src/server/account-discovery.ts +49 -0
- package/src/server/accounts-routes.ts +74 -0
- package/src/server/check-accounts.ts +79 -0
- package/src/server/filter-payload.ts +145 -0
- package/src/server/graphql/client.ts +103 -0
- package/src/server/graphql/mutations.ts +518 -0
- package/src/server/history-routes.ts +11 -0
- package/src/server/history.ts +53 -0
- package/src/server/index.ts +40 -0
- package/src/server/otp-manager.ts +63 -0
- package/src/server/payload-schemas/amex.schema.ts +2 -0
- package/src/server/payload-schemas/cal.schema.ts +27 -0
- package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
- package/src/server/payload-schemas/discount.schema.ts +26 -0
- package/src/server/payload-schemas/isracard.schema.ts +58 -0
- package/src/server/payload-schemas/max.schema.ts +27 -0
- package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
- package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
- package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
- package/src/server/scrape-runner.ts +165 -0
- package/src/server/scrapers/__tests__/amex.test.ts +142 -0
- package/src/server/scrapers/__tests__/cal.test.ts +135 -0
- package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
- package/src/server/scrapers/__tests__/discount.test.ts +160 -0
- package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
- package/src/server/scrapers/__tests__/max.test.ts +115 -0
- package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
- package/src/server/scrapers/amex.ts +63 -0
- package/src/server/scrapers/cal.ts +56 -0
- package/src/server/scrapers/currency-rates.ts +64 -0
- package/src/server/scrapers/discount.ts +62 -0
- package/src/server/scrapers/isracard.ts +68 -0
- package/src/server/scrapers/max.ts +32 -0
- package/src/server/scrapers/poalim.ts +103 -0
- package/src/server/settings-routes.ts +27 -0
- package/src/server/sources-routes.ts +182 -0
- package/src/server/validate-payload.ts +74 -0
- package/src/server/vault-routes.ts +99 -0
- package/src/server/vault-store.ts +42 -0
- package/src/server/vault.ts +216 -0
- package/src/server/websocket.ts +454 -0
- package/src/shared/source-types.ts +10 -0
- package/src/shared/types.ts +20 -0
- package/src/shared/ws-protocol.ts +177 -0
- package/src/test-setup.ts +6 -0
- package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
- package/src/ui/__tests__/config.test.tsx +99 -0
- package/src/ui/__tests__/history.test.tsx +94 -0
- package/src/ui/__tests__/run.test.tsx +195 -0
- package/src/ui/__tests__/settings-tab.test.tsx +79 -0
- package/src/ui/__tests__/sources-tab.test.tsx +139 -0
- package/src/ui/__tests__/vault-setup.test.tsx +105 -0
- package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
- package/src/ui/app.tsx +109 -0
- package/src/ui/components/error-boundary.tsx +54 -0
- package/src/ui/components/otp-modal.tsx +82 -0
- package/src/ui/components/skeleton.tsx +58 -0
- package/src/ui/components/task-row.tsx +241 -0
- package/src/ui/contexts/vault-context.tsx +77 -0
- package/src/ui/lib/api.ts +117 -0
- package/src/ui/lib/ws.ts +137 -0
- package/src/ui/main.tsx +9 -0
- package/src/ui/screens/config/accounts-tab.tsx +185 -0
- package/src/ui/screens/config/config.tsx +163 -0
- package/src/ui/screens/config/settings-tab.tsx +167 -0
- package/src/ui/screens/config/source-forms.tsx +518 -0
- package/src/ui/screens/config/source-types.ts +91 -0
- package/src/ui/screens/config/sources-tab.tsx +176 -0
- package/src/ui/screens/history.tsx +234 -0
- package/src/ui/screens/run.tsx +266 -0
- package/src/ui/screens/vault-setup.tsx +120 -0
- package/src/ui/screens/vault-unlock.tsx +38 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from 'vitest';
|
|
2
|
+
import { PayloadValidationError, validatePayload } from '../validate-payload.js';
|
|
3
|
+
import { _resetRunState, startRun, type ScrapeTask } from '../scrape-runner.js';
|
|
4
|
+
import type { ServerMessage } from '../../shared/ws-protocol.js';
|
|
5
|
+
|
|
6
|
+
describe('validatePayload — valid fixtures', () => {
|
|
7
|
+
it('accepts a minimal poalim-ils payload', () => {
|
|
8
|
+
const result = validatePayload('poalim-ils', {
|
|
9
|
+
transactions: [
|
|
10
|
+
{
|
|
11
|
+
activityDescription: 'Credit',
|
|
12
|
+
activityTypeCode: 1,
|
|
13
|
+
eventAmount: 100,
|
|
14
|
+
eventDate: 20240101,
|
|
15
|
+
serialNumber: 1,
|
|
16
|
+
transactionType: 'REGULAR',
|
|
17
|
+
currentBalance: 5000,
|
|
18
|
+
referenceNumber: 12345,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
retrievalTransactionData: { accountNumber: 100000, branchNumber: 600, bankNumber: 12 },
|
|
22
|
+
});
|
|
23
|
+
expect(result.transactions).toHaveLength(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('accepts a poalim-ils payload with extra fields (passthrough)', () => {
|
|
27
|
+
const result = validatePayload('poalim-ils', {
|
|
28
|
+
transactions: [],
|
|
29
|
+
retrievalTransactionData: { accountNumber: 1, branchNumber: 2, bankNumber: 12 },
|
|
30
|
+
unknownField: 'should be kept',
|
|
31
|
+
});
|
|
32
|
+
expect((result as Record<string, unknown>)['unknownField']).toBe('should be kept');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('accepts a minimal poalim-foreign payload', () => {
|
|
36
|
+
const result = validatePayload('poalim-foreign', {
|
|
37
|
+
balancesAndLimitsDataList: [
|
|
38
|
+
{
|
|
39
|
+
currencySwiftCode: 'USD',
|
|
40
|
+
currencyCode: 1,
|
|
41
|
+
transactions: [],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
expect(result.balancesAndLimitsDataList).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts a minimal poalim-swift payload', () => {
|
|
49
|
+
const result = validatePayload('poalim-swift', { swiftsList: [] });
|
|
50
|
+
expect(result.swiftsList).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts a minimal isracard payload', () => {
|
|
54
|
+
const result = validatePayload('isracard', {
|
|
55
|
+
Header: { Status: '1', Message: null },
|
|
56
|
+
CardsTransactionsListBean: {
|
|
57
|
+
cardNumberList: ['012345'],
|
|
58
|
+
Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
expect(result.Header.Status).toBe('1');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts amex with the same shape as isracard', () => {
|
|
65
|
+
const result = validatePayload('amex', {
|
|
66
|
+
Header: { Status: '1', Message: null },
|
|
67
|
+
CardsTransactionsListBean: {
|
|
68
|
+
cardNumberList: ['343434'],
|
|
69
|
+
Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(result.Header.Status).toBe('1');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts a minimal cal payload', () => {
|
|
76
|
+
const result = validatePayload('cal', [
|
|
77
|
+
{ card: '1234', month: '2024-01', transactions: [] },
|
|
78
|
+
]);
|
|
79
|
+
expect(result).toHaveLength(1);
|
|
80
|
+
expect(result[0]!.card).toBe('1234');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('accepts a minimal discount payload', () => {
|
|
84
|
+
const result = validatePayload('discount', [
|
|
85
|
+
{ accountNumber: 'ACC-001', month: '2024-01', balance: 5000, transactions: [] },
|
|
86
|
+
]);
|
|
87
|
+
expect(result).toHaveLength(1);
|
|
88
|
+
expect(result[0]!.accountNumber).toBe('ACC-001');
|
|
89
|
+
expect(result[0]!.balance).toBe(5000);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('accepts a minimal max payload', () => {
|
|
93
|
+
const result = validatePayload('max', [{ accountNumber: '1234', txns: [] }]);
|
|
94
|
+
expect(result).toHaveLength(1);
|
|
95
|
+
expect(result[0]!.accountNumber).toBe('1234');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('accepts an empty max payload', () => {
|
|
99
|
+
const result = validatePayload('max', []);
|
|
100
|
+
expect(result).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('accepts a currency-rates payload', () => {
|
|
104
|
+
const result = validatePayload('currency-rates', [
|
|
105
|
+
{ date: '2024-01-01', currency: 'USD', rate: 3.712 },
|
|
106
|
+
]);
|
|
107
|
+
expect(result).toHaveLength(1);
|
|
108
|
+
expect(result[0]!.currency).toBe('USD');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('validatePayload — invalid fixtures', () => {
|
|
113
|
+
it('throws PayloadValidationError for wrong transactions type in poalim-ils', () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
validatePayload('poalim-ils', {
|
|
116
|
+
transactions: 'not-an-array',
|
|
117
|
+
retrievalTransactionData: { accountNumber: 1, branchNumber: 2, bankNumber: 12 },
|
|
118
|
+
}),
|
|
119
|
+
).toThrow(PayloadValidationError);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('error message includes the payload type', () => {
|
|
123
|
+
try {
|
|
124
|
+
validatePayload('poalim-ils', { transactions: 42 });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
expect(e).toBeInstanceOf(PayloadValidationError);
|
|
127
|
+
expect((e as PayloadValidationError).message).toContain('poalim-ils');
|
|
128
|
+
expect((e as PayloadValidationError).payloadType).toBe('poalim-ils');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws for missing required field in discount payload', () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
validatePayload('discount', [
|
|
135
|
+
{ accountNumber: 'ACC-001', month: '2024-01' }, // missing balance and transactions
|
|
136
|
+
]),
|
|
137
|
+
).toThrow(PayloadValidationError);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('throws for invalid transaction type in poalim-ils', () => {
|
|
141
|
+
expect(() =>
|
|
142
|
+
validatePayload('poalim-ils', {
|
|
143
|
+
transactions: [
|
|
144
|
+
{
|
|
145
|
+
activityDescription: 'X',
|
|
146
|
+
activityTypeCode: 1,
|
|
147
|
+
eventAmount: 100,
|
|
148
|
+
eventDate: 20240101,
|
|
149
|
+
serialNumber: 1,
|
|
150
|
+
transactionType: 'INVALID_TYPE',
|
|
151
|
+
currentBalance: 5000,
|
|
152
|
+
referenceNumber: 1,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
retrievalTransactionData: { accountNumber: 1, branchNumber: 2, bankNumber: 12 },
|
|
156
|
+
}),
|
|
157
|
+
).toThrow(PayloadValidationError);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('throws for wrong currency in currency-rates', () => {
|
|
161
|
+
expect(() =>
|
|
162
|
+
validatePayload('currency-rates', [{ date: '2024-01-01', currency: 'XYZ', rate: 1.0 }]),
|
|
163
|
+
).toThrow(PayloadValidationError);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('runner integration — task-error on PayloadValidationError', () => {
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
_resetRunState();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('emits task-error (not a crash) when validatePayload throws inside run()', async () => {
|
|
173
|
+
const events: ServerMessage[] = [];
|
|
174
|
+
|
|
175
|
+
const task: ScrapeTask = {
|
|
176
|
+
sourceId: 'bad-src',
|
|
177
|
+
nickname: 'bad-src',
|
|
178
|
+
type: 'poalim',
|
|
179
|
+
run: async () => {
|
|
180
|
+
// Deliberately pass invalid data to trigger PayloadValidationError
|
|
181
|
+
validatePayload('poalim-ils', { transactions: 'not-an-array' });
|
|
182
|
+
return { inserted: 0, skipped: 0, insertedIds: [] };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
await startRun([task], false, msg => events.push(msg));
|
|
187
|
+
|
|
188
|
+
const taskError = events.find(e => e.type === 'task-error');
|
|
189
|
+
expect(taskError).toBeTruthy();
|
|
190
|
+
expect((taskError as { sourceId: string }).sourceId).toBe('bad-src');
|
|
191
|
+
expect(events.at(-1)).toMatchObject({ type: 'run-complete', totalInserted: 0, totalSkipped: 0 });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { defaultVault, saveVaultFile } from '../vault.js';
|
|
8
|
+
import { lockVault } from '../vault-store.js';
|
|
9
|
+
import { registerVaultRoutes } from '../vault-routes.js';
|
|
10
|
+
|
|
11
|
+
const PASSWORD = 'test-password-123';
|
|
12
|
+
|
|
13
|
+
function makeTmpPath() {
|
|
14
|
+
return join(tmpdir(), `vault-test-${randomBytes(4).toString('hex')}.vault`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function buildApp(vaultPath: string): Promise<FastifyInstance> {
|
|
18
|
+
process.env['VAULT_PATH'] = vaultPath;
|
|
19
|
+
const app = Fastify();
|
|
20
|
+
await registerVaultRoutes(app);
|
|
21
|
+
await app.ready();
|
|
22
|
+
return app;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Tests where the vault file does NOT yet exist ─────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('GET /api/vault/status', () => {
|
|
28
|
+
let vaultPath: string;
|
|
29
|
+
let app: FastifyInstance;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
vaultPath = makeTmpPath();
|
|
33
|
+
app = await buildApp(vaultPath);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
lockVault();
|
|
38
|
+
await app.close();
|
|
39
|
+
await rm(vaultPath, { force: true });
|
|
40
|
+
delete process.env['VAULT_PATH'];
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns locked=true and hasFile=false when no vault file exists', async () => {
|
|
44
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
|
|
45
|
+
expect(res.statusCode).toBe(200);
|
|
46
|
+
expect(res.json()).toEqual({ locked: true, hasFile: false });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── Tests for POST /api/vault/create ─────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('POST /api/vault/create', () => {
|
|
53
|
+
let vaultPath: string;
|
|
54
|
+
let app: FastifyInstance;
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
vaultPath = makeTmpPath();
|
|
58
|
+
app = await buildApp(vaultPath);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
lockVault();
|
|
63
|
+
await app.close();
|
|
64
|
+
await rm(vaultPath, { force: true });
|
|
65
|
+
delete process.env['VAULT_PATH'];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('creates a vault and returns 201', async () => {
|
|
69
|
+
const res = await app.inject({
|
|
70
|
+
method: 'POST',
|
|
71
|
+
url: '/api/vault/create',
|
|
72
|
+
payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
|
|
73
|
+
});
|
|
74
|
+
expect(res.statusCode).toBe(201);
|
|
75
|
+
expect(res.json()).toEqual({ ok: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('GET /api/vault/status after create returns locked=false and hasFile=true', async () => {
|
|
79
|
+
await app.inject({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
url: '/api/vault/create',
|
|
82
|
+
payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
|
|
83
|
+
});
|
|
84
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
|
|
85
|
+
expect(res.json()).toEqual({ locked: false, hasFile: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns 409 when vault file already exists', async () => {
|
|
89
|
+
await app.inject({
|
|
90
|
+
method: 'POST',
|
|
91
|
+
url: '/api/vault/create',
|
|
92
|
+
payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
|
|
93
|
+
});
|
|
94
|
+
const res = await app.inject({
|
|
95
|
+
method: 'POST',
|
|
96
|
+
url: '/api/vault/create',
|
|
97
|
+
payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
|
|
98
|
+
});
|
|
99
|
+
expect(res.statusCode).toBe(409);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Tests where the vault file ALREADY exists ─────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('GET /api/vault/status', () => {
|
|
106
|
+
let vaultPath: string;
|
|
107
|
+
let app: FastifyInstance;
|
|
108
|
+
|
|
109
|
+
beforeEach(async () => {
|
|
110
|
+
vaultPath = makeTmpPath();
|
|
111
|
+
await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
|
|
112
|
+
app = await buildApp(vaultPath);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(async () => {
|
|
116
|
+
lockVault();
|
|
117
|
+
await app.close();
|
|
118
|
+
await rm(vaultPath, { force: true });
|
|
119
|
+
delete process.env['VAULT_PATH'];
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns locked=true and hasFile=true before unlock', async () => {
|
|
123
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
|
|
124
|
+
expect(res.statusCode).toBe(200);
|
|
125
|
+
expect(res.json()).toEqual({ locked: true, hasFile: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns locked=false after successful unlock', async () => {
|
|
129
|
+
await app.inject({
|
|
130
|
+
method: 'POST',
|
|
131
|
+
url: '/api/vault/unlock',
|
|
132
|
+
payload: { password: PASSWORD },
|
|
133
|
+
});
|
|
134
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
|
|
135
|
+
expect(res.json()).toMatchObject({ locked: false, hasFile: true });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('POST /api/vault/unlock', () => {
|
|
140
|
+
let vaultPath: string;
|
|
141
|
+
let app: FastifyInstance;
|
|
142
|
+
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
vaultPath = makeTmpPath();
|
|
145
|
+
await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
|
|
146
|
+
app = await buildApp(vaultPath);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(async () => {
|
|
150
|
+
lockVault();
|
|
151
|
+
await app.close();
|
|
152
|
+
await rm(vaultPath, { force: true });
|
|
153
|
+
delete process.env['VAULT_PATH'];
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns ok with correct password', async () => {
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: 'POST',
|
|
159
|
+
url: '/api/vault/unlock',
|
|
160
|
+
payload: { password: PASSWORD },
|
|
161
|
+
});
|
|
162
|
+
expect(res.statusCode).toBe(200);
|
|
163
|
+
expect(res.json()).toEqual({ ok: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns 401 with wrong password', async () => {
|
|
167
|
+
const res = await app.inject({
|
|
168
|
+
method: 'POST',
|
|
169
|
+
url: '/api/vault/unlock',
|
|
170
|
+
payload: { password: 'wrong-password' },
|
|
171
|
+
});
|
|
172
|
+
expect(res.statusCode).toBe(401);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { decryptVault, defaultVault, encryptVault, VaultSchema } from '../vault.js';
|
|
3
|
+
|
|
4
|
+
describe('vault', () => {
|
|
5
|
+
it('encrypt→decrypt round-trip returns original vault', async () => {
|
|
6
|
+
const vault = defaultVault();
|
|
7
|
+
vault.poalimAccounts.push({ id: 'test-id-1', userCode: 'user1', password: 'pass1' });
|
|
8
|
+
const blob = await encryptVault(vault, 'my-password');
|
|
9
|
+
expect(await decryptVault(blob, 'my-password')).toEqual(vault);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('wrong password returns null without throwing', async () => {
|
|
13
|
+
const blob = await encryptVault(defaultVault(), 'correct-password');
|
|
14
|
+
await expect(decryptVault(blob, 'wrong-password')).resolves.toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('tampered ciphertext returns null', async () => {
|
|
18
|
+
const blob = await encryptVault(defaultVault(), 'password');
|
|
19
|
+
const buf = Buffer.from(blob, 'base64');
|
|
20
|
+
buf[buf.length - 1] ^= 0xff;
|
|
21
|
+
expect(await decryptVault(buf.toString('base64'), 'password')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('VaultSchema rejects missing required fields', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
VaultSchema.parse({ poalimAccounts: [{ password: 'no-user-code' }] }),
|
|
27
|
+
).toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('defaultVault() passes VaultSchema.parse', () => {
|
|
31
|
+
expect(() => VaultSchema.parse(defaultVault())).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import '@fastify/websocket'; // type augmentation for app.injectWS
|
|
2
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
3
|
+
import type { WebSocket } from 'ws';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { registerWebSocketRoute } from '../websocket.js';
|
|
6
|
+
|
|
7
|
+
const STUB_VAULT = {
|
|
8
|
+
poalimAccounts: [{ id: 'src-1', userCode: 'u', password: 'p' }],
|
|
9
|
+
discountAccounts: [],
|
|
10
|
+
isracardAccounts: [],
|
|
11
|
+
amexAccounts: [],
|
|
12
|
+
calAccounts: [],
|
|
13
|
+
maxAccounts: [],
|
|
14
|
+
accountRecords: [],
|
|
15
|
+
settings: { showBrowser: false, fetchBankOfIsraelRates: true, concurrentScraping: false },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
vi.mock('../vault-store.js', () => ({ isLocked: () => false, getVault: () => STUB_VAULT }));
|
|
19
|
+
vi.mock('../scrape-runner.js', () => ({ startRun: vi.fn().mockResolvedValue(undefined) }));
|
|
20
|
+
|
|
21
|
+
let app: FastifyInstance;
|
|
22
|
+
|
|
23
|
+
// Buffered message queue: onMessage collects arrivals; next() dequeues or waits.
|
|
24
|
+
// Set up via onInit so the listener is active before the connection opens —
|
|
25
|
+
// guarantees no message is ever missed regardless of event-loop ordering.
|
|
26
|
+
function makeClient() {
|
|
27
|
+
const queue: unknown[] = [];
|
|
28
|
+
const waiters: Array<(m: unknown) => void> = [];
|
|
29
|
+
|
|
30
|
+
const onMessage = (data: { toString(): string }) => {
|
|
31
|
+
const msg = JSON.parse(data.toString()) as unknown;
|
|
32
|
+
const resolve = waiters.shift();
|
|
33
|
+
if (resolve) resolve(msg);
|
|
34
|
+
else queue.push(msg);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const next = (ms = 2000): Promise<unknown> => {
|
|
38
|
+
if (queue.length > 0) return Promise.resolve(queue.shift());
|
|
39
|
+
return new Promise((res, rej) => {
|
|
40
|
+
const t = setTimeout(() => {
|
|
41
|
+
const i = waiters.indexOf(res);
|
|
42
|
+
if (i !== -1) waiters.splice(i, 1);
|
|
43
|
+
rej(new Error('timeout waiting for WS message'));
|
|
44
|
+
}, ms);
|
|
45
|
+
waiters.push(m => {
|
|
46
|
+
clearTimeout(t);
|
|
47
|
+
res(m);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return { onMessage, next };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function openSocket(
|
|
56
|
+
client: ReturnType<typeof makeClient>,
|
|
57
|
+
): Promise<WebSocket> {
|
|
58
|
+
return app.injectWS('/ws', undefined, {
|
|
59
|
+
onInit: (w: WebSocket) => w.on('message', client.onMessage),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
app = Fastify({ logger: false });
|
|
65
|
+
await registerWebSocketRoute(app);
|
|
66
|
+
await app.ready();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
await app.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('WebSocket /ws', () => {
|
|
74
|
+
it('sends connected ack immediately on connection', async () => {
|
|
75
|
+
const client = makeClient();
|
|
76
|
+
const ws = await openSocket(client);
|
|
77
|
+
expect(await client.next()).toEqual({ type: 'connected' });
|
|
78
|
+
ws.close();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('responds to ping with pong', async () => {
|
|
82
|
+
const client = makeClient();
|
|
83
|
+
const ws = await openSocket(client);
|
|
84
|
+
await client.next(); // consume connected
|
|
85
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
86
|
+
expect(await client.next()).toEqual({ type: 'pong' });
|
|
87
|
+
ws.close();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('sends error reply for unknown message type', async () => {
|
|
91
|
+
const client = makeClient();
|
|
92
|
+
const ws = await openSocket(client);
|
|
93
|
+
await client.next(); // consume connected
|
|
94
|
+
|
|
95
|
+
ws.send(JSON.stringify({ type: 'totally-unknown', payload: 42 }));
|
|
96
|
+
|
|
97
|
+
expect(await client.next()).toEqual({ type: 'error', message: 'Unknown message type' });
|
|
98
|
+
ws.close();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('server stays alive after unknown message type', async () => {
|
|
102
|
+
const client = makeClient();
|
|
103
|
+
const ws = await openSocket(client);
|
|
104
|
+
await client.next(); // consume connected
|
|
105
|
+
|
|
106
|
+
ws.send(JSON.stringify({ type: 'totally-unknown', payload: 42 }));
|
|
107
|
+
await client.next(); // consume error reply
|
|
108
|
+
|
|
109
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
110
|
+
expect(await client.next()).toEqual({ type: 'pong' });
|
|
111
|
+
ws.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('accepts run-start message without crashing (stub handler)', async () => {
|
|
115
|
+
const client = makeClient();
|
|
116
|
+
const ws = await openSocket(client);
|
|
117
|
+
await client.next(); // consume connected
|
|
118
|
+
|
|
119
|
+
ws.send(JSON.stringify({ type: 'run-start', sourceIds: ['src-1'] }));
|
|
120
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
121
|
+
|
|
122
|
+
// pong proves the server processed run-start without crashing
|
|
123
|
+
expect(await client.next()).toEqual({ type: 'pong' });
|
|
124
|
+
ws.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does not crash on malformed JSON — server stays alive', async () => {
|
|
128
|
+
const client = makeClient();
|
|
129
|
+
const ws = await openSocket(client);
|
|
130
|
+
await client.next();
|
|
131
|
+
|
|
132
|
+
ws.send('this is {{ not valid json');
|
|
133
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
134
|
+
|
|
135
|
+
expect(await client.next()).toEqual({ type: 'pong' });
|
|
136
|
+
ws.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('handles multiple concurrent connections independently', async () => {
|
|
140
|
+
const c1 = makeClient();
|
|
141
|
+
const c2 = makeClient();
|
|
142
|
+
const ws1 = await openSocket(c1);
|
|
143
|
+
const ws2 = await openSocket(c2);
|
|
144
|
+
|
|
145
|
+
expect(await c1.next()).toEqual({ type: 'connected' });
|
|
146
|
+
expect(await c2.next()).toEqual({ type: 'connected' });
|
|
147
|
+
|
|
148
|
+
ws1.close();
|
|
149
|
+
ws2.close();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
extractAccountIdentifiers,
|
|
4
|
+
type SourceType,
|
|
5
|
+
type ValidatedPayload,
|
|
6
|
+
} from './check-accounts.js';
|
|
7
|
+
import { getVault, updateVault } from './vault-store.js';
|
|
8
|
+
import type { AccountRecord } from './vault.js';
|
|
9
|
+
|
|
10
|
+
export async function registerDiscoveredAccounts(
|
|
11
|
+
sourceType: SourceType,
|
|
12
|
+
sourceId: string,
|
|
13
|
+
payloads: ValidatedPayload[],
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const existing = getVault().accountRecords;
|
|
16
|
+
const newRecords: AccountRecord[] = [];
|
|
17
|
+
|
|
18
|
+
for (const payload of payloads) {
|
|
19
|
+
for (const accountNumber of extractAccountIdentifiers(sourceType, payload)) {
|
|
20
|
+
const alreadyKnown = existing.some(
|
|
21
|
+
r => r.sourceType === sourceType && r.accountNumber === accountNumber,
|
|
22
|
+
);
|
|
23
|
+
if (
|
|
24
|
+
!alreadyKnown &&
|
|
25
|
+
!newRecords.some(
|
|
26
|
+
r =>
|
|
27
|
+
r.sourceType === sourceType &&
|
|
28
|
+
r.sourceId === sourceId &&
|
|
29
|
+
r.accountNumber === accountNumber,
|
|
30
|
+
)
|
|
31
|
+
) {
|
|
32
|
+
newRecords.push({
|
|
33
|
+
id: randomUUID(),
|
|
34
|
+
sourceId,
|
|
35
|
+
sourceType,
|
|
36
|
+
accountNumber,
|
|
37
|
+
status: 'pending' as const,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (newRecords.length > 0) {
|
|
44
|
+
await updateVault(v => ({
|
|
45
|
+
...v,
|
|
46
|
+
accountRecords: [...v.accountRecords, ...newRecords],
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { getVault, isLocked, updateVault } from './vault-store.js';
|
|
3
|
+
|
|
4
|
+
type StatusBody = { status: 'accepted' | 'ignored' };
|
|
5
|
+
|
|
6
|
+
function guardLocked(reply: { status(code: number): { send(body: unknown): unknown } }) {
|
|
7
|
+
if (isLocked()) return reply.status(401).send({ error: 'vault-locked' });
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function registerAccountsRoutes(app: FastifyInstance): Promise<void> {
|
|
12
|
+
app.get('/api/vault/accounts', async (_req, reply) => {
|
|
13
|
+
const blocked = guardLocked(reply);
|
|
14
|
+
if (blocked) return blocked;
|
|
15
|
+
const vault = getVault();
|
|
16
|
+
const { accountRecords } = vault;
|
|
17
|
+
return accountRecords.map(r => ({
|
|
18
|
+
...r,
|
|
19
|
+
nickname: (
|
|
20
|
+
vault[`${r.sourceType}Accounts` as keyof typeof vault] as {
|
|
21
|
+
id: string;
|
|
22
|
+
nickname?: string;
|
|
23
|
+
}[]
|
|
24
|
+
).find(a => a.id === r.sourceId)?.nickname,
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.put<{ Params: { id: string }; Body: StatusBody }>(
|
|
29
|
+
'/api/vault/accounts/:id',
|
|
30
|
+
async (req, reply) => {
|
|
31
|
+
const blocked = guardLocked(reply);
|
|
32
|
+
if (blocked) return blocked;
|
|
33
|
+
|
|
34
|
+
const { id } = req.params;
|
|
35
|
+
const { status } = req.body;
|
|
36
|
+
|
|
37
|
+
if (status !== 'accepted' && status !== 'ignored') {
|
|
38
|
+
return reply.status(400).send({ error: 'status must be accepted or ignored' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const vault = getVault();
|
|
42
|
+
if (!vault.accountRecords.some(a => a.id === id)) {
|
|
43
|
+
return reply.status(404).send({ error: 'not-found' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await updateVault(v => {
|
|
47
|
+
const idx = v.accountRecords.findIndex(a => a.id === id);
|
|
48
|
+
if (idx === -1) return v;
|
|
49
|
+
const accounts = [...v.accountRecords];
|
|
50
|
+
accounts[idx] = { ...accounts[idx], status };
|
|
51
|
+
return { ...v, accountRecords: accounts };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return getVault().accountRecords;
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
app.delete<{ Params: { id: string } }>('/api/vault/accounts/:id', async (req, reply) => {
|
|
59
|
+
const blocked = guardLocked(reply);
|
|
60
|
+
if (blocked) return blocked;
|
|
61
|
+
|
|
62
|
+
const { id } = req.params;
|
|
63
|
+
if (!getVault().accountRecords.some(a => a.id === id)) {
|
|
64
|
+
return reply.status(404).send({ error: 'not-found' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await updateVault(v => ({
|
|
68
|
+
...v,
|
|
69
|
+
accountRecords: v.accountRecords.filter(a => a.id !== id),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
return getVault().accountRecords;
|
|
73
|
+
});
|
|
74
|
+
}
|