@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,132 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OtpCancelledError, OtpManager, OtpTimeoutError } from '../otp-manager.js';
|
|
3
|
+
import type { ServerMessage } from '../../shared/ws-protocol.js';
|
|
4
|
+
|
|
5
|
+
function makeEmit() {
|
|
6
|
+
const sent: ServerMessage[] = [];
|
|
7
|
+
const emit = (msg: ServerMessage) => sent.push(msg);
|
|
8
|
+
return { emit, sent };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('waitForOtp', () => {
|
|
16
|
+
it('resolves with the correct string when submitOtp is called', async () => {
|
|
17
|
+
const manager = new OtpManager();
|
|
18
|
+
const { emit } = makeEmit();
|
|
19
|
+
const promise = manager.waitForOtp('src-1', emit);
|
|
20
|
+
manager.submitOtp('src-1', '123456');
|
|
21
|
+
await expect(promise).resolves.toBe('123456');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects with OtpTimeoutError after timeout elapses', async () => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
const manager = new OtpManager();
|
|
27
|
+
const { emit } = makeEmit();
|
|
28
|
+
const promise = manager.waitForOtp('src-1', emit, 100);
|
|
29
|
+
vi.advanceTimersByTime(100);
|
|
30
|
+
await expect(promise).rejects.toBeInstanceOf(OtpTimeoutError);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('emits otp-required immediately on call', async () => {
|
|
34
|
+
const manager = new OtpManager();
|
|
35
|
+
const { emit, sent } = makeEmit();
|
|
36
|
+
const promise = manager.waitForOtp('src-1', emit);
|
|
37
|
+
expect(sent).toEqual([{ type: 'otp-required', sourceId: 'src-1' }]);
|
|
38
|
+
manager.submitOtp('src-1', 'x');
|
|
39
|
+
await promise;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('OtpTimeoutError carries the sourceId', async () => {
|
|
43
|
+
vi.useFakeTimers();
|
|
44
|
+
const manager = new OtpManager();
|
|
45
|
+
const { emit } = makeEmit();
|
|
46
|
+
const promise = manager.waitForOtp('src-timeout', emit, 100);
|
|
47
|
+
vi.advanceTimersByTime(100);
|
|
48
|
+
try {
|
|
49
|
+
await promise;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
expect(e).toBeInstanceOf(OtpTimeoutError);
|
|
52
|
+
expect((e as OtpTimeoutError).sourceId).toBe('src-timeout');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('submitOtp', () => {
|
|
58
|
+
it('is a no-op for an unknown sourceId', () => {
|
|
59
|
+
const manager = new OtpManager();
|
|
60
|
+
expect(() => manager.submitOtp('does-not-exist', '000000')).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('clears the pending entry so a second submit is a no-op', async () => {
|
|
64
|
+
const manager = new OtpManager();
|
|
65
|
+
const { emit } = makeEmit();
|
|
66
|
+
const promise = manager.waitForOtp('src-1', emit);
|
|
67
|
+
manager.submitOtp('src-1', 'first');
|
|
68
|
+
manager.submitOtp('src-1', 'second'); // should not throw or double-resolve
|
|
69
|
+
await expect(promise).resolves.toBe('first');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('cancelOtp', () => {
|
|
74
|
+
it('rejects the waiting promise with OtpCancelledError', async () => {
|
|
75
|
+
const manager = new OtpManager();
|
|
76
|
+
const { emit } = makeEmit();
|
|
77
|
+
const promise = manager.waitForOtp('src-1', emit);
|
|
78
|
+
manager.cancelOtp('src-1');
|
|
79
|
+
await expect(promise).rejects.toBeInstanceOf(OtpCancelledError);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('is a no-op for an unknown sourceId', () => {
|
|
83
|
+
const manager = new OtpManager();
|
|
84
|
+
expect(() => manager.cancelOtp('does-not-exist')).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('hasPendingOtp', () => {
|
|
89
|
+
it('returns true while waiting, false after resolution', async () => {
|
|
90
|
+
const manager = new OtpManager();
|
|
91
|
+
const { emit } = makeEmit();
|
|
92
|
+
const promise = manager.waitForOtp('src-1', emit);
|
|
93
|
+
expect(manager.hasPendingOtp('src-1')).toBe(true);
|
|
94
|
+
manager.submitOtp('src-1', 'otp');
|
|
95
|
+
await promise;
|
|
96
|
+
expect(manager.hasPendingOtp('src-1')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns false for an unknown sourceId', () => {
|
|
100
|
+
const manager = new OtpManager();
|
|
101
|
+
expect(manager.hasPendingOtp('nope')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('concurrent waitForOtp for different sourceIds', () => {
|
|
106
|
+
it('resolves each independently', async () => {
|
|
107
|
+
const manager = new OtpManager();
|
|
108
|
+
const { emit } = makeEmit();
|
|
109
|
+
const p1 = manager.waitForOtp('src-a', emit);
|
|
110
|
+
const p2 = manager.waitForOtp('src-b', emit);
|
|
111
|
+
|
|
112
|
+
manager.submitOtp('src-a', 'otp-for-a');
|
|
113
|
+
await expect(p1).resolves.toBe('otp-for-a');
|
|
114
|
+
|
|
115
|
+
manager.submitOtp('src-b', 'otp-for-b');
|
|
116
|
+
await expect(p2).resolves.toBe('otp-for-b');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('timing out one does not affect the other', async () => {
|
|
120
|
+
vi.useFakeTimers();
|
|
121
|
+
const manager = new OtpManager();
|
|
122
|
+
const { emit } = makeEmit();
|
|
123
|
+
const p1 = manager.waitForOtp('src-short', emit, 100);
|
|
124
|
+
const p2 = manager.waitForOtp('src-long', emit, 10_000);
|
|
125
|
+
|
|
126
|
+
vi.advanceTimersByTime(100);
|
|
127
|
+
await expect(p1).rejects.toBeInstanceOf(OtpTimeoutError);
|
|
128
|
+
|
|
129
|
+
manager.submitOtp('src-long', 'still-good');
|
|
130
|
+
await expect(p2).resolves.toBe('still-good');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { _resetRunState, startRun, type ScrapeTask } from '../scrape-runner.js';
|
|
3
|
+
import type { ServerMessage } from '../../shared/ws-protocol.js';
|
|
4
|
+
|
|
5
|
+
type TaskResult = { inserted: number; skipped: number; insertedIds: string[]; insertedTransactions: []; changedTransactions: [] };
|
|
6
|
+
|
|
7
|
+
function makeTask(
|
|
8
|
+
sourceId: string,
|
|
9
|
+
run: () => Promise<TaskResult> = async () => ({
|
|
10
|
+
inserted: 2,
|
|
11
|
+
skipped: 1,
|
|
12
|
+
insertedIds: ['id-1', 'id-2'],
|
|
13
|
+
insertedTransactions: [],
|
|
14
|
+
changedTransactions: [],
|
|
15
|
+
}),
|
|
16
|
+
): ScrapeTask {
|
|
17
|
+
return { sourceId, nickname: sourceId, type: 'poalim', run };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
_resetRunState();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ── Sequential ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('sequential mode', () => {
|
|
27
|
+
it('emits pending×2 → running→done task1 → running→done task2 → run-complete', async () => {
|
|
28
|
+
const events: ServerMessage[] = [];
|
|
29
|
+
const emit = (msg: ServerMessage) => events.push(msg);
|
|
30
|
+
|
|
31
|
+
await startRun([makeTask('src-1'), makeTask('src-2')], false, emit);
|
|
32
|
+
|
|
33
|
+
expect(events[0]).toEqual({ type: 'task-pending', sourceId: 'src-1' });
|
|
34
|
+
expect(events[1]).toEqual({ type: 'task-pending', sourceId: 'src-2' });
|
|
35
|
+
expect(events[2]).toMatchObject({ type: 'task-running', sourceId: 'src-1' });
|
|
36
|
+
expect(events[3]).toMatchObject({ type: 'task-done', sourceId: 'src-1', inserted: 2, skipped: 1 });
|
|
37
|
+
expect(events[4]).toMatchObject({ type: 'task-running', sourceId: 'src-2' });
|
|
38
|
+
expect(events[5]).toMatchObject({ type: 'task-done', sourceId: 'src-2', inserted: 2, skipped: 1 });
|
|
39
|
+
expect(events[6]).toEqual({ type: 'run-complete', totalInserted: 4, totalSkipped: 2, errors: 0 });
|
|
40
|
+
expect(events).toHaveLength(7);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns a RunRecord with correct totals and timestamps', async () => {
|
|
44
|
+
const before = new Date();
|
|
45
|
+
const record = await startRun([makeTask('src-1')], false, () => {});
|
|
46
|
+
const after = new Date();
|
|
47
|
+
|
|
48
|
+
expect(record.id).toMatch(/^[\da-f-]{36}$/);
|
|
49
|
+
expect(record.totalInserted).toBe(2);
|
|
50
|
+
expect(record.totalSkipped).toBe(1);
|
|
51
|
+
expect(record.errorCount).toBe(0);
|
|
52
|
+
expect(record.startedAt >= before).toBe(true);
|
|
53
|
+
expect(record.completedAt <= after).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Concurrent ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('concurrent mode', () => {
|
|
60
|
+
it('both running events arrive before either done event', async () => {
|
|
61
|
+
const events: ServerMessage[] = [];
|
|
62
|
+
const emit = (msg: ServerMessage) => events.push(msg);
|
|
63
|
+
|
|
64
|
+
const delayed = (): Promise<TaskResult> =>
|
|
65
|
+
new Promise(res => setTimeout(() => res({ inserted: 1, skipped: 0, insertedIds: ['x'], insertedTransactions: [], changedTransactions: [] }), 20));
|
|
66
|
+
|
|
67
|
+
await startRun([makeTask('src-1', delayed), makeTask('src-2', delayed)], true, emit);
|
|
68
|
+
|
|
69
|
+
const runningIdxs = events.flatMap((e, i) => (e.type === 'task-running' ? [i] : []));
|
|
70
|
+
const doneIdxs = events.flatMap((e, i) => (e.type === 'task-done' ? [i] : []));
|
|
71
|
+
|
|
72
|
+
expect(runningIdxs).toHaveLength(2);
|
|
73
|
+
expect(doneIdxs).toHaveLength(2);
|
|
74
|
+
expect(Math.max(...runningIdxs)).toBeLessThan(Math.min(...doneIdxs));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('totals reflect both tasks', async () => {
|
|
78
|
+
const record = await startRun(
|
|
79
|
+
[makeTask('src-1'), makeTask('src-2')],
|
|
80
|
+
true,
|
|
81
|
+
() => {},
|
|
82
|
+
);
|
|
83
|
+
expect(record.totalInserted).toBe(4);
|
|
84
|
+
expect(record.totalSkipped).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── Task error ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('task error handling', () => {
|
|
91
|
+
it('emits task-error when run() throws, other task still completes', async () => {
|
|
92
|
+
const events: ServerMessage[] = [];
|
|
93
|
+
const emit = (msg: ServerMessage) => events.push(msg);
|
|
94
|
+
|
|
95
|
+
const failing = makeTask('src-1', async () => {
|
|
96
|
+
throw new Error('Scraper exploded');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await startRun([failing, makeTask('src-2')], false, emit);
|
|
100
|
+
|
|
101
|
+
const taskError = events.find(
|
|
102
|
+
e => e.type === 'task-error' && 'sourceId' in e && e.sourceId === 'src-1',
|
|
103
|
+
);
|
|
104
|
+
expect(taskError).toBeTruthy();
|
|
105
|
+
expect((taskError as { message: string }).message).toContain('Scraper exploded');
|
|
106
|
+
|
|
107
|
+
expect(events.some(e => e.type === 'task-done' && 'sourceId' in e && e.sourceId === 'src-2')).toBe(true);
|
|
108
|
+
expect(events.at(-1)).toMatchObject({ type: 'run-complete', errors: 1 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('run-complete totals reflect only the successful task', async () => {
|
|
112
|
+
const record = await startRun(
|
|
113
|
+
[
|
|
114
|
+
makeTask('src-1', async () => {
|
|
115
|
+
throw new Error('fail');
|
|
116
|
+
}),
|
|
117
|
+
makeTask('src-2'),
|
|
118
|
+
],
|
|
119
|
+
false,
|
|
120
|
+
() => {},
|
|
121
|
+
);
|
|
122
|
+
expect(record.totalInserted).toBe(2); // only src-2
|
|
123
|
+
expect(record.totalSkipped).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── In-progress guard ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('in-progress guard', () => {
|
|
130
|
+
it('throws when called while a run is already in progress', async () => {
|
|
131
|
+
const neverResolves = (): Promise<TaskResult> => new Promise(_r => {});
|
|
132
|
+
|
|
133
|
+
// Start a run that never finishes (don't await it)
|
|
134
|
+
const firstRun = startRun([makeTask('src-1', neverResolves)], false, () => {});
|
|
135
|
+
|
|
136
|
+
// Second call should reject immediately because _running is already true
|
|
137
|
+
await expect(startRun([makeTask('src-2')], false, () => {})).rejects.toThrow(
|
|
138
|
+
'Run already in progress',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Cleanup: _resetRunState is called in afterEach, firstRun hangs but that's OK
|
|
142
|
+
void firstRun;
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
let vaultPath: string;
|
|
14
|
+
let app: FastifyInstance;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
vaultPath = join(tmpdir(), `vault-test-${randomBytes(4).toString('hex')}.vault`);
|
|
18
|
+
process.env['VAULT_PATH'] = vaultPath;
|
|
19
|
+
await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
|
|
20
|
+
|
|
21
|
+
app = Fastify();
|
|
22
|
+
await registerVaultRoutes(app);
|
|
23
|
+
await app.ready();
|
|
24
|
+
|
|
25
|
+
await app.inject({
|
|
26
|
+
method: 'POST',
|
|
27
|
+
url: '/api/vault/unlock',
|
|
28
|
+
payload: { password: PASSWORD },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
lockVault();
|
|
34
|
+
await app.close();
|
|
35
|
+
await rm(vaultPath, { force: true });
|
|
36
|
+
delete process.env['VAULT_PATH'];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('GET /api/vault/settings', () => {
|
|
40
|
+
it('returns default settings after unlock', async () => {
|
|
41
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/settings' });
|
|
42
|
+
expect(res.statusCode).toBe(200);
|
|
43
|
+
expect(res.json()).toMatchObject({
|
|
44
|
+
showBrowser: false,
|
|
45
|
+
fetchBankOfIsraelRates: true,
|
|
46
|
+
concurrentScraping: false,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns 401 when vault is locked', async () => {
|
|
51
|
+
lockVault();
|
|
52
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/settings' });
|
|
53
|
+
expect(res.statusCode).toBe(401);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('PUT /api/vault/settings', () => {
|
|
58
|
+
it('merges partial update and persists', async () => {
|
|
59
|
+
const res = await app.inject({
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
url: '/api/vault/settings',
|
|
62
|
+
payload: { showBrowser: true, serverUrl: 'https://example.com' },
|
|
63
|
+
});
|
|
64
|
+
expect(res.statusCode).toBe(200);
|
|
65
|
+
const settings = res.json() as Record<string, unknown>;
|
|
66
|
+
expect(settings.showBrowser).toBe(true);
|
|
67
|
+
expect(settings.serverUrl).toBe('https://example.com');
|
|
68
|
+
expect(settings.fetchBankOfIsraelRates).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('GET after PUT reflects saved value', async () => {
|
|
72
|
+
await app.inject({
|
|
73
|
+
method: 'PUT',
|
|
74
|
+
url: '/api/vault/settings',
|
|
75
|
+
payload: { apiKey: 'my-key' },
|
|
76
|
+
});
|
|
77
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/settings' });
|
|
78
|
+
expect(res.json()).toMatchObject({ apiKey: 'my-key' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('survives re-lock and re-unlock (persisted to disk)', async () => {
|
|
82
|
+
await app.inject({
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
url: '/api/vault/settings',
|
|
85
|
+
payload: { serverUrl: 'https://persisted.example' },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
lockVault();
|
|
89
|
+
await app.inject({
|
|
90
|
+
method: 'POST',
|
|
91
|
+
url: '/api/vault/unlock',
|
|
92
|
+
payload: { password: PASSWORD },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/settings' });
|
|
96
|
+
expect(res.json()).toMatchObject({ serverUrl: 'https://persisted.example' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns 401 when vault is locked', async () => {
|
|
100
|
+
lockVault();
|
|
101
|
+
const res = await app.inject({
|
|
102
|
+
method: 'PUT',
|
|
103
|
+
url: '/api/vault/settings',
|
|
104
|
+
payload: { showBrowser: true },
|
|
105
|
+
});
|
|
106
|
+
expect(res.statusCode).toBe(401);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns 400 for invalid field types', async () => {
|
|
110
|
+
const res = await app.inject({
|
|
111
|
+
method: 'PUT',
|
|
112
|
+
url: '/api/vault/settings',
|
|
113
|
+
payload: { showBrowser: 'not-a-boolean' },
|
|
114
|
+
});
|
|
115
|
+
expect(res.statusCode).toBe(400);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
let vaultPath: string;
|
|
14
|
+
let app: FastifyInstance;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
vaultPath = join(tmpdir(), `vault-test-${randomBytes(4).toString('hex')}.vault`);
|
|
18
|
+
process.env['VAULT_PATH'] = vaultPath;
|
|
19
|
+
await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
|
|
20
|
+
|
|
21
|
+
app = Fastify();
|
|
22
|
+
await registerVaultRoutes(app);
|
|
23
|
+
await app.ready();
|
|
24
|
+
|
|
25
|
+
await app.inject({
|
|
26
|
+
method: 'POST',
|
|
27
|
+
url: '/api/vault/unlock',
|
|
28
|
+
payload: { password: PASSWORD },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
lockVault();
|
|
34
|
+
await app.close();
|
|
35
|
+
await rm(vaultPath, { force: true });
|
|
36
|
+
delete process.env['VAULT_PATH'];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('GET /api/vault/sources', () => {
|
|
40
|
+
it('returns empty list initially', async () => {
|
|
41
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/sources' });
|
|
42
|
+
expect(res.statusCode).toBe(200);
|
|
43
|
+
expect(res.json()).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns 401 when vault is locked', async () => {
|
|
47
|
+
lockVault();
|
|
48
|
+
const res = await app.inject({ method: 'GET', url: '/api/vault/sources' });
|
|
49
|
+
expect(res.statusCode).toBe(401);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('POST /api/vault/sources', () => {
|
|
54
|
+
it('appends a poalim source and returns updated list', async () => {
|
|
55
|
+
const res = await app.inject({
|
|
56
|
+
method: 'POST',
|
|
57
|
+
url: '/api/vault/sources',
|
|
58
|
+
payload: { type: 'poalim', userCode: 'user1', password: 'pass1' },
|
|
59
|
+
});
|
|
60
|
+
expect(res.statusCode).toBe(200);
|
|
61
|
+
const list = res.json() as Array<{ type: string; userCode: string; id: string }>;
|
|
62
|
+
expect(list).toHaveLength(1);
|
|
63
|
+
expect(list[0].type).toBe('poalim');
|
|
64
|
+
expect(list[0].userCode).toBe('user1');
|
|
65
|
+
expect(list[0].id).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('appends a discount source', async () => {
|
|
69
|
+
const res = await app.inject({
|
|
70
|
+
method: 'POST',
|
|
71
|
+
url: '/api/vault/sources',
|
|
72
|
+
payload: { type: 'discount', ID: 'D123', password: 'pass2' },
|
|
73
|
+
});
|
|
74
|
+
expect(res.statusCode).toBe(200);
|
|
75
|
+
const list = res.json() as Array<{ type: string }>;
|
|
76
|
+
expect(list[0].type).toBe('discount');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns 400 for missing required fields', async () => {
|
|
80
|
+
const res = await app.inject({
|
|
81
|
+
method: 'POST',
|
|
82
|
+
url: '/api/vault/sources',
|
|
83
|
+
payload: { type: 'poalim' },
|
|
84
|
+
});
|
|
85
|
+
expect(res.statusCode).toBe(400);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns 401 when vault is locked', async () => {
|
|
89
|
+
lockVault();
|
|
90
|
+
const res = await app.inject({
|
|
91
|
+
method: 'POST',
|
|
92
|
+
url: '/api/vault/sources',
|
|
93
|
+
payload: { type: 'poalim', userCode: 'user1', password: 'pass1' },
|
|
94
|
+
});
|
|
95
|
+
expect(res.statusCode).toBe(401);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('PUT /api/vault/sources/:id', () => {
|
|
100
|
+
it('updates a source and returns updated list', async () => {
|
|
101
|
+
const addRes = await app.inject({
|
|
102
|
+
method: 'POST',
|
|
103
|
+
url: '/api/vault/sources',
|
|
104
|
+
payload: { type: 'poalim', userCode: 'user1', password: 'pass1' },
|
|
105
|
+
});
|
|
106
|
+
const [added] = addRes.json() as Array<{ id: string; nickname?: string }>;
|
|
107
|
+
|
|
108
|
+
const res = await app.inject({
|
|
109
|
+
method: 'PUT',
|
|
110
|
+
url: `/api/vault/sources/${added.id}`,
|
|
111
|
+
payload: { nickname: 'My Poalim' },
|
|
112
|
+
});
|
|
113
|
+
expect(res.statusCode).toBe(200);
|
|
114
|
+
const list = res.json() as Array<{ id: string; nickname?: string }>;
|
|
115
|
+
expect(list[0].nickname).toBe('My Poalim');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns 404 for unknown id', async () => {
|
|
119
|
+
const res = await app.inject({
|
|
120
|
+
method: 'PUT',
|
|
121
|
+
url: '/api/vault/sources/does-not-exist',
|
|
122
|
+
payload: { nickname: 'x' },
|
|
123
|
+
});
|
|
124
|
+
expect(res.statusCode).toBe(404);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('DELETE /api/vault/sources/:id', () => {
|
|
129
|
+
it('removes a source and returns updated list', async () => {
|
|
130
|
+
const addRes = await app.inject({
|
|
131
|
+
method: 'POST',
|
|
132
|
+
url: '/api/vault/sources',
|
|
133
|
+
payload: { type: 'max', username: 'maxuser', password: 'pass3' },
|
|
134
|
+
});
|
|
135
|
+
const [added] = addRes.json() as Array<{ id: string }>;
|
|
136
|
+
|
|
137
|
+
const res = await app.inject({
|
|
138
|
+
method: 'DELETE',
|
|
139
|
+
url: `/api/vault/sources/${added.id}`,
|
|
140
|
+
});
|
|
141
|
+
expect(res.statusCode).toBe(200);
|
|
142
|
+
expect(res.json()).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns 404 for unknown id', async () => {
|
|
146
|
+
const res = await app.inject({ method: 'DELETE', url: '/api/vault/sources/ghost' });
|
|
147
|
+
expect(res.statusCode).toBe(404);
|
|
148
|
+
});
|
|
149
|
+
});
|