test(server): seed the days integration, OIDC and share service specs through the factories

The days integration suite, the OIDC service suite and the share service
suite write their fixtures and read back their effects through the ORM
factories: invite tokens through makeInviteToken, user column edits
through updateRows, bookings, stays, packing items and messages through
insertRow on their entities. Two more database mocks drop their dead
canAccessTrip/isOwner stand-ins.

The OIDC identity-linking cases keep their raw SELECT * of the user row,
marked: they assert that no column of the table moved, including any the
entity might not map.
This commit is contained in:
Maurice
2026-10-08 16:48:22 +02:00
parent 5947631db6
commit 58cdaf828c
3 changed files with 299 additions and 282 deletions
+79 -73
View File
@@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import { MikroORM } from '@mikro-orm/core';
// ─────────────────────────────────────────────────────────────────────────────
// In-memory DB — schema applied in beforeAll after mocks register
@@ -30,9 +31,32 @@ import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAccommodation, createReservation } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import type { FactoryOrm } from '../helpers/factories/context';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { Reservations } from '../../src/db/entities/Reservations.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
let nestApp: INestApplication;
let app: Application;
/** The app's own ORM, which the factories seed and read through. */
const orm = (): FactoryOrm => nestApp.get(MikroORM);
const dayCount = (tripId: number) => countRows(orm(), Days, { trip: tripId });
/** The trip's days in day order: id, number and date, as the reorder cases compare them. */
const dayList = async (tripId: number) =>
(await findRows(orm(), Days, { trip: tripId }, { day_number: 'asc' })).map((d) => ({ id: d.id, day_number: d.day_number, date: d.date }));
/** The stay's check-in and check-out days. */
const stayDays = async (id: number) => {
const stay = await findRow(orm(), DayAccommodations, { id });
return { start_day_id: stay?.start_day_id, end_day_id: stay?.end_day_id };
};
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
@@ -181,7 +205,7 @@ describe('Create day', () => {
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' });
expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 });
expect(await dayCount(trip.id)).toBe(1);
});
it('DAY-013: dated next to a position is refused by the contract with 400 and adds nothing', async () => {
@@ -195,8 +219,8 @@ describe('Create day', () => {
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain('dated cannot be combined with date or position');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 2 });
expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-08-02' });
expect(await dayCount(trip.id)).toBe(2);
expect((await findRow(orm(), Trips, { id: trip.id }))?.end_date).toBe('2026-08-02');
});
});
@@ -307,8 +331,7 @@ describe('Delete day', () => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id);
expect(deleted).toBeUndefined();
expect(await findRow(orm(), Days, { id: day.id })).toBeNull();
});
it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => {
@@ -341,7 +364,7 @@ describe('Delete day', () => {
it('DAY-008: deleting a dated day with no day left to take the last date ends the trip a day earlier', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' });
const first = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 1').get(trip.id) as { id: number };
const [first] = await dayList(trip.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${first.id}`)
@@ -366,7 +389,7 @@ describe('Delete day', () => {
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'A trip needs at least one day.' });
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
expect(await findRow(orm(), Days, { id: day.id })).not.toBeNull();
});
it('DAY-010: a member without day_edit gets 403 and the day stays', async () => {
@@ -376,7 +399,7 @@ describe('Delete day', () => {
addTripMember(testDb, trip.id, member.id);
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
await setAppSetting(orm(), 'perm_day_edit', 'trip_owner');
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
await invalidatePermissionsCache();
try {
@@ -384,9 +407,9 @@ describe('Delete day', () => {
.delete(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
expect(await findRow(orm(), Days, { id: day.id })).not.toBeNull();
} finally {
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
await deleteRows(orm(), AppSettings, { key: 'perm_day_edit' });
await invalidatePermissionsCache();
}
});
@@ -459,9 +482,7 @@ describe('Accommodations', () => {
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
// Seed accommodation directly
testDb.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
).run(trip.id, place.id, day1.id, day2.id);
await insertRow(orm(), DayAccommodations, { trip: trip.id, place: place.id, startDay: day1.id, endDay: day2.id });
const res = await request(app)
.get(`/api/trips/${trip.id}/accommodations`)
@@ -509,8 +530,7 @@ describe('Accommodations', () => {
expect(deleteRes.body.success).toBe(true);
// Verify removed from DB
const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId);
expect(row).toBeUndefined();
expect(await findRow(orm(), DayAccommodations, { id: accommodationId })).toBeNull();
});
it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => {
@@ -540,12 +560,10 @@ describe('Accommodations', () => {
expect(res.status).toBe(201);
// Linked reservation should exist
const reservation = testDb.prepare(
'SELECT * FROM reservations WHERE accommodation_id = ?'
).get(res.body.accommodation.id) as any;
expect(reservation).toBeDefined();
expect(reservation.type).toBe('hotel');
expect(reservation.confirmation_number).toBe('CONF-XYZ');
const reservation = await findRow(orm(), Reservations, { accommodation_id: String(res.body.accommodation.id) });
expect(reservation).not.toBeNull();
expect(reservation!.type).toBe('hotel');
expect(reservation!.confirmation_number).toBe('CONF-XYZ');
});
it('ACCOM-004 — PUT /api/trips/:tripId/accommodations/:id updates the accommodation', async () => {
@@ -601,20 +619,15 @@ describe('Accommodations', () => {
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
const accommodationId = createRes.body.accommodation.id;
const reservationBefore = testDb.prepare(
'SELECT id FROM reservations WHERE accommodation_id = ?'
).get(accommodationId) as any;
expect(reservationBefore).toBeDefined();
const reservationBefore = await findRow(orm(), Reservations, { accommodation_id: String(accommodationId) });
expect(reservationBefore).not.toBeNull();
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
const reservationAfter = testDb.prepare(
'SELECT id FROM reservations WHERE id = ?'
).get(reservationBefore.id);
expect(reservationAfter).toBeUndefined();
expect(await findRow(orm(), Reservations, { id: reservationBefore!.id })).toBeNull();
});
it('ACCOM-006 — DELETE accommodation also removes its linked budget item (issue #933)', async () => {
@@ -637,26 +650,19 @@ describe('Accommodations', () => {
});
expect(createRes.status).toBe(201);
const accommodationId = testDb.prepare(
'SELECT id FROM day_accommodations WHERE trip_id = ?'
).get(trip.id) as any;
expect(accommodationId).toBeDefined();
const accommodationId = await findRow(orm(), DayAccommodations, { trip: trip.id });
expect(accommodationId).not.toBeNull();
const budgetBefore = testDb.prepare(
'SELECT id FROM budget_items WHERE trip_id = ?'
).get(trip.id);
expect(budgetBefore).toBeDefined();
const budgetBefore = await findRow(orm(), BudgetItems, { trip: trip.id });
expect(budgetBefore).not.toBeNull();
// Delete via the accommodation endpoint (the primary bug path)
const delRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId.id}`)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId!.id}`)
.set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200);
const budgetAfter = testDb.prepare(
'SELECT id FROM budget_items WHERE trip_id = ?'
).get(trip.id);
expect(budgetAfter).toBeUndefined();
expect(await findRow(orm(), BudgetItems, { trip: trip.id })).toBeNull();
});
});
@@ -702,15 +708,15 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ title: 'Renamed' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Day not found' });
const row = testDb.prepare('SELECT title FROM days WHERE id = ?').get(day.id) as { title: string };
expect(row.title).toBe('Original');
const row = await findRow(orm(), Days, { id: day.id });
expect(row?.title).toBe('Original');
});
it('POST /days by the hex-spelled trip id (append, no position) answers the guard\'s own 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2 — was a manufactured 500)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const hexTripId = '0x' + trip.id.toString(16);
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${hexTripId}/days`)
@@ -718,7 +724,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -726,7 +732,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const hexTripId = '0x' + trip.id.toString(16);
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${hexTripId}/days`)
@@ -734,7 +740,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -742,7 +748,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}.0`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -750,7 +756,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -758,7 +764,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}.0`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -766,7 +772,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -774,7 +780,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}%20`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -782,7 +788,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -790,7 +796,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}%20`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -798,7 +804,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -806,7 +812,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `%2B${trip.id}`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -814,7 +820,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -822,7 +828,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `%2B${trip.id}`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -830,7 +836,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -838,7 +844,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}e0`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -846,7 +852,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
@@ -854,7 +860,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}e0`;
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
@@ -862,14 +868,14 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('PUT /days/reorder by the hex-spelled trip id 400s "orderedIds must be a permutation…" and writes NOTHING — the live H2 bug: it used to 200 and invert a real accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
const days = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
const days = await dayList(trip.id);
expect(days).toHaveLength(3);
const [d1, d2, d3] = days;
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
@@ -877,7 +883,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
// the shape the compiled-boot evidence used to catch reorder inverting.
const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id);
const reservation = createReservation(testDb, trip.id, { day_id: d1.id, title: 'Dinner' });
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-06-01T19:00:00', reservation.id);
await updateRows(orm(), Reservations, { id: reservation.id }, { reservation_time: '2026-06-01T19:00:00' });
const hexTripId = '0x' + trip.id.toString(16);
const res = await request(app)
@@ -888,20 +894,20 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
expect(res.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
// Nothing renumbered.
const after = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
const after = await dayList(trip.id);
expect(after).toEqual(days);
// The stay is still d1 -> d2, never inverted.
const stayAfter = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id) as { start_day_id: number; end_day_id: number };
const stayAfter = await stayDays(stay.id);
expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d2.id });
// The reservation's stamped time is untouched.
const resAfter = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(reservation.id) as { reservation_time: string };
expect(resAfter.reservation_time).toBe('2026-06-01T19:00:00');
const resAfter = await findRow(orm(), Reservations, { id: reservation.id });
expect(resAfter?.reservation_time).toBe('2026-06-01T19:00:00');
});
it('PUT /days/reorder with the REAL numeric trip id still works and can still be legitimately rejected for inverting a stay (unchanged, base 400) — L-5: zero days rows changed, not only the stay', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
const days = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
const days = await dayList(trip.id);
const [d1, d2, d3] = days;
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d3.id);
@@ -912,10 +918,10 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ orderedIds: [d3.id, d2.id, d1.id] });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This move would make an accommodation end before it starts.' });
const stayAfter = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id) as { start_day_id: number; end_day_id: number };
const stayAfter = await stayDays(stay.id);
expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d3.id });
// L-5: the rollback holds on every day row, not only the stay — zero rows changed.
const daysAfter = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
const daysAfter = await dayList(trip.id);
expect(daysAfter).toEqual(days);
});
});
+97 -106
View File
@@ -19,21 +19,8 @@ vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return mock;
// Trip access reads through TripsRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
@@ -116,6 +103,34 @@ import {
createTestTripMembersRepo,
} from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import { findRow, updateRows } from '../../helpers/factories/rows';
import { readUser } from '../../helpers/factories/users';
import { setAppSetting } from '../../helpers/factories/settings';
import { makeInviteToken } from '../../helpers/factories/tokens';
import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity';
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
import { Users } from '../../../src/db/entities/Users.entity';
const orm = () => sharedTestOrm(testDb);
/** Writes columns of the user row directly, the state a login starts from. */
async function setUserColumns(
userId: number,
data: { oidc_sub?: string | null; oidc_issuer?: string | null; avatar?: string | null; updated_at?: string },
): Promise<void> {
await updateRows(await orm(), Users, { id: userId }, data);
}
async function userByEmail(email: string) {
return findRow(await orm(), Users, { email });
}
/** How often the invite was redeemed; fails the case when it is gone. */
async function usedCount(token: string): Promise<number | null | undefined> {
const row = await findRow(await orm(), InviteTokens, { token });
if (!row) throw new Error(`no invite ${token}`);
return row.used_count;
}
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
@@ -520,8 +535,7 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-020: finds existing user by oidc_sub', async () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-alice-123', oidc_issuer: MOCK_CONFIG.issuer });
const result = await svc.findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
@@ -548,8 +562,7 @@ describe('findOrCreateUser', () => {
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
expect(await userByEmail('newuser@example.com')).not.toBeNull();
});
it('OIDC-SVC-023: first user gets admin role', async () => {
@@ -595,7 +608,7 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-022e: a later login never renames the account to the claim', async () => {
const { user } = createUser(testDb, { email: 'kept@example.com', username: 'chosen' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run('sub-kept', MOCK_CONFIG.issuer, user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-kept', oidc_issuer: MOCK_CONFIG.issuer });
process.env.OIDC_USERNAME_CLAIM = 'preferred_username';
const result = await svc.findOrCreateUser(
{ sub: 'sub-kept', email: 'kept@example.com', name: 'Kept', preferred_username: 'idp-name' },
@@ -606,7 +619,7 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-024: returns registration_disabled error when registration is off', async () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
await setAppSetting(await orm(), 'allow_registration', 'false');
const result = await svc.findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
@@ -619,20 +632,20 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', async () => {
const { user } = createUser(testDb, { email: 'charlie@example.com' });
// Ensure no oidc_sub set
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null });
await svc.findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true },
MOCK_CONFIG
);
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
const updated = await readUser(await orm(), user.id);
expect(updated.oidc_sub).toBe('sub-charlie-linked');
});
it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', async () => {
const { user } = createUser(testDb, { email: 'dora@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null });
// No email_verified claim — an IdP that lets users set arbitrary emails must
// not be able to take over a pre-existing password account.
@@ -643,15 +656,14 @@ describe('findOrCreateUser', () => {
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('email_not_verified');
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
const updated = await readUser(await orm(), user.id);
expect(updated.oidc_sub).toBeNull(); // account not linked / not hijacked
});
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', async () => {
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-diana-role', oidc_issuer: MOCK_CONFIG.issuer });
process.env.OIDC_ADMIN_VALUE = 'admins';
@@ -663,15 +675,12 @@ describe('findOrCreateUser', () => {
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
expect(dbUser.role).toBe('admin');
expect((await readUser(await orm(), user.id)).role).toBe('admin');
});
it('OIDC-SVC-027: new user with valid invite token increments used_count', async () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
await makeInviteToken(await orm(), creator.id, { token: 'tok-valid', max_uses: 5, used_count: 0 });
const result = await svc.findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
@@ -681,15 +690,12 @@ describe('findOrCreateUser', () => {
expect('user' in result).toBe(true);
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
expect(token.used_count).toBe(1);
expect(await usedCount('tok-valid')).toBe(1);
});
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', async () => {
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
).run(creator.id);
await makeInviteToken(await orm(), creator.id, { token: 'tok-expired', max_uses: 5, used_count: 0, expires_at: '2000-01-01T00:00:00.000Z' });
const result = await svc.findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
@@ -699,19 +705,15 @@ describe('findOrCreateUser', () => {
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
expect(newUser).toBeDefined();
expect(await userByEmail('expired-invitee@example.com')).not.toBeNull();
// Invite used_count must remain 0 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
expect(token.used_count).toBe(0);
expect(await usedCount('tok-expired')).toBe(0);
});
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', async () => {
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
await makeInviteToken(await orm(), creator.id, { token: 'tok-full', max_uses: 1, used_count: 1 });
const result = await svc.findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
@@ -721,12 +723,10 @@ describe('findOrCreateUser', () => {
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
expect(newUser).toBeDefined();
expect(await userByEmail('full-invitee@example.com')).not.toBeNull();
// Invite used_count must remain 1 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
expect(token.used_count).toBe(1);
expect(await usedCount('tok-full')).toBe(1);
});
// ── OIDC picture claim → avatar (#1399) ──────────────────────────────────
@@ -737,8 +737,7 @@ describe('findOrCreateUser', () => {
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic1@example.com'").get() as any;
expect(row.avatar).toBe('https://idp.example.com/u/pic1.png');
expect((await userByEmail('pic1@example.com'))?.avatar).toBe('https://idp.example.com/u/pic1.png');
});
it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', async () => {
@@ -746,43 +745,41 @@ describe('findOrCreateUser', () => {
{ sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' },
MOCK_CONFIG
);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic2@example.com'").get() as any;
expect(row.avatar).toBeNull();
const row = await userByEmail('pic2@example.com');
expect(row).not.toBeNull();
expect(row!.avatar).toBeNull();
});
it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', async () => {
const { user } = createUser(testDb, { email: 'pic3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?')
.run('sub-pic-3', MOCK_CONFIG.issuer, user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-pic-3', oidc_issuer: MOCK_CONFIG.issuer, avatar: null });
await svc.findOrCreateUser(
{ sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
const row = await readUser(await orm(), user.id);
expect(row.avatar).toBe('https://idp.example.com/u/pic3.png');
});
it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', async () => {
const { user } = createUser(testDb, { email: 'pic4@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-pic-4', oidc_issuer: MOCK_CONFIG.issuer, avatar: 'uploaded-abc.jpg' });
await svc.findOrCreateUser(
{ sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
const row = await readUser(await orm(), user.id);
expect(row.avatar).toBe('uploaded-abc.jpg');
});
it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', async () => {
const { user } = createUser(testDb, { email: 'pic5@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-pic-5', oidc_issuer: MOCK_CONFIG.issuer, avatar: 'https://idp.example.com/u/old.png' });
await svc.findOrCreateUser(
{ sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
const row = await readUser(await orm(), user.id);
expect(row.avatar).toBe('https://idp.example.com/u/new.png');
});
@@ -792,15 +789,14 @@ describe('findOrCreateUser', () => {
// a sub/issuer pair pinning the account to a provider that is gone.
it('OIDC-SVC-060: a provider without a picture claim clears the previous provider avatar', async () => {
const { user } = createUser(testDb, { email: 'switch1@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-old-1', 'https://old-idp.example.com', 'https://old-idp.example.com/u/me.png', user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-old-1', oidc_issuer: 'https://old-idp.example.com', avatar: 'https://old-idp.example.com/u/me.png' });
await svc.findOrCreateUser(
{ sub: 'sub-new-1', email: 'switch1@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
const row = await readUser(await orm(), user.id);
expect(row.avatar).toBeNull();
});
@@ -824,29 +820,27 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-061: an uploaded avatar survives the same switch', async () => {
const { user } = createUser(testDb, { email: 'switch2@example.com' });
// A local upload is a bare filename, not a URL, and belongs to the user.
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-old-2', 'https://old-idp.example.com', 'uploaded-abc.jpg', user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-old-2', oidc_issuer: 'https://old-idp.example.com', avatar: 'uploaded-abc.jpg' });
await svc.findOrCreateUser(
{ sub: 'sub-new-2', email: 'switch2@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
const row = await readUser(await orm(), user.id);
expect(row.avatar).toBe('uploaded-abc.jpg');
});
it('OIDC-SVC-062: the account is relinked to the new provider sub and issuer', async () => {
const { user } = createUser(testDb, { email: 'switch3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-old-3', 'https://old-idp.example.com', user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-old-3', oidc_issuer: 'https://old-idp.example.com' });
await svc.findOrCreateUser(
{ sub: 'sub-new-3', email: 'switch3@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
const row = testDb.prepare('SELECT oidc_sub, oidc_issuer FROM users WHERE id = ?').get(user.id) as any;
const row = await readUser(await orm(), user.id);
expect(row.oidc_sub).toBe('sub-new-3');
expect(row.oidc_issuer).toBe('https://new-idp.example.com');
});
@@ -858,13 +852,14 @@ describe('findOrCreateUser', () => {
// exists to foreclose (task-1-fix-report.md).
it('OIDC-SVC-090: identity linking (O5) leaves the rest of the users row byte-identical', async () => {
const { user } = createUser(testDb, { email: 'linkrow@example.com', username: 'linkrowuser' });
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null });
// Task 6 review L1 / Task 7 review, Task 6 L1: pin updated_at to a fixed
// past value before the snapshot so a future `linkOidcIdentity` that
// starts stamping updated_at (a CURRENT_TIMESTAMP drift) makes this test
// fail loudly, instead of the before/after snapshot racing the mutation
// inside the same second and passing by accident.
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
await setUserColumns(user.id, { updated_at: '2020-01-01 00:00:00' });
// test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move.
const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
await svc.findOrCreateUser(
@@ -872,16 +867,17 @@ describe('findOrCreateUser', () => {
MOCK_CONFIG,
);
// test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move.
const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
expect(after).toEqual({ ...before, oidc_sub: 'sub-linkrow', oidc_issuer: MOCK_CONFIG.issuer });
});
it('OIDC-SVC-091: the provider-switch branch (O6) leaves the rest of the users row byte-identical', async () => {
const { user } = createUser(testDb, { email: 'switchrow@example.com', username: 'switchrowuser' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-old-row', 'https://old-idp.example.com', user.id);
await setUserColumns(user.id, { oidc_sub: 'sub-old-row', oidc_issuer: 'https://old-idp.example.com' });
// Task 6 review L1 / Task 7 review, Task 6 L1: see OIDC-SVC-090's comment.
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
await setUserColumns(user.id, { updated_at: '2020-01-01 00:00:00' });
// test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move.
const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
await svc.findOrCreateUser(
@@ -889,6 +885,7 @@ describe('findOrCreateUser', () => {
{ ...MOCK_CONFIG, issuer: 'https://new-idp-row.example.com' },
);
// test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move.
const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
expect(after).toEqual({ ...before, oidc_sub: 'sub-new-row', oidc_issuer: 'https://new-idp-row.example.com' });
});
@@ -902,9 +899,7 @@ describe('findOrCreateUser', () => {
// serialization, one winner.
it('OIDC-SVC-092: two concurrent SSO registrations racing the same one-use invite — the loser gets registration_disabled, byte-identical to the legacy invite_exhausted outcome', async () => {
const { user: creator } = createUser(testDb, { email: 'racecreator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-race', 1, 0, ?)"
).run(creator.id);
await makeInviteToken(await orm(), creator.id, { token: 'tok-race', max_uses: 1, used_count: 0 });
const [first, second] = await Promise.all([
svc.findOrCreateUser({ sub: 'sub-race-1', email: 'racer1@example.com', name: 'Racer One' }, MOCK_CONFIG, 'tok-race'),
@@ -918,8 +913,7 @@ describe('findOrCreateUser', () => {
expect(losers).toHaveLength(1);
expect(losers[0]!.error).toBe('registration_disabled');
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-race'").get() as { used_count: number };
expect(token.used_count).toBe(1);
expect(await usedCount('tok-race')).toBe(1);
});
it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', async () => {
@@ -945,9 +939,7 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', async () => {
const { user: admin } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, admin.id);
testDb.prepare(
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)'
).run('inv-trip-join', admin.id, trip.id);
await makeInviteToken(await orm(), admin.id, { token: 'inv-trip-join', max_uses: 5, used_count: 0, expires_at: null, trip: trip.id });
const result = await svc.findOrCreateUser(
{ sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' },
@@ -956,8 +948,7 @@ describe('findOrCreateUser', () => {
);
expect('user' in result).toBe(true);
const uid = (result as { user: any }).user.id;
const member = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, uid);
expect(member).toBeTruthy();
expect(await findRow(await orm(), TripMembers, { trip: trip.id, user: uid })).toBeTruthy();
});
});
@@ -966,19 +957,19 @@ describe('findOrCreateUser', () => {
describe('findOrCreateUser role mapping', () => {
type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } };
function ssoUser(email: string, sub: string, role: 'admin' | 'user') {
async function ssoUser(email: string, sub: string, role: 'admin' | 'user') {
const { user } = createUser(testDb, { email, role });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, MOCK_CONFIG.issuer, user.id);
await setUserColumns(user.id, { oidc_sub: sub, oidc_issuer: MOCK_CONFIG.issuer });
return user;
}
const storedRole = (id: number) => (testDb.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string }).role;
const storedRole = async (id: number) => (await readUser(await orm(), id)).role;
it('OIDC-SVC-063: an admin keeps the role when the configured claim is absent from the payload', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
// A second admin row, so the #1274 last-admin guard cannot be what saves them.
createUser(testDb, { email: 'bootstrap@example.com', role: 'admin' });
const user = ssoUser('sso-admin@example.com', 'sub-keep-admin', 'admin');
const user = await ssoUser('sso-admin@example.com', 'sub-keep-admin', 'admin');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
@@ -988,7 +979,7 @@ describe('findOrCreateUser role mapping', () => {
)) as RoleResult;
expect(result.user.role).toBe('admin');
expect(storedRole(user.id)).toBe('admin');
expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined();
} finally {
warn.mockRestore();
@@ -998,7 +989,7 @@ describe('findOrCreateUser role mapping', () => {
it('OIDC-SVC-064: an absent claim does not promote a plain user either', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('plain@example.com', 'sub-keep-user', 'user');
const user = await ssoUser('plain@example.com', 'sub-keep-user', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
@@ -1008,7 +999,7 @@ describe('findOrCreateUser role mapping', () => {
)) as RoleResult;
expect(result.user.role).toBe('user');
expect(storedRole(user.id)).toBe('user');
expect(await storedRole(user.id)).toBe('user');
expect(result.roleChange).toBeUndefined();
} finally {
warn.mockRestore();
@@ -1018,14 +1009,14 @@ describe('findOrCreateUser role mapping', () => {
it('OIDC-SVC-065: a custom claim that does arrive still promotes, and reports the change', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('promote@example.com', 'sub-promote', 'user');
const user = await ssoUser('promote@example.com', 'sub-promote', 'user');
const result = (await svc.findOrCreateUser(
{ sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('admin');
expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toEqual({ from: 'user', to: 'admin', claim: 'entitlements' });
});
@@ -1033,21 +1024,21 @@ describe('findOrCreateUser role mapping', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
createUser(testDb, { email: 'bootstrap2@example.com', role: 'admin' });
const user = ssoUser('demote@example.com', 'sub-demote', 'admin');
const user = await ssoUser('demote@example.com', 'sub-demote', 'admin');
const result = (await svc.findOrCreateUser(
{ sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('user');
expect(await storedRole(user.id)).toBe('user');
expect(result.roleChange).toEqual({ from: 'admin', to: 'user', claim: 'entitlements' });
});
it('OIDC-SVC-067: the only admin is still kept when the claim arrives without the admin value (#1274)', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('lonely@example.com', 'sub-lonely', 'admin');
const user = await ssoUser('lonely@example.com', 'sub-lonely', 'admin');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
@@ -1056,7 +1047,7 @@ describe('findOrCreateUser role mapping', () => {
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('admin');
expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined();
expect(warn.mock.calls[0][0]).toContain('only admin');
} finally {
@@ -1067,7 +1058,7 @@ describe('findOrCreateUser role mapping', () => {
it('OIDC-SVC-068: for a plain user the absent-claim warning names the claim and the keys that arrived, never their values, and fires once', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'trek_roles';
ssoUser('warned@example.com', 'sub-warned', 'user');
await ssoUser('warned@example.com', 'sub-warned', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// The warning is deduped per claim name for the life of the process, so this
// case needs an instance that has not seen the claim yet.
@@ -1103,7 +1094,7 @@ describe('findOrCreateUser role mapping', () => {
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(result.user.id)).toBe('user');
expect(await storedRole(result.user.id)).toBe('user');
expect(result.roleChange).toBeUndefined();
expect(warn.mock.calls[0][0]).toContain('during registration');
} finally {
@@ -1114,14 +1105,14 @@ describe('findOrCreateUser role mapping', () => {
it('OIDC-SVC-070: a claim that already agrees with the stored role reports no change', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('steady@example.com', 'sub-steady', 'admin');
const user = await ssoUser('steady@example.com', 'sub-steady', 'admin');
const result = (await svc.findOrCreateUser(
{ sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('admin');
expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined();
});
@@ -1142,7 +1133,7 @@ describe('findOrCreateUser role mapping', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles';
createUser(testDb, { email: 'bootstrap3@example.com', role: 'admin' });
const user = ssoUser('kept@example.com', 'sub-kept-admin', 'admin');
const user = await ssoUser('kept@example.com', 'sub-kept-admin', 'admin');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
@@ -1161,7 +1152,7 @@ describe('findOrCreateUser role mapping', () => {
expect(line).not.toContain('authentik Admins');
expect(line).not.toContain('kept@example.com');
}
expect(storedRole(user.id)).toBe('admin');
expect(await storedRole(user.id)).toBe('admin');
} finally {
warn.mockRestore();
}
@@ -1171,8 +1162,8 @@ describe('findOrCreateUser role mapping', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'shared_roles';
createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' });
ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin');
ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user');
await ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin');
await ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const fresh = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb));
+123 -103
View File
@@ -12,24 +12,10 @@ import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/plac
// ── DB setup ──────────────────────────────────────────────────────────────────
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return mock;
// Trip access reads through TripsRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
@@ -60,6 +46,20 @@ import type { User } from '../../../src/types';
import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo } from '../../helpers/test-uow';
import { shareServiceRepoArgs } from '../../helpers/share-repos';
import type { TestOrm } from '../../helpers/test-orm';
import type { EntityData, RequiredEntityData } from '@mikro-orm/core';
import { deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
import { setAppSetting, setUserSetting } from '../../helpers/factories/settings';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { CollabMessages } from '../../../src/db/entities/CollabMessages.entity';
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { ReservationDayPositions } from '../../../src/db/entities/ReservationDayPositions.entity';
import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
let svc: ShareService;
let t: TestOrm;
@@ -92,7 +92,12 @@ afterAll(async () => {
});
function shareRow(tripId: number | string) {
return testDb.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
return findRow(t, ShareTokens, { trip: Number(tripId) });
}
/** A reservation on the trip with exactly the given columns; returns its id. */
function addReservation(tripId: number, columns: EntityData<Reservations>): Promise<number> {
return insertRow(t, Reservations, { trip: tripId, ...columns } as RequiredEntityData<Reservations>);
}
/** Owner + trip + a share link with all five flags on unless overridden. */
@@ -142,7 +147,7 @@ describe('createOrUpdate', () => {
const result = await svc.createOrUpdate(String(trip.id), user.id, {});
expect(result.created).toBe(true);
expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
const row = shareRow(trip.id);
const row = (await shareRow(trip.id))!;
expect(row.token).toBe(result.token);
expect(row.created_by).toBe(user.id);
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
@@ -159,7 +164,7 @@ describe('createOrUpdate', () => {
await svc.createOrUpdate(String(trip.id), user.id, {
share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true,
});
const row = shareRow(trip.id);
const row = (await shareRow(trip.id))!;
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
.toEqual([0, 0, 1, 1, 1]);
});
@@ -170,7 +175,7 @@ describe('createOrUpdate', () => {
const first = await svc.createOrUpdate(String(trip.id), user.id, {});
const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
expect(second).toEqual({ token: first.token, created: false });
expect(shareRow(trip.id).share_budget).toBe(1);
expect((await shareRow(trip.id))!.share_budget).toBe(1);
});
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', async () => {
@@ -179,10 +184,10 @@ describe('createOrUpdate', () => {
await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
// Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it
// onto the 90-day clock.
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: null });
const before = Date.now();
await svc.createOrUpdate(String(trip.id), user.id, {});
const row = shareRow(trip.id);
const row = (await shareRow(trip.id))!;
// Omitted share_packing fell back to its default (off) — destructuring
// defaults apply on every call, not only on create.
expect(row.share_packing).toBe(0);
@@ -225,7 +230,7 @@ describe('get and remove', () => {
const trip = createTrip(testDb, user.id);
await svc.createOrUpdate(String(trip.id), user.id, {});
await svc.remove(String(trip.id));
expect(shareRow(trip.id)).toBeUndefined();
expect(await shareRow(trip.id)).toBeNull();
await expect(svc.remove(String(trip.id))).resolves.toBeUndefined();
});
});
@@ -239,9 +244,9 @@ describe('getSharedTripData', () => {
it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => {
const { trip, token } = await seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: '2020-01-01T00:00:00.000Z' });
expect(await svc.getSharedTripData(token)).toBeNull();
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: null });
expect(await svc.getSharedTripData(token)).not.toBeNull();
});
@@ -257,7 +262,7 @@ describe('getSharedTripData', () => {
it('SHARE-SVC-040 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => {
const { trip, token } = await seedSharedTrip();
expect(await svc.getSharedTripData(token)).not.toBeNull();
testDb.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(trip.id);
await deleteRows(t, ShareTokens, { trip: trip.id });
expect(await svc.getSharedTripData(token)).toBeNull();
});
@@ -278,7 +283,7 @@ describe('getSharedTripData', () => {
// The token normally cascades away with its trip; orphan it deliberately
// to pin the defensive `if (!trip) return null` branch.
testDb.exec('PRAGMA foreign_keys = OFF');
testDb.prepare('DELETE FROM trips WHERE id = ?').run(trip.id);
await deleteRows(t, Trips, { id: trip.id });
testDb.exec('PRAGMA foreign_keys = ON');
expect(await svc.getSharedTripData(token)).toBeNull();
});
@@ -314,12 +319,12 @@ describe('getSharedTripData', () => {
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id);
await updateRows(t, Places, { id: place.id }, { place_time: '09:00', end_time: '10:00' });
const a = createDayAssignment(testDb, day.id, place.id);
let entry = (await svc.getSharedTripData(token))!.assignments[day.id][0];
expect(entry.place.place_time).toBe('09:00');
expect(entry.place.end_time).toBe('10:00');
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', a.id);
await updateRows(t, DayAssignments, { id: a.id }, { assignment_time: '14:00', assignment_end_time: '15:00' });
entry = (await svc.getSharedTripData(token))!.assignments[day.id][0];
expect(entry.place.place_time).toBe('14:00');
expect(entry.place.end_time).toBe('15:00');
@@ -330,12 +335,8 @@ describe('getSharedTripData', () => {
const day = createDay(testDb, trip.id);
const p1 = createPlace(testDb, trip.id, { name: 'First' });
const p2 = createPlace(testDb, trip.id, { name: 'Second' });
testDb.prepare(
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
).run(day.id, p2.id);
testDb.prepare(
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
).run(day.id, p1.id);
await insertRow(t, DayAssignments, { day: day.id, place: p2.id, order_index: 0, created_at: '2025-01-01T11:00:00' });
await insertRow(t, DayAssignments, { day: day.id, place: p1.id, order_index: 0, created_at: '2025-01-01T10:00:00' });
const entries = (await svc.getSharedTripData(token))!.assignments[day.id];
expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']);
});
@@ -343,29 +344,28 @@ describe('getSharedTripData', () => {
it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => {
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id);
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)')
.run(r1.lastInsertRowid, day.id);
const r1 = await addReservation(trip.id, { title: 'Flight', type: 'flight' });
const r2 = await addReservation(trip.id, { title: 'Hotel', type: 'hotel' });
await insertRow(t, ReservationDayPositions, { reservation: r1, day: day.id, position: 3 });
const reservations = (await svc.getSharedTripData(token))!.reservations;
const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid);
const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid);
const flight = reservations.find((r: any) => r.id === r1);
const hotel = reservations.find((r: any) => r.id === r2);
expect(flight.day_positions).toEqual({ [day.id]: 3 });
expect(hotel.day_positions).toBeNull();
});
it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => {
const { user, trip, token } = await seedSharedTrip();
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id);
await insertRow(t, PackingItems, { trip: trip.id, name: 'Common', is_private: 0, owner: user.id });
await insertRow(t, PackingItems, { trip: trip.id, name: 'Secret', is_private: 1, owner: user.id });
const packing = (await svc.getSharedTripData(token))!.packing;
expect(packing.map((p: any) => p.name)).toEqual(['Common']);
});
it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => {
const { user, trip, token } = await seedSharedTrip();
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id);
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id);
await insertRow(t, CollabMessages, { trip: trip.id, user: user.id, text: 'Hello', deleted: 0 });
await insertRow(t, CollabMessages, { trip: trip.id, user: user.id, text: 'Gone', deleted: 1 });
const collab = (await svc.getSharedTripData(token))!.collab;
expect(collab).toHaveLength(1);
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
@@ -381,9 +381,9 @@ describe('getSharedTripData', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, day.id, place.id);
testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id);
testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id);
await addReservation(trip.id, { title: 'Flight', type: 'flight' });
await insertRow(t, PackingItems, { trip: trip.id, name: 'Socks' });
await insertRow(t, BudgetItems, { trip: trip.id, name: 'Food', category: 'food', total_price: 10 });
const data = (await svc.getSharedTripData(token))!;
expect(data.days).toEqual([]);
expect(data.assignments).toEqual({});
@@ -404,7 +404,7 @@ describe('getSharedTripData', () => {
const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id);
createDayAssignment(testDb, day.id, museum.id);
const stayStop = createDayAssignment(testDb, day.id, hotel.id);
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stay.id, stayStop.id);
await updateRows(t, DayAssignments, { id: stayStop.id }, { accommodation_id: stay.id });
createDayNote(testDb, day.id, trip.id);
for (const type of ['flight', 'hotel', 'restaurant', 'tour']) createReservation(testDb, trip.id, { type, title: type });
@@ -420,7 +420,7 @@ describe('getSharedTripData', () => {
const { trip, token } = await seedSharedTrip({ share_hide_images: true });
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
testDb.prepare("UPDATE places SET image_url = '/api/maps/place-photo/abc/bytes' WHERE id = ?").run(place.id);
await updateRows(t, Places, { id: place.id }, { image_url: '/api/maps/place-photo/abc/bytes' });
createDayAssignment(testDb, day.id, place.id);
const data = (await svc.getSharedTripData(token))!;
@@ -433,9 +433,9 @@ describe('getSharedTripData', () => {
it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => {
const { user, trip, token } = await seedSharedTrip();
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('EUR');
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id);
await updateRows(t, Trips, { id: trip.id }, { currency: 'USD' });
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('USD');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id);
await setUserSetting(t, user.id, 'default_currency', ' CHF ');
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('CHF');
});
@@ -444,8 +444,8 @@ describe('getSharedTripData', () => {
const proxied = createPlace(testDb, trip.id, { name: 'Proxied' });
const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' });
createPlace(testDb, trip.id, { name: 'Bare' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id);
await updateRows(t, Places, { id: proxied.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' });
await updateRows(t, Places, { id: uploaded.id }, { image_url: '/uploads/pic.jpg' });
const byName = Object.fromEntries(
(await svc.getSharedTripData(token))!.places.map((p: any) => [p.name, p.image_url]),
);
@@ -457,18 +457,16 @@ describe('getSharedTripData', () => {
it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user, token } = await seedSharedTrip();
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
await setAppSetting(t, 'default_user_setting_carto_api_key', 'instance-key');
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('instance-key');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
await setUserSetting(t, user.id, 'carto_api_key', ' owner-key ');
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('owner-key');
});
it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => {
const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state)
VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id);
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number)
VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`).run(trip.id);
await addReservation(trip.id, { title: 'Parked Flight', type: 'flight', status: 'confirmed', confirmation_number: 'SECRET1', ingest_state: 'staged' });
await addReservation(trip.id, { title: 'Booked Flight', type: 'flight', status: 'confirmed', confirmation_number: 'OPEN1' });
const data = (await svc.getSharedTripData(token))!;
@@ -480,8 +478,7 @@ describe('getSharedTripData', () => {
it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => {
const { trip, token } = await seedSharedTrip();
for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`);
await addReservation(trip.id, { title: `Booking ${i}`, type: 'flight', status: 'confirmed' });
}
expect(((await svc.getSharedTripData(token))!.reservations as any[])).toHaveLength(5);
@@ -491,14 +488,18 @@ describe('getSharedTripData', () => {
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const stay = (): number => testDb.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)
`).run(trip.id, place.id, day.id, day.id).lastInsertRowid as number;
const stay = (): Promise<number> =>
insertRow(t, DayAccommodations, { trip: trip.id, place: place.id, startDay: day.id, endDay: day.id });
const stagedStay = stay();
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, accommodation_id, ingest_state)
VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`).run(trip.id, String(stagedStay));
const unlinkedStay = stay();
const stagedStay = await stay();
await addReservation(trip.id, {
title: 'Parked Hotel',
type: 'hotel',
status: 'confirmed',
accommodation_id: String(stagedStay),
ingest_state: 'staged',
});
const unlinkedStay = await stay();
const rows = (await svc.getSharedTripData(token))!.accommodations as any[];
@@ -511,10 +512,18 @@ describe('getSharedTripData', () => {
describe('getSharedTripData redaction (#2320)', () => {
it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => {
const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations
(trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review)
VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`)
.run(trip.id);
await addReservation(trip.id, {
title: 'Night train',
type: 'train',
status: 'confirmed',
confirmation_number: 'PNR9XY',
notes: 'Bring the tickets',
url: 'https://bahn.example/booking/1',
external_source: 'kitinerary',
external_id: 'ext-42',
sync_enabled: 1,
needs_review: 0,
});
const [row] = (await svc.getSharedTripData(token))!.reservations as any[];
@@ -538,8 +547,7 @@ describe('getSharedTripData redaction (#2320)', () => {
{ from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10', confirmation_number: 'LEG2' },
],
});
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, metadata)
VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata);
await addReservation(trip.id, { title: 'To New York', type: 'flight', status: 'confirmed', metadata });
const data = (await svc.getSharedTripData(token))!;
const [row] = data.reservations as any[];
@@ -560,12 +568,9 @@ describe('getSharedTripData redaction (#2320)', () => {
it('SHARE-SVC-034: a booking carries its ordered stops', async () => {
const { trip, token } = await seedSharedTrip();
const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid;
const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84);
ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35);
const id = await addReservation(trip.id, { title: 'Coach', type: 'bus', status: 'confirmed' });
await insertRow(t, ReservationEndpoints, { reservation: id, role: 'to', sequence: 1, name: 'Lyon', code: null, lat: 45.76, lng: 4.84 });
await insertRow(t, ReservationEndpoints, { reservation: id, role: 'from', sequence: 0, name: 'Paris', code: 'PAR', lat: 48.85, lng: 2.35 });
const [row] = (await svc.getSharedTripData(token))!.reservations as any[];
@@ -579,10 +584,10 @@ describe('getSharedTripData redaction (#2320)', () => {
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url);
await addReservation(trip.id, { title, type: 'other', status: 'confirmed', url });
}
const place = createPlace(testDb, trip.id, { name: 'Museum' });
testDb.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?').run('javascript:alert(2)', '+43 1 234', place.id);
await updateRows(t, Places, { id: place.id }, { website: 'javascript:alert(2)', phone: '+43 1 234' });
createDayAssignment(testDb, day.id, place.id, {});
const data = (await svc.getSharedTripData(token))!;
@@ -597,8 +602,16 @@ describe('getSharedTripData redaction (#2320)', () => {
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue',
duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(place.id);
await updateRows(t, Places, { id: place.id }, {
description: 'Big museum',
address: 'Rue de Rivoli',
notes: 'Skip the pyramid queue',
duration_minutes: 180,
website: 'https://louvre.fr',
phone: '+33 1',
reservation_notes: 'Booked under Ada',
google_place_id: 'ChIJ123',
});
createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
const data = (await svc.getSharedTripData(token))!;
@@ -619,16 +632,16 @@ describe('getSharedTripData redaction (#2320)', () => {
it('SHARE-SVC-046 (SH12 full-key parity): every allow-listed column of a fully-populated place reaches the public payload, nothing else does', async () => {
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Fully Populated' });
testDb.prepare(`UPDATE places SET
description = 'A description', address = '1 Rue Test', price = 12.5, currency = 'EUR',
place_time = '09:00', end_time = '10:00', duration_minutes = 60, notes = 'A public note',
image_url = '/uploads/pic.jpg', website = 'https://example.com', phone = '+33 1 23',
transport_mode = 'walk',
reservation_status = 'confirmed', reservation_notes = 'Private booking note', reservation_datetime = '2026-01-01T09:00',
google_place_id = 'ChIJ-private', google_ftid = 'ftid-private', osm_id = 'osm-private', amap_poi_id = 'amap-private',
route_geometry = '{"type":"LineString"}', route_color = '#ff0000', stop_type = 'hotel', fill_percent = 42,
source = 'import'
WHERE id = ?`).run(place.id);
await updateRows(t, Places, { id: place.id }, {
description: 'A description', address: '1 Rue Test', price: 12.5, currency: 'EUR',
place_time: '09:00', end_time: '10:00', duration_minutes: 60, notes: 'A public note',
image_url: '/uploads/pic.jpg', website: 'https://example.com', phone: '+33 1 23',
transport_mode: 'walk',
reservation_status: 'confirmed', reservation_notes: 'Private booking note', reservation_datetime: '2026-01-01T09:00',
google_place_id: 'ChIJ-private', google_ftid: 'ftid-private', osm_id: 'osm-private', amap_poi_id: 'amap-private',
route_geometry: '{"type":"LineString"}', route_color: '#ff0000', stop_type: 'hotel', fill_percent: 42,
source: 'import',
});
const data = (await svc.getSharedTripData(token))!;
const pool = (data.places as any[])[0];
@@ -659,8 +672,16 @@ describe('getSharedTripData redaction (#2320)', () => {
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id);
await insertRow(t, DayAccommodations, {
trip: trip.id,
place: place.id,
startDay: day.id,
endDay: day.id,
check_in: '15:00',
check_out: '11:00',
confirmation: 'HOTEL-777',
notes: 'Ask for a quiet room',
});
const data = (await svc.getSharedTripData(token))!;
const [stay] = data.accommodations as any[];
@@ -685,7 +706,7 @@ describe('getSharedPlacePhotoKey', () => {
it('SHARE-SVC-022: returns null for an unknown or expired token', async () => {
expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull();
const { trip, token } = await seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: '2020-01-01T00:00:00.000Z' });
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled();
});
@@ -698,9 +719,9 @@ describe('getSharedPlacePhotoKey', () => {
it('SHARE-SVC-043 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => {
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id);
await updateRows(t, Places, { id: place.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' });
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).not.toBeNull();
testDb.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(trip.id);
await deleteRows(t, ShareTokens, { trip: trip.id });
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
});
@@ -719,7 +740,7 @@ describe('getSharedPlacePhotoKey', () => {
it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => {
const { trip, token } = await seedSharedTrip({ share_map: false });
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id);
await updateRows(t, Places, { id: place.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' });
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled();
});
@@ -735,8 +756,7 @@ describe('getSharedPlacePhotoKey', () => {
const place = createPlace(testDb, trip.id);
// Wikimedia pseudo-IDs contain characters that must round-trip encoded.
const placeId = 'coords:48.8,2.3';
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?')
.run(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, place.id);
await updateRows(t, Places, { id: place.id }, { image_url: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes` });
serveKey.mockReturnValue('abc.jpg');
expect(await svc.getSharedPlacePhotoKey(token, placeId)).toBe('abc.jpg');
expect(serveKey).toHaveBeenCalledWith(placeId);