diff --git a/server/tests/unit/services/airtrailWriteGate.test.ts b/server/tests/unit/services/airtrailWriteGate.test.ts new file mode 100644 index 00000000..18405abd --- /dev/null +++ b/server/tests/unit/services/airtrailWriteGate.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * The #1240 write gate: pushReservationToAirtrail must NOT write to AirTrail unless + * the flight's owner has opted in (airtrail_write_enabled). Collaborators are mocked + * so the test exercises just the gate + payload wiring. + */ + +vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } })); +vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) })); +vi.mock('../../../src/services/auditLog', () => ({ logError: vi.fn(), logInfo: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); +vi.mock('../../../src/services/reservationService', () => ({ + getReservation: vi.fn(), + getReservationWithJoins: vi.fn(), + updateReservation: vi.fn(), +})); +vi.mock('../../../src/services/airtrail/airtrailClient', () => ({ + AirtrailAuthError: class AirtrailAuthError extends Error {}, + getFlight: vi.fn(), + listFlights: vi.fn(), + saveFlight: vi.fn(), +})); +vi.mock('../../../src/services/airtrail/airtrailMapper', () => ({ + canonicalHash: vi.fn(() => 'hash'), + mapFlightToReservation: vi.fn(() => ({})), + entityCode: (e: any) => e?.icao || e?.iata || null, +})); +vi.mock('../../../src/services/airtrail/airtrailService', () => ({ + isAirtrailWriteEnabled: vi.fn(), + getAirtrailCredentials: vi.fn(), +})); + +import { pushReservationToAirtrail } from '../../../src/services/airtrail/airtrailSync'; +import { db } from '../../../src/db/database'; +import { getReservationWithJoins } from '../../../src/services/reservationService'; +import { getFlight, saveFlight } from '../../../src/services/airtrail/airtrailClient'; +import { isAirtrailWriteEnabled, getAirtrailCredentials } from '../../../src/services/airtrail/airtrailService'; + +const linkedRow = { id: 5, trip_id: 9, external_id: '42', external_owner_user_id: 7, sync_enabled: 1 }; +const runSpy = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + // Route db reads: global sync setting + the linked reservation row. + (db.prepare as any).mockImplementation((sql: string) => ({ + get: () => { + if (sql.includes('app_settings')) return { value: 'true' }; + if (sql.includes('FROM reservations')) return { ...linkedRow }; + return undefined; + }, + run: (...args: any[]) => { + runSpy(sql, args); + return {}; + }, + all: () => [], + })); + (getAirtrailCredentials as any).mockReturnValue({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false }); + // GET returns AirTrail-owned detail TREK doesn't model — must survive the writeback. + (getFlight as any).mockResolvedValue({ id: 42, from: { iata: 'JFK' }, to: { iata: 'LHR' }, seats: [], departureTerminal: '7' }); + (saveFlight as any).mockResolvedValue({ id: 42 }); + (getReservationWithJoins as any).mockReturnValue({ + external_id: '42', + reservation_time: '2021-09-01T19:00', + reservation_end_time: '2021-09-02T08:00', + notes: 'note', + metadata: JSON.stringify({}), + endpoints: [ + { role: 'from', code: 'JFK' }, + { role: 'to', code: 'LHR' }, + ], + }); +}); + +describe('pushReservationToAirtrail write gate (#1240)', () => { + it('does nothing — and does not detach — when the owner has not opted in', async () => { + (isAirtrailWriteEnabled as any).mockReturnValue(false); + await pushReservationToAirtrail(5, 9); + expect(getFlight).not.toHaveBeenCalled(); + expect(saveFlight).not.toHaveBeenCalled(); + expect(runSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op + }); + + it('writes back, preserving AirTrail-owned fields, when the owner has opted in', async () => { + (isAirtrailWriteEnabled as any).mockReturnValue(true); + await pushReservationToAirtrail(5, 9); + expect(saveFlight).toHaveBeenCalledTimes(1); + const payload = (saveFlight as any).mock.calls[0][1]; + expect(payload.departureTerminal).toBe('7'); // spread preserved the unmanaged field + expect(payload.from).toBe('JFK'); // TREK-managed field still applied as a code + }); +}); diff --git a/server/tests/unit/services/airtrailWriteback.test.ts b/server/tests/unit/services/airtrailWriteback.test.ts new file mode 100644 index 00000000..679f8156 --- /dev/null +++ b/server/tests/unit/services/airtrailWriteback.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Avoid any real DNS/network from the SSRF guard during saveSettings. +vi.mock('../../../src/utils/ssrfGuard', () => ({ + checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false })), + safeFetch: vi.fn(), +})); + +import { db } from '../../../src/db/database'; +import { createUser } from '../../helpers/factories'; +import { + getConnectionSettings, + isAirtrailWriteEnabled, + saveSettings, +} from '../../../src/services/airtrail/airtrailService'; + +describe('airtrail writeback opt-in persistence (#1240)', () => { + it('defaults the writeback opt-in to off for a new user', () => { + const { user } = createUser(db); + expect(isAirtrailWriteEnabled(user.id)).toBe(false); + expect(getConnectionSettings(user.id).writeEnabled).toBe(false); + }); + + it('persists the opt-in and lets it be toggled back off without dropping the key', async () => { + const { user } = createUser(db); + + await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, true, null); + expect(isAirtrailWriteEnabled(user.id)).toBe(true); + const on = getConnectionSettings(user.id); + expect(on.writeEnabled).toBe(true); + expect(on.connected).toBe(true); // key stored + + // No key supplied keeps the stored key; only the opt-in flips back off. + await saveSettings(user.id, 'https://at.example.com', undefined, false, false, null); + expect(isAirtrailWriteEnabled(user.id)).toBe(false); + expect(getConnectionSettings(user.id).connected).toBe(true); + }); +});