/** * DawarichService: DAWARICH-SVC-001 through DAWARICH-SVC-143. * * This is the credential half of the Dawarich integration. Everything it owns * is either a secret at rest or a decision about that secret, which is why the * database here is real in-memory SQLite rather than a stub: the interesting * behaviour lives in an UPSERT plus three conditional UPDATEs inside one * transaction, and a spy on `db.run` would only prove that some strings were * handed along. Reading the row back is the assertion. * * The at-rest crypto is real too, for the same reason. `maybe_encrypt_api_key` * stubbed to the identity function would let a regression that writes the key * in plaintext pass every case in this file, and "the key is never in the * column as typed" is the single most important thing the service does. * * The two things that would reach outside the process are faked: `checkSsrf`, * because the save path resolves DNS and a test must not depend on a resolver, * and `DawarichClient`, because the probe makes six upstream calls. The client * stub has no default implementation of its own beyond what each case sets, so * an unstubbed call fails loudly instead of dialling out. * * What is worth pinning here, in order of what it costs when it regresses: * * - **the key never leaves.** `getConnection` hands back a mask, and a key * that cannot be decrypted (rotated ENCRYPTION_KEY, restored backup) has to * read as "not connected" rather than be sent upstream as a garbage bearer * token; * - **the key goes with the host.** Re-pointing the connection at a different * Dawarich without retyping the key must drop the key, because a credential * minted on one instance shipped to another one is a leak with a helpful UI * in front of it. Same origin, different path is the same instance and must * keep it, or every trailing-slash edit logs the user out; * - **a private address is a warning, not a refusal.** A self-hosted Dawarich * on a LAN is the common case. A guard that refuses it breaks the majority * of installs to protect against something that is not happening; * - **an older Dawarich still connects.** A 404 from an endpoint turns that * one capability off; anything else propagates, because "connected, but * nothing works" is a worse answer than an error; * - **a stored capabilities blob is read defensively.** It is written by * whatever build was running last, and a malformed value means "not probed * yet", never a crash on the settings page. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { createSnapshotTestDb } from '../../helpers/db-mock'; // ── DB setup (real in-memory SQLite, the pattern the other Dawarich service tests use) ── const testDb = createSnapshotTestDb(); // Same fixed key the global setup exports, pinned here so the at-rest round // trip cannot depend on what is lying in server/data. vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); // The whole ssrfGuard surface, not only `checkSsrf`: several modules on the // import graph pull other names off it, and a factory mock that omits one // throws on access rather than falling back to the real export. const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() })); vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf, safeFetch: vi.fn(), safeFetchFollow: vi.fn(), safeFetchAdminConfigured: vi.fn(), safeFetchLlm: vi.fn(), createPinnedDispatcher: vi.fn(() => ({})), SsrfBlockedError: class extends Error {}, })); import { DAWARICH_KEY_MASK, type DawarichCapabilities } from '@trek/shared'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { DawarichConnections } from '../../../src/db/entities/DawarichConnections.entity'; import { findRow, upsertRow } from '../../helpers/factories/rows'; import type { DawarichConnectionsRepository } from '../../../src/db/repositories/DawarichConnections.repository'; import { DawarichService } from '../../../src/nest/integrations/dawarich.service'; import type { TestOrm } from '../../helpers/test-orm'; import { createTestDawarichConnectionsRepo } from '../../helpers/dawarich-repos'; import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow'; import { DawarichError, type DawarichClient, type DawarichVisitRaw, } from '../../../src/nest/integrations/dawarich.client'; import type { AuditService } from '../../../src/nest/audit/audit.service'; // ── Collaborators ──────────────────────────────────────────────────────────── const audit = { writeAudit: vi.fn() }; const client = { probe: vi.fn(), listVisits: vi.fn(), listTracks: vi.fn(), listPoints: vi.fn(), listVisitedCities: vi.fn(), findVisitsNear: vi.fn(), }; let t: TestOrm; let connections: DawarichConnectionsRepository; let svc: DawarichService; // ── Fixtures ───────────────────────────────────────────────────────────────── const DAY_MS = 86_400_000; const HOST = 'https://dawarich.example'; const IP = '203.0.113.44'; let USER = 0; interface ConnFixture { url: string | null; /** Written verbatim, so a case can store a legacy plaintext key or a corrupt blob. */ apiKey: string | null; allowInsecureTls: 0 | 1; syncEnabled: 0 | 1; lastSyncAt: string | null; lastSyncState: string; lastSyncError: string | null; capabilities: string | null; } async function connect(userId: number, over: Partial = {}): Promise { const row: ConnFixture = { url: HOST, apiKey: 'stored-key', allowInsecureTls: 0, syncEnabled: 1, lastSyncAt: null, lastSyncState: 'never', lastSyncError: null, capabilities: null, ...over, }; await upsertRow(t, DawarichConnections, { user: userId, url: row.url, api_key: row.apiKey, allow_insecure_tls: row.allowInsecureTls, sync_enabled: row.syncEnabled, last_sync_at: row.lastSyncAt, last_sync_state: row.lastSyncState, last_sync_error: row.lastSyncError, capabilities: row.capabilities, }); } function row(userId = USER) { return findRow(t, DawarichConnections, { user: userId }); } const FULL_CAPS: DawarichCapabilities = { visits: true, tracks: true, points: true, locations: true, visitedCities: true, visitUpdatedAt: false, visitCountryCode: false, serverVersion: '1.14.4', probedAt: '2026-09-12T00:00:00.000Z', }; function visit(over: Partial = {}): DawarichVisitRaw { return { id: 501, area_id: null, started_at: '2026-09-10T09:00:00Z', ended_at: '2026-09-10T12:00:00Z', duration: null, name: 'Hotel Adlon', status: 'suggested', confidence: null, confidence_band: null, place: { latitude: 52.5163, longitude: 13.3777, id: 77 }, ...over, }; } /** Every probed endpoint answers; nothing is found, which is still an answer. */ function allEndpointsAnswer(visits: DawarichVisitRaw[] = []): void { client.probe.mockResolvedValue({ version: '1.14.4' }); client.listVisits.mockResolvedValue({ visits, truncated: false, version: '1.14.4' }); client.listTracks.mockResolvedValue({ features: [], truncated: false }); client.listPoints.mockResolvedValue({ points: [], truncated: false }); client.listVisitedCities.mockResolvedValue([]); client.findVisitsNear.mockResolvedValue([]); } const notFound = () => new DawarichError('not_found', 'Dawarich answered HTTP 404', 404); /** The five capabilities that are nothing but "did this endpoint answer". */ type ProbedCapability = 'visits' | 'tracks' | 'points' | 'visitedCities' | 'locations'; type ProbedMethod = 'listVisits' | 'listTracks' | 'listPoints' | 'listVisitedCities' | 'findVisitsNear'; const PROBED_CAPABILITIES: ProbedCapability[] = ['visits', 'tracks', 'points', 'visitedCities', 'locations']; /** Which client call decides which capability. Mutable tuples, because it.each wants them. */ const endpointCases: Array<[ProbedMethod, ProbedCapability]> = [ ['listVisits', 'visits'], ['listTracks', 'tracks'], ['listPoints', 'points'], ['listVisitedCities', 'visitedCities'], ['findVisitsNear', 'locations'], ]; const updatedAtAbsentCases: Array<[string, Partial]> = [ ['missing', {}], ['explicitly null', { updated_at: null }], ]; const countryCodeAbsentCases: Array<[string, Partial]> = [ ['a visit with no place', { place: null }], ['a place without the field', { place: { latitude: 1, longitude: 2, id: 3 } }], ['a place with a null code', { place: { latitude: 1, longitude: 2, id: 3, country_code: null } }], ]; beforeAll(async () => { t = await sharedTestOrm(testDb); connections = await createTestDawarichConnectionsRepo(testDb); svc = new DawarichService( connections, audit as unknown as AuditService, client as unknown as DawarichClient, await createTestUnitOfWork(testDb), ); }); beforeEach(() => { resetTestDb(testDb); t.clear(); vi.clearAllMocks(); checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: IP }); allEndpointsAnswer(); USER = createUser(testDb).user.id; }); afterAll(async () => { await t.close(); testDb.close(); }); // --------------------------------------------------------------------------- // getCredentials // --------------------------------------------------------------------------- describe('DawarichService getCredentials', () => { it('DAWARICH-SVC-001: a user who never connected has no credentials', async () => { expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-002: a row without an address is not a connection', async () => { await connect(USER, { url: null }); expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-003: a row without a key is not a connection', async () => { await connect(USER, { apiKey: null }); expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', async () => { // What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind: // the envelope is intact, the ciphertext is not ours. await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', async () => { await connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 }); expect(await svc.getCredentials(USER)).toEqual({ baseUrl: HOST, apiKey: 'plain-legacy-key', allowInsecureTls: false, }); }); it('DAWARICH-SVC-006: the self-signed-certificate flag is carried into the credentials as a boolean', async () => { await svc.saveSettings(USER, HOST, 'typed-secret', true, true, IP); expect(await svc.getCredentials(USER)).toEqual({ baseUrl: HOST, apiKey: 'typed-secret', allowInsecureTls: true, }); }); }); // --------------------------------------------------------------------------- // isConnected / listSyncableUserIds // --------------------------------------------------------------------------- describe('DawarichService isConnected', () => { it('DAWARICH-SVC-010: both halves present means connected', async () => { await connect(USER); expect(await svc.isConnected(USER)).toBe(true); }); it('DAWARICH-SVC-011: an address without a key is not connected', async () => { await connect(USER, { apiKey: null }); expect(await svc.isConnected(USER)).toBe(false); }); it('DAWARICH-SVC-012: no row at all is not connected', async () => { expect(await svc.isConnected(USER)).toBe(false); }); it('DAWARICH-SVC-013: an undecryptable key still counts as connected here, because this asks about the row and not about the secret', async () => { // Deliberate difference from getCredentials: the settings card has to keep // showing a connection the user can repair, and only the outbound path // cares that the ciphertext is unusable. await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); expect(await svc.isConnected(USER)).toBe(true); expect(await svc.getCredentials(USER)).toBeNull(); }); }); describe('DawarichService listSyncableUserIds', () => { it('DAWARICH-SVC-020: only rows with background sync on, an address and a key', async () => { const syncable = USER; const pollOff = createUser(testDb).user.id; const noUrl = createUser(testDb).user.id; const blankUrl = createUser(testDb).user.id; const noKey = createUser(testDb).user.id; await connect(syncable); await connect(pollOff, { syncEnabled: 0 }); await connect(noUrl, { url: null }); // An empty string is a distinct case from NULL: the save path stores NULL, // but an older row or a hand-edited one can hold '' and it must not be // polled, because the client would then request /api/v1 on no host at all. await connect(blankUrl, { url: '' }); await connect(noKey, { apiKey: null }); expect(await svc.listSyncableUserIds()).toEqual([syncable]); }); it('DAWARICH-SVC-021: no connections at all is an empty list, not a throw', async () => { expect(await svc.listSyncableUserIds()).toEqual([]); }); it('DAWARICH-SVC-022: every qualifying user comes back, not only the first row the cursor lands on', async () => { // The cron fans out over this list. A query that answered with one id would // leave everyone but the earliest registration silently unsynced, and the // settings card would still say the poll was on for all of them. const second = createUser(testDb).user.id; const pollOff = createUser(testDb).user.id; await connect(USER); await connect(second); await connect(pollOff, { syncEnabled: 0 }); const ids = await svc.listSyncableUserIds(); // No ORDER BY upstream, so the set is the contract and the order is not. expect(new Set(ids)).toEqual(new Set([USER, second])); expect(ids).toHaveLength(2); }); }); // --------------------------------------------------------------------------- // getConnection: what the settings card renders // --------------------------------------------------------------------------- describe('DawarichService getConnection', () => { it('DAWARICH-SVC-030: a user who never opened the card gets empty fields and the poll defaulted ON', async () => { expect(await svc.getConnection(USER)).toEqual({ url: '', apiKeyMasked: '', allowInsecureTls: false, syncEnabled: true, connected: false, lastSyncAt: null, lastSyncState: 'never', lastSyncError: null, capabilities: null, }); }); it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', async () => { await connect(USER, { apiKey: 'stored-key', allowInsecureTls: 1, syncEnabled: 0, lastSyncAt: '2026-09-12T04:15:00.000Z', lastSyncState: 'partial', lastSyncError: 'rate_limited', }); const out = await svc.getConnection(USER); expect(out).toEqual({ url: HOST, apiKeyMasked: DAWARICH_KEY_MASK, allowInsecureTls: true, syncEnabled: false, connected: true, lastSyncAt: '2026-09-12T04:15:00.000Z', lastSyncState: 'partial', lastSyncError: 'rate_limited', capabilities: null, }); expect(JSON.stringify(out)).not.toContain('stored-key'); }); it('DAWARICH-SVC-032: an address without a key reports the address and no mask', async () => { await connect(USER, { apiKey: null }); expect(await svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false }); }); it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', async () => { await connect(USER, { url: null, apiKey: null }); expect(await svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true }); }); it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', async (state) => { await connect(USER, { lastSyncState: state }); expect((await svc.getConnection(USER)).lastSyncState).toBe(state); }); it.each(['syncing', 'OK', 'success', ''])( 'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it', async (state) => { await connect(USER, { lastSyncState: state }); expect((await svc.getConnection(USER)).lastSyncState).toBe('never'); }, ); it.each(['{not json', '', 'undefined'])( 'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page', async (raw) => { await connect(USER, { capabilities: raw }); expect((await svc.getConnection(USER)).capabilities).toBeNull(); }, ); it.each(['null', '42', '"visits"', 'true'])( 'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed', async (raw) => { await connect(USER, { capabilities: raw }); expect((await svc.getConnection(USER)).capabilities).toBeNull(); }, ); it('DAWARICH-SVC-038: a blob written by an older build keeps only the fields it has, with every unknown flag off', async () => { // The column survives an upgrade, so the shape is whatever was current when // the last probe ran. Missing flags are off and a missing probe timestamp // is the epoch, which reads as "long ago" everywhere it is compared. await connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) }); expect((await svc.getConnection(USER)).capabilities).toEqual({ visits: true, tracks: false, points: false, locations: false, visitedCities: false, visitUpdatedAt: false, visitCountryCode: false, serverVersion: null, probedAt: new Date(0).toISOString(), }); }); it('DAWARICH-SVC-039: a complete blob round-trips unchanged', async () => { await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); expect((await svc.getConnection(USER)).capabilities).toEqual(FULL_CAPS); }); }); describe('DawarichService getCapabilities', () => { it('DAWARICH-SVC-040: null when there is no connection', async () => { expect(await svc.getCapabilities(USER)).toBeNull(); }); it('DAWARICH-SVC-041: null when the connection has never been probed', async () => { await connect(USER); expect(await svc.getCapabilities(USER)).toBeNull(); }); it('DAWARICH-SVC-042: the stored probe otherwise', async () => { await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); }); // --------------------------------------------------------------------------- // saveSettings // --------------------------------------------------------------------------- describe('DawarichService saveSettings', () => { it('DAWARICH-SVC-050: a first save creates the row and stores the key encrypted, never as typed', async () => { const out = await svc.saveSettings(USER, ` ${HOST} `, 'super-secret', true, true, IP); expect(out).toEqual({ success: true }); const stored = await row(); expect(stored?.url).toBe(HOST); expect(stored?.allow_insecure_tls).toBe(1); expect(stored?.sync_enabled).toBe(1); expect(stored?.api_key).toMatch(/^enc:v1:/); expect(stored?.api_key).not.toContain('super-secret'); // The round trip is the point: encrypted at rest, usable on the way out. expect((await svc.getCredentials(USER))?.apiKey).toBe('super-secret'); }); it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => { await svc.saveSettings(USER, HOST, 'k', false, false, IP); expect((await row())?.sync_enabled).toBe(0); expect((await row())?.allow_insecure_tls).toBe(0); expect((await svc.getConnection(USER)).syncEnabled).toBe(false); expect(await svc.listSyncableUserIds()).toEqual([]); }); it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => { await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); const encrypted = (await row())?.api_key; await svc.saveSettings(USER, `${HOST}`, undefined, true, true, IP); expect((await row())?.api_key).toBe(encrypted); expect((await row())?.allow_insecure_tls).toBe(1); }); it.each([DAWARICH_KEY_MASK, ' ', ''])( 'DAWARICH-SVC-053: the key field %j means "keep what is stored"', async (typed) => { await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); const encrypted = (await row())?.api_key; await svc.saveSettings(USER, HOST, typed, false, true, IP); expect((await row())?.api_key).toBe(encrypted); expect((await svc.getCredentials(USER))?.apiKey).toBe('first-key'); }, ); it('DAWARICH-SVC-054: a new key typed over an old one replaces it', async () => { await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); await svc.saveSettings(USER, HOST, 'second-key', false, true, IP); expect((await svc.getCredentials(USER))?.apiKey).toBe('second-key'); }); it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => { await connect(USER, { url: 'https://old.example', apiKey: 'stored-key', lastSyncAt: '2026-09-12T04:15:00.000Z', lastSyncState: 'ok', lastSyncError: 'rate_limited', capabilities: JSON.stringify(FULL_CAPS), }); await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); const stored = await row(); expect(stored?.url).toBe('https://new.example'); expect(stored?.api_key).toBeNull(); expect(stored?.capabilities).toBeNull(); expect(stored?.last_sync_state).toBe('never'); expect(stored?.last_sync_error).toBeNull(); expect(stored?.last_sync_at).toBeNull(); }); it('DAWARICH-SVC-056: a different port is a different instance, so the key goes with it', async () => { await connect(USER, { url: 'https://d.example' }); await svc.saveSettings(USER, 'https://d.example:8443', undefined, false, true, IP); expect((await row())?.api_key).toBeNull(); }); it.each([ ['https://d.example/api', 'https://d.example/'], ['https://d.example', 'https://d.example/api/v1'], ['https://d.example/', 'https://d.example'], ])( 'DAWARICH-SVC-057: %s to %s is the same instance and keeps the key', async (before, after) => { await connect(USER, { url: before, apiKey: 'stored-key' }); await svc.saveSettings(USER, after, undefined, false, true, IP); expect((await row())?.api_key).toBe('stored-key'); expect((await row())?.url).toBe(after); }, ); it('DAWARICH-SVC-058: a key typed at the same time as the host change survives, because it was minted for the new host', async () => { await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); expect(await svc.getCredentials(USER)).toEqual({ baseUrl: 'https://new.example', apiKey: 'minted-for-new', allowInsecureTls: false, }); }); it('DAWARICH-SVC-059: an unparseable stored address compares as written, so retyping it unchanged keeps the key', async () => { // No scheme, so the URL constructor throws and the comparison falls back to // string equality. Erring towards keeping the key here is safe precisely // because the text did not change. await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); await svc.saveSettings(USER, 'dawarich.example', undefined, false, true, IP); expect((await row())?.api_key).toBe('stored-key'); }); it('DAWARICH-SVC-060: an unparseable stored address against a different one drops the key', async () => { await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); expect((await row())?.api_key).toBeNull(); }); it('DAWARICH-SVC-061: a row whose address column is null counts as no previous host, so nothing is dropped', async () => { await connect(USER, { url: null, apiKey: 'stored-key' }); await svc.saveSettings(USER, HOST, undefined, false, true, IP); expect((await row())?.api_key).toBe('stored-key'); expect((await row())?.url).toBe(HOST); }); it('DAWARICH-SVC-062: clearing the address wipes the key, because a live credential for a server nobody named is just a stored secret', async () => { await connect(USER, { apiKey: 'stored-key', lastSyncAt: '2026-09-12T04:15:00.000Z', lastSyncState: 'ok', lastSyncError: 'x', capabilities: JSON.stringify(FULL_CAPS), }); const out = await svc.saveSettings(USER, ' ', undefined, false, true, IP); expect(out).toEqual({ success: true }); const stored = await row(); expect(stored?.url).toBeNull(); expect(stored?.api_key).toBeNull(); expect(stored?.capabilities).toBeNull(); expect(stored?.last_sync_state).toBe('never'); expect(stored?.last_sync_at).toBeNull(); expect(stored?.last_sync_error).toBeNull(); }); it('DAWARICH-SVC-063: a key typed while the address is cleared is discarded with it', async () => { await connect(USER, { apiKey: 'stored-key' }); await svc.saveSettings(USER, '', 'pointless-key', false, true, IP); expect((await row())?.api_key).toBeNull(); }); it('DAWARICH-SVC-064: an empty address is never resolved, so clearing the connection cannot fail on DNS', async () => { await svc.saveSettings(USER, undefined, undefined, false, true, IP); expect(checkSsrf).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-065: an address that cannot work at all is refused with the guard reason and nothing is written', async () => { checkSsrf.mockResolvedValue({ allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' }); const out = await svc.saveSettings(USER, 'ftp://dawarich.example', 'k', false, true, IP); expect(out).toEqual({ success: false, code: 'invalid_url', error: 'Only HTTP and HTTPS URLs are allowed', }); expect(await row()).toBeNull(); expect(audit.writeAudit).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-066: a refusal without a reason still carries a sentence a caller reading the API directly can act on', async () => { checkSsrf.mockResolvedValue({ allowed: false, isPrivate: false }); expect(await svc.saveSettings(USER, 'http://nope.invalid', 'k', false, true, IP)).toEqual({ success: false, code: 'invalid_url', error: 'Invalid Dawarich URL', }); }); it('DAWARICH-SVC-067: a private address is saved with a warning and an audit line, not refused', async () => { checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.0.5' }); const out = await svc.saveSettings(USER, 'http://dawarich.lan:3000', 'k', false, true, '198.51.100.7'); expect(out).toEqual({ success: true, warning: expect.stringContaining('192.168.0.5'), warningCode: 'private_ip', warningIp: '192.168.0.5', }); expect((await row())?.url).toBe('http://dawarich.lan:3000'); expect(audit.writeAudit).toHaveBeenCalledWith({ userId: USER, action: 'dawarich.private_ip_configured', ip: '198.51.100.7', details: { dawarich_url: 'http://dawarich.lan:3000', resolved_ip: '192.168.0.5' }, }); }); it('DAWARICH-SVC-068: a private address the guard would also block is still saved, because a self-hosted LAN instance is the common case', async () => { // ALLOW_INTERNAL_NETWORK unset: checkSsrf says not allowed AND private. // Refusing here would break the majority of installs. checkSsrf.mockResolvedValue({ allowed: false, isPrivate: true, resolvedIp: '10.0.0.9', error: 'Requests to private/internal network addresses are not allowed.', }); const out = await svc.saveSettings(USER, 'http://10.0.0.9:3000', 'k', false, true, IP); expect(out).toMatchObject({ success: true, warningCode: 'private_ip', warningIp: '10.0.0.9' }); expect((await row())?.url).toBe('http://10.0.0.9:3000'); }); it('DAWARICH-SVC-069: a private result the resolver could not name still warns, with no IP in the warning payload', async () => { checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true }); const out = await svc.saveSettings(USER, 'http://dawarich.lan', 'k', false, true, null); expect(out).toMatchObject({ success: true, warningCode: 'private_ip' }); expect(out.warningIp).toBeUndefined(); expect(audit.writeAudit).toHaveBeenCalledTimes(1); }); it('DAWARICH-SVC-070: the address write and the key write are one transaction, so a failed key write cannot leave the old key pointed at the new host', async () => { await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); // Rollback proof (R7's transaction, one of the plan's eight): spy the // SECOND write (the key) to throw and confirm the FIRST write (the // address, already committed to the transactional connection) is rolled // back with it — proving `saveSettings`'s `uow.transactional` wraps both, // not just the address upsert. const spy = vi.spyOn(connections, 'setApiKey').mockRejectedValue(new Error('disk I/O error')); try { await expect(svc.saveSettings(USER, 'https://new.example', 'fresh', false, true, IP)).rejects.toThrow( 'disk I/O error', ); } finally { spy.mockRestore(); } const stored = await row(); expect(stored?.url).toBe('https://old.example'); expect(stored?.api_key).toBe('stored-key'); }); it("DAWARICH-SVC-071: both wipes name one user, so neither the host move nor the cleared address reaches anybody else's credential", async () => { // A missing WHERE on either UPDATE passes every single-user case in this // file and then logs the whole install out of Dawarich the first time one // person edits their own URL. Two victims, one bystander, both statements. const bystander = createUser(testDb).user.id; await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); await connect(bystander, { apiKey: 'bystander-key', capabilities: JSON.stringify(FULL_CAPS), lastSyncState: 'ok', }); await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); await svc.saveSettings(USER, '', undefined, false, true, IP); expect((await row())?.api_key).toBeNull(); expect(await row(bystander)).toMatchObject({ url: HOST, api_key: 'bystander-key', last_sync_state: 'ok', capabilities: JSON.stringify(FULL_CAPS), }); }); it('DAWARICH-SVC-072: a key typed with the host change keeps the key but still drops what the old instance answered', async () => { // The probe blob and the sync history describe the server the row used to // point at. Carrying them over would show the old instance's version and // "last sync ok" on a connection that has never been asked anything, and // the tracks layer would skip an endpoint the new instance does have. await connect(USER, { url: 'https://old.example', apiKey: 'stored-key', lastSyncAt: '2026-09-12T04:15:00.000Z', lastSyncState: 'ok', lastSyncError: 'rate_limited', capabilities: JSON.stringify(FULL_CAPS), }); await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); expect((await svc.getCredentials(USER))?.apiKey).toBe('minted-for-new'); const stored = await row(); expect(stored?.url).toBe('https://new.example'); expect(stored?.capabilities).toBeNull(); expect(stored?.last_sync_state).toBe('never'); expect(stored?.last_sync_error).toBeNull(); expect(stored?.last_sync_at).toBeNull(); }); it('DAWARICH-SVC-073: a new key on the same host keeps the probe, because it describes the host and not the key', async () => { await connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key', lastSyncState: 'ok', capabilities: JSON.stringify(FULL_CAPS), }); await svc.saveSettings(USER, 'https://d.example/', 'rotated-key', false, true, IP); expect((await svc.getCredentials(USER))?.apiKey).toBe('rotated-key'); expect((await row())?.capabilities).toBe(JSON.stringify(FULL_CAPS)); expect((await row())?.last_sync_state).toBe('ok'); }); }); // --------------------------------------------------------------------------- // disconnect // --------------------------------------------------------------------------- describe('DawarichService disconnect', () => { it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', async () => { await connect(USER); await svc.disconnect(USER, '198.51.100.7'); expect(await row()).toBeNull(); expect((await svc.getConnection(USER)).connected).toBe(false); expect(audit.writeAudit).toHaveBeenCalledWith({ userId: USER, action: 'dawarich.disconnected', ip: '198.51.100.7', details: {}, }); }); it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', async () => { await svc.disconnect(USER, null); expect(await row()).toBeNull(); expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ ip: null })); }); it('DAWARICH-SVC-082: only the calling user loses their connection', async () => { const other = createUser(testDb).user.id; await connect(USER); await connect(other); await svc.disconnect(USER, null); expect(await row(other)).not.toBeNull(); }); }); // --------------------------------------------------------------------------- // testConnection // --------------------------------------------------------------------------- describe('DawarichService testConnection', () => { it('DAWARICH-SVC-090: nothing typed and nothing stored answers not_connected without touching the network', async () => { expect(await svc.testConnection(USER, undefined, undefined, false)).toEqual({ connected: false, error: 'not_connected', }); expect(client.probe).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-091: an address with no key anywhere is not_connected, not a probe with an empty bearer token', async () => { expect(await svc.testConnection(USER, HOST, '', false)).toEqual({ connected: false, error: 'not_connected', }); expect(client.probe).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-092: a key typed with no address falls back to the stored address', async () => { await connect(USER, { apiKey: 'stored-key' }); await svc.testConnection(USER, '', 'typed-key', false); expect(client.probe).toHaveBeenCalledWith({ baseUrl: HOST, apiKey: 'typed-key', allowInsecureTls: false }); }); it('DAWARICH-SVC-093: the mask means "use the stored key", which is what a form that only changed the TLS flag posts back', async () => { await connect(USER, { apiKey: 'stored-key' }); await svc.testConnection(USER, HOST, DAWARICH_KEY_MASK, true); expect(client.probe).toHaveBeenCalledWith({ baseUrl: HOST, apiKey: 'stored-key', allowInsecureTls: true }); }); it('DAWARICH-SVC-094: a stored connection tested as it stands persists the freshly probed capabilities', async () => { await connect(USER, { apiKey: 'stored-key' }); const out = await svc.testConnection(USER, HOST, undefined, false); expect(out.connected).toBe(true); expect(await svc.getCapabilities(USER)).toEqual(out.capabilities); }); it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => { await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false); expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); it('DAWARICH-SVC-096: a test from a form with nothing stored yet writes no capabilities row', async () => { const out = await svc.testConnection(USER, HOST, 'typed-key', false); expect(out.connected).toBe(true); expect(await row()).toBeNull(); }); it('DAWARICH-SVC-097: the visit count is the last 30 days, and it is only asked for when the visits endpoint exists', async () => { await connect(USER, { apiKey: 'stored-key' }); allEndpointsAnswer([visit({ id: 1 }), visit({ id: 2 }), visit({ id: 3 })]); const out = await svc.testConnection(USER, HOST, undefined, false); expect(out.visitCount).toBe(3); // Call 0 is the 24h capability probe; call 1 is the count the card shows. const [, from, to] = client.listVisits.mock.calls[1] as [unknown, Date, Date]; expect(to.getTime() - from.getTime()).toBe(30 * DAY_MS); }); it('DAWARICH-SVC-098: an instance without a visits endpoint reports zero rather than asking a route that is not there', async () => { await connect(USER, { apiKey: 'stored-key' }); client.listVisits.mockRejectedValue(notFound()); const out = await svc.testConnection(USER, HOST, undefined, false); expect(out).toMatchObject({ connected: true, visitCount: 0 }); expect(out.capabilities?.visits).toBe(false); expect(client.listVisits).toHaveBeenCalledTimes(1); }); it('DAWARICH-SVC-099: a count that fails after the connection is already proven is not a failed connection', async () => { await connect(USER, { apiKey: 'stored-key' }); client.listVisits .mockResolvedValueOnce({ visits: [visit()], truncated: false, version: '1.14.4' }) .mockRejectedValueOnce(new DawarichError('rate_limited', 'HTTP 429', 429)); const out = await svc.testConnection(USER, HOST, undefined, false); expect(out).toMatchObject({ connected: true, visitCount: 0 }); }); it('DAWARICH-SVC-100: a rejected key is a 200 the form can render, carrying the code the client translates', async () => { await connect(USER, { apiKey: 'stored-key' }); client.probe.mockRejectedValue(new DawarichError('unauthorized', 'Dawarich answered HTTP 401', 401)); const out = await svc.testConnection(USER, HOST, undefined, false); expect(out).toEqual({ connected: false, error: 'unauthorized' }); expect(out).not.toHaveProperty('errorDetail'); }); it('DAWARICH-SVC-101: the free-text detail rides along when there is one, for the person debugging their own reverse proxy', async () => { await connect(USER, { apiKey: 'stored-key' }); client.probe.mockRejectedValue( new DawarichError('invalid_response', 'Not JSON', 200, 'login'), ); expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ connected: false, error: 'invalid_response', errorDetail: 'login', }); }); it('DAWARICH-SVC-102: anything that is not a DawarichError reads as unreachable rather than leaking the message', async () => { await connect(USER, { apiKey: 'stored-key' }); client.probe.mockRejectedValue(new Error('ECONNRESET on https://dawarich.example?api_key=secret')); expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ connected: false, error: 'unreachable', }); }); it('DAWARICH-SVC-103: a failed probe leaves the stored capabilities untouched', async () => { await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); await svc.testConnection(USER, HOST, undefined, false); expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); it('DAWARICH-SVC-104: a stored key that no longer decrypts is never dialled with, so the form hears "not connected" instead of a ciphertext going upstream as a bearer token', async () => { // The rotated-ENCRYPTION_KEY case reached from the outbound side rather than // through getCredentials: the row still reads as connected on the card, and // the test button has to say what is wrong without sending the blob anywhere. await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ connected: false, error: 'not_connected', }); expect(client.probe).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-105: a blank key field against a different host than the stored one is refused before anything is sent', async () => { // The form prefills the address and never the key, so "retype the URL and // press Test" is the ordinary way to move an instance. Falling back to the // stored key here would carry it to whatever host was just typed, which is // exactly what saveSettings refuses to persist. await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://someone-elses.example', undefined, false); expect(out).toMatchObject({ connected: false, error: 'not_connected' }); expect(out.errorDetail).toContain('https://old.example'); expect(client.probe).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-106: the mask against a different host is a blank field, not a key', async () => { await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://old.example:8443', DAWARICH_KEY_MASK, false); expect(out).toMatchObject({ connected: false, error: 'not_connected' }); expect(client.probe).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-107: a path edit on the same origin still tests with the stored key', async () => { // The mirror of 105: the same instance under a corrected path is where the // key was issued, and refusing it would make every trailing-slash edit // demand the key again. await connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://d.example/', undefined, false); expect(out.connected).toBe(true); expect(client.probe).toHaveBeenCalledWith({ baseUrl: 'https://d.example/', apiKey: 'stored-key', allowInsecureTls: false }); }); it('DAWARICH-SVC-108: a key typed for the new host is used as typed, so moving an instance and testing it first still works', async () => { await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://new.example', 'minted-for-new', false); expect(out.connected).toBe(true); expect(client.probe).toHaveBeenCalledWith({ baseUrl: 'https://new.example', apiKey: 'minted-for-new', allowInsecureTls: false }); }); }); // --------------------------------------------------------------------------- // probeCapabilities // --------------------------------------------------------------------------- describe('DawarichService probeCapabilities', () => { const CREDS = { baseUrl: HOST, apiKey: 'k', allowInsecureTls: false }; it('DAWARICH-SVC-110: every endpoint answering means every capability is on, with the version the instance reported', async () => { const caps = await svc.probeCapabilities(CREDS); expect(caps).toMatchObject({ visits: true, tracks: true, points: true, locations: true, visitedCities: true, serverVersion: '1.14.4', }); expect(Number.isNaN(Date.parse(caps.probedAt))).toBe(false); }); it('DAWARICH-SVC-111: an instance too old to send the version banner still connects, with a null version', async () => { client.probe.mockResolvedValue({ version: null }); expect((await svc.probeCapabilities(CREDS)).serverVersion).toBeNull(); }); it('DAWARICH-SVC-112: the probe window is a single day, the same one for every endpoint it asks', async () => { await svc.probeCapabilities(CREDS); const [, visitsFrom, visitsTo] = client.listVisits.mock.calls[0] as [unknown, Date, Date]; const [, tracksFrom, tracksTo] = client.listTracks.mock.calls[0] as [unknown, Date, Date]; expect(visitsTo.getTime() - visitsFrom.getTime()).toBe(DAY_MS); expect(tracksFrom.getTime()).toBe(visitsFrom.getTime()); expect(tracksTo.getTime()).toBe(visitsTo.getTime()); }); it('DAWARICH-SVC-113: the locations probe asks about a coordinate no recording will ever be near, because it is about the route answering', async () => { await svc.probeCapabilities(CREDS); expect(client.findVisitsNear).toHaveBeenCalledWith(CREDS, 0, 0, 50, 1); }); it.each(endpointCases)( 'DAWARICH-SVC-114: a 404 from %s turns off only %s, so an older Dawarich still connects', async (method, capability) => { client[method].mockRejectedValue(notFound()); const caps = await svc.probeCapabilities(CREDS); expect(caps[capability]).toBe(false); const others = PROBED_CAPABILITIES.filter((c) => c !== capability); for (const other of others) expect(caps[other]).toBe(true); }, ); it('DAWARICH-SVC-115: a rejected key during the probe propagates, because "connected, but nothing works" is the worse answer', async () => { client.listTracks.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); await expect(svc.probeCapabilities(CREDS)).rejects.toBeInstanceOf(DawarichError); }); it('DAWARICH-SVC-116: a plain transport failure during the probe propagates too', async () => { client.listPoints.mockRejectedValue(new Error('socket hang up')); await expect(svc.probeCapabilities(CREDS)).rejects.toThrow('socket hang up'); }); it('DAWARICH-SVC-117: a failing authentication check fails the whole probe, before any endpoint is asked', async () => { client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); await expect(svc.probeCapabilities(CREDS)).rejects.toBeInstanceOf(DawarichError); expect(client.listVisits).not.toHaveBeenCalled(); }); it('DAWARICH-SVC-118: no visit inside the probe window means the two field capabilities read as "not seen", not as "absent"', async () => { const caps = await svc.probeCapabilities(CREDS); expect(caps.visitUpdatedAt).toBe(false); expect(caps.visitCountryCode).toBe(false); }); it('DAWARICH-SVC-119: with no visits endpoint at all there is no row to sample, and both field capabilities stay off', async () => { client.listVisits.mockRejectedValue(notFound()); const caps = await svc.probeCapabilities(CREDS); expect(caps).toMatchObject({ visits: false, visitUpdatedAt: false, visitCountryCode: false }); }); it('DAWARICH-SVC-120: a sampled visit that carries updated_at lights the capability up on its own', async () => { allEndpointsAnswer([visit({ updated_at: '2026-09-11T08:00:00Z' })]); expect((await svc.probeCapabilities(CREDS)).visitUpdatedAt).toBe(true); }); it.each(updatedAtAbsentCases)('DAWARICH-SVC-121: an %s updated_at reads as absent', async (_label, over) => { allEndpointsAnswer([visit(over)]); expect((await svc.probeCapabilities(CREDS)).visitUpdatedAt).toBe(false); }); it('DAWARICH-SVC-122: a sampled place carrying a country code lights that capability up', async () => { allEndpointsAnswer([visit({ place: { latitude: 1, longitude: 2, id: 3, country_code: 'DE' } })]); expect((await svc.probeCapabilities(CREDS)).visitCountryCode).toBe(true); }); it.each(countryCodeAbsentCases)('DAWARICH-SVC-123: %s reads as no country code', async (_label, over) => { allEndpointsAnswer([visit(over)]); expect((await svc.probeCapabilities(CREDS)).visitCountryCode).toBe(false); }); }); // --------------------------------------------------------------------------- // storeCapabilities / recordSyncResult: the two columns the card reads back // --------------------------------------------------------------------------- describe('DawarichService storeCapabilities', () => { it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', async () => { await connect(USER); await svc.storeCapabilities(USER, FULL_CAPS); expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); expect(JSON.parse((await row())?.capabilities ?? 'null')).toEqual(FULL_CAPS); }); it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', async () => { await svc.storeCapabilities(USER, FULL_CAPS); expect(await row()).toBeNull(); }); it('DAWARICH-SVC-132: only the named user is touched', async () => { const other = createUser(testDb).user.id; await connect(USER); await connect(other); await svc.storeCapabilities(USER, FULL_CAPS); expect((await row(other))?.capabilities).toBeNull(); }); }); describe('DawarichService recordSyncResult', () => { it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', async () => { await connect(USER); const before = Date.now(); await svc.recordSyncResult(USER, 'failed', 'unreachable'); const out = await svc.getConnection(USER); expect(out.lastSyncState).toBe('failed'); expect(out.lastSyncError).toBe('unreachable'); expect(Date.parse(out.lastSyncAt ?? '')).toBeGreaterThanOrEqual(before - 1000); }); it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', async () => { await connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' }); await svc.recordSyncResult(USER, 'ok', null); expect(await svc.getConnection(USER)).toMatchObject({ lastSyncState: 'ok', lastSyncError: null }); }); it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', async () => { await connect(USER); await svc.recordSyncResult(USER, 'partial', 'too_large'); expect((await svc.getConnection(USER)).lastSyncState).toBe('partial'); }); it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', async () => { await svc.recordSyncResult(USER, 'ok', null); expect(await row()).toBeNull(); }); });