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 request from 'supertest';
import type { Application } from 'express'; import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common';
import { MikroORM } from '@mikro-orm/core';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// In-memory DB — schema applied in beforeAll after mocks register // 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 { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAccommodation, createReservation } from '../helpers/factories'; import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAccommodation, createReservation } from '../helpers/factories';
import { authCookie } from '../helpers/auth'; 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 nestApp: INestApplication;
let app: Application; 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 () => { beforeAll(async () => {
nestApp = await buildApp(); nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance(); app = nestApp.getHttpAdapter().getInstance();
@@ -181,7 +205,7 @@ describe('Create day', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' }); 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 () => { 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(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain('dated cannot be combined with date or position'); 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(await dayCount(trip.id)).toBe(2);
expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-08-02' }); 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.status).toBe(200);
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id); expect(await findRow(orm(), Days, { id: day.id })).toBeNull();
expect(deleted).toBeUndefined();
}); });
it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => { 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 () => { 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' }); 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) const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${first.id}`) .delete(`/api/trips/${trip.id}/days/${first.id}`)
@@ -366,7 +389,7 @@ describe('Delete day', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'A trip needs at least one day.' }); 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 () => { 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); addTripMember(testDb, trip.id, member.id);
const day = createDay(testDb, trip.id); const day = createDay(testDb, trip.id);
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'); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
await invalidatePermissionsCache(); await invalidatePermissionsCache();
try { try {
@@ -384,9 +407,9 @@ describe('Delete day', () => {
.delete(`/api/trips/${trip.id}/days/${day.id}`) .delete(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(member.id)); .set('Cookie', authCookie(member.id));
expect(res.status).toBe(403); 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 { } finally {
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run(); await deleteRows(orm(), AppSettings, { key: 'perm_day_edit' });
await invalidatePermissionsCache(); await invalidatePermissionsCache();
} }
}); });
@@ -459,9 +482,7 @@ describe('Accommodations', () => {
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' }); const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
// Seed accommodation directly // Seed accommodation directly
testDb.prepare( await insertRow(orm(), DayAccommodations, { trip: trip.id, place: place.id, startDay: day1.id, endDay: day2.id });
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
).run(trip.id, place.id, day1.id, day2.id);
const res = await request(app) const res = await request(app)
.get(`/api/trips/${trip.id}/accommodations`) .get(`/api/trips/${trip.id}/accommodations`)
@@ -509,8 +530,7 @@ describe('Accommodations', () => {
expect(deleteRes.body.success).toBe(true); expect(deleteRes.body.success).toBe(true);
// Verify removed from DB // Verify removed from DB
const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId); expect(await findRow(orm(), DayAccommodations, { id: accommodationId })).toBeNull();
expect(row).toBeUndefined();
}); });
it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => { it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => {
@@ -540,12 +560,10 @@ describe('Accommodations', () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
// Linked reservation should exist // Linked reservation should exist
const reservation = testDb.prepare( const reservation = await findRow(orm(), Reservations, { accommodation_id: String(res.body.accommodation.id) });
'SELECT * FROM reservations WHERE accommodation_id = ?' expect(reservation).not.toBeNull();
).get(res.body.accommodation.id) as any; expect(reservation!.type).toBe('hotel');
expect(reservation).toBeDefined(); expect(reservation!.confirmation_number).toBe('CONF-XYZ');
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 () => { 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 }); .send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
const accommodationId = createRes.body.accommodation.id; const accommodationId = createRes.body.accommodation.id;
const reservationBefore = testDb.prepare( const reservationBefore = await findRow(orm(), Reservations, { accommodation_id: String(accommodationId) });
'SELECT id FROM reservations WHERE accommodation_id = ?' expect(reservationBefore).not.toBeNull();
).get(accommodationId) as any;
expect(reservationBefore).toBeDefined();
const deleteRes = await request(app) const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`) .delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200); expect(deleteRes.status).toBe(200);
const reservationAfter = testDb.prepare( expect(await findRow(orm(), Reservations, { id: reservationBefore!.id })).toBeNull();
'SELECT id FROM reservations WHERE id = ?'
).get(reservationBefore.id);
expect(reservationAfter).toBeUndefined();
}); });
it('ACCOM-006 — DELETE accommodation also removes its linked budget item (issue #933)', async () => { 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); expect(createRes.status).toBe(201);
const accommodationId = testDb.prepare( const accommodationId = await findRow(orm(), DayAccommodations, { trip: trip.id });
'SELECT id FROM day_accommodations WHERE trip_id = ?' expect(accommodationId).not.toBeNull();
).get(trip.id) as any;
expect(accommodationId).toBeDefined();
const budgetBefore = testDb.prepare( const budgetBefore = await findRow(orm(), BudgetItems, { trip: trip.id });
'SELECT id FROM budget_items WHERE trip_id = ?' expect(budgetBefore).not.toBeNull();
).get(trip.id);
expect(budgetBefore).toBeDefined();
// Delete via the accommodation endpoint (the primary bug path) // Delete via the accommodation endpoint (the primary bug path)
const delRes = await request(app) 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)); .set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200); expect(delRes.status).toBe(200);
const budgetAfter = testDb.prepare( expect(await findRow(orm(), BudgetItems, { trip: trip.id })).toBeNull();
'SELECT id FROM budget_items WHERE trip_id = ?'
).get(trip.id);
expect(budgetAfter).toBeUndefined();
}); });
}); });
@@ -702,15 +708,15 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ title: 'Renamed' }); .send({ title: 'Renamed' });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Day not found' }); expect(res.body).toEqual({ error: 'Day not found' });
const row = testDb.prepare('SELECT title FROM days WHERE id = ?').get(day.id) as { title: string }; const row = await findRow(orm(), Days, { id: day.id });
expect(row.title).toBe('Original'); 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 () => { 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' }); const trip = createTrip(testDb, user.id, { title: 'Trip' });
const hexTripId = '0x' + trip.id.toString(16); 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) const res = await request(app)
.post(`/api/trips/${hexTripId}/days`) .post(`/api/trips/${hexTripId}/days`)
@@ -718,7 +724,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({}); .send({});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); 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 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) const res = await request(app)
.post(`/api/trips/${hexTripId}/days`) .post(`/api/trips/${hexTripId}/days`)
@@ -734,7 +740,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 }); .send({ position: 1 });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' }); const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}.0`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -750,7 +756,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({}); .send({});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}.0`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -766,7 +772,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 }); .send({ position: 1 });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' }); const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}%20`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -782,7 +788,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({}); .send({});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}%20`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -798,7 +804,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 }); .send({ position: 1 });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' }); const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `%2B${trip.id}`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -814,7 +820,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({}); .send({});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `%2B${trip.id}`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -830,7 +836,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 }); .send({ position: 1 });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' }); const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}e0`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -846,7 +852,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({}); .send({});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}e0`; 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) const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`) .post(`/api/trips/${shapedTripId}/days`)
@@ -862,14 +868,14 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (
.send({ position: 1 }); .send({ position: 1 });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' }); 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); 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 () => { 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' }); 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); expect(days).toHaveLength(3);
const [d1, d2, d3] = days; const [d1, d2, d3] = days;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }); 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. // the shape the compiled-boot evidence used to catch reorder inverting.
const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id);
const reservation = createReservation(testDb, trip.id, { day_id: d1.id, title: 'Dinner' }); 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 hexTripId = '0x' + trip.id.toString(16);
const res = await request(app) 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.' }); expect(res.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
// Nothing renumbered. // 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); expect(after).toEqual(days);
// The stay is still d1 -> d2, never inverted. // 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 }); expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d2.id });
// The reservation's stamped time is untouched. // 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 }; const resAfter = await findRow(orm(), Reservations, { id: reservation.id });
expect(resAfter.reservation_time).toBe('2026-06-01T19:00:00'); 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 () => { 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' }); 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 [d1, d2, d3] = days;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }); const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d3.id); 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] }); .send({ orderedIds: [d3.id, d2.id, d1.id] });
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This move would make an accommodation end before it starts.' }); 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 }); 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. // 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); 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 { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb(); const db = createSnapshotTestDb();
const mock = { // Trip access reads through TripsRepository now; the module only hands out the handle.
db, return { db, closeDb: () => {}, reinitialize: () => {} };
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;
}); });
@@ -116,6 +103,34 @@ import {
createTestTripMembersRepo, createTestTripMembersRepo,
} from '../../helpers/test-uow'; } from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos'; 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 { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-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 () => { it('OIDC-SVC-020: finds existing user by oidc_sub', async () => {
const { user } = createUser(testDb, { email: 'alice@example.com' }); const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually // Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-alice-123', oidc_issuer: MOCK_CONFIG.issuer });
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
const result = await svc.findOrCreateUser( const result = await svc.findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' }, { sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
@@ -548,8 +562,7 @@ describe('findOrCreateUser', () => {
MOCK_CONFIG MOCK_CONFIG
); );
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get(); expect(await userByEmail('newuser@example.com')).not.toBeNull();
expect(newUser).toBeDefined();
}); });
it('OIDC-SVC-023: first user gets admin role', async () => { 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 () => { 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' }); 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'; process.env.OIDC_USERNAME_CLAIM = 'preferred_username';
const result = await svc.findOrCreateUser( const result = await svc.findOrCreateUser(
{ sub: 'sub-kept', email: 'kept@example.com', name: 'Kept', preferred_username: 'idp-name' }, { 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 () => { it('OIDC-SVC-024: returns registration_disabled error when registration is off', async () => {
createUser(testDb, { email: 'existing@example.com' }); 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( const result = await svc.findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' }, { 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 () => { it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', async () => {
const { user } = createUser(testDb, { email: 'charlie@example.com' }); const { user } = createUser(testDb, { email: 'charlie@example.com' });
// Ensure no oidc_sub set // 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( await svc.findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true }, { sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true },
MOCK_CONFIG 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'); expect(updated.oidc_sub).toBe('sub-charlie-linked');
}); });
it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', async () => { it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', async () => {
const { user } = createUser(testDb, { email: 'dora@example.com' }); 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 // No email_verified claim — an IdP that lets users set arbitrary emails must
// not be able to take over a pre-existing password account. // not be able to take over a pre-existing password account.
@@ -643,15 +656,14 @@ describe('findOrCreateUser', () => {
expect('error' in result).toBe(true); expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('email_not_verified'); 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 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 () => { 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' }); const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup // Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-diana-role', oidc_issuer: MOCK_CONFIG.issuer });
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_ADMIN_VALUE = 'admins'; process.env.OIDC_ADMIN_VALUE = 'admins';
@@ -663,15 +675,12 @@ describe('findOrCreateUser', () => {
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin'); 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((await readUser(await orm(), user.id)).role).toBe('admin');
expect(dbUser.role).toBe('admin');
}); });
it('OIDC-SVC-027: new user with valid invite token increments used_count', async () => { it('OIDC-SVC-027: new user with valid invite token increments used_count', async () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' }); const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare( await makeInviteToken(await orm(), creator.id, { token: 'tok-valid', max_uses: 5, used_count: 0 });
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
const result = await svc.findOrCreateUser( const result = await svc.findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' }, { sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
@@ -681,15 +690,12 @@ describe('findOrCreateUser', () => {
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any; expect(await usedCount('tok-valid')).toBe(1);
expect(token.used_count).toBe(1);
}); });
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', async () => { 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' }); const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare( await makeInviteToken(await orm(), creator.id, { token: 'tok-expired', max_uses: 5, used_count: 0, expires_at: '2000-01-01T00:00:00.000Z' });
"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);
const result = await svc.findOrCreateUser( const result = await svc.findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' }, { 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 // User is still created because open registration is allowed
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get(); expect(await userByEmail('expired-invitee@example.com')).not.toBeNull();
expect(newUser).toBeDefined();
// Invite used_count must remain 0 (token was treated as invalid) // 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(await usedCount('tok-expired')).toBe(0);
expect(token.used_count).toBe(0);
}); });
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', async () => { 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' }); const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare( await makeInviteToken(await orm(), creator.id, { token: 'tok-full', max_uses: 1, used_count: 1 });
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
const result = await svc.findOrCreateUser( const result = await svc.findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' }, { 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 // User is still created because open registration is allowed
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get(); expect(await userByEmail('full-invitee@example.com')).not.toBeNull();
expect(newUser).toBeDefined();
// Invite used_count must remain 1 (token was treated as invalid) // 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(await usedCount('tok-full')).toBe(1);
expect(token.used_count).toBe(1);
}); });
// ── OIDC picture claim → avatar (#1399) ────────────────────────────────── // ── OIDC picture claim → avatar (#1399) ──────────────────────────────────
@@ -737,8 +737,7 @@ describe('findOrCreateUser', () => {
MOCK_CONFIG MOCK_CONFIG
); );
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic1@example.com'").get() as any; expect((await userByEmail('pic1@example.com'))?.avatar).toBe('https://idp.example.com/u/pic1.png');
expect(row.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 () => { 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' }, { sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' },
MOCK_CONFIG MOCK_CONFIG
); );
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic2@example.com'").get() as any; const row = await userByEmail('pic2@example.com');
expect(row.avatar).toBeNull(); expect(row).not.toBeNull();
expect(row!.avatar).toBeNull();
}); });
it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', async () => { it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', async () => {
const { user } = createUser(testDb, { email: 'pic3@example.com' }); const { user } = createUser(testDb, { email: 'pic3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-pic-3', oidc_issuer: MOCK_CONFIG.issuer, avatar: null });
.run('sub-pic-3', MOCK_CONFIG.issuer, user.id);
await svc.findOrCreateUser( await svc.findOrCreateUser(
{ sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' }, { sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' },
MOCK_CONFIG 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'); 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 () => { it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', async () => {
const { user } = createUser(testDb, { email: 'pic4@example.com' }); const { user } = createUser(testDb, { email: 'pic4@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-pic-4', oidc_issuer: MOCK_CONFIG.issuer, avatar: 'uploaded-abc.jpg' });
.run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id);
await svc.findOrCreateUser( await svc.findOrCreateUser(
{ sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' }, { sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' },
MOCK_CONFIG 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'); expect(row.avatar).toBe('uploaded-abc.jpg');
}); });
it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', async () => { it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', async () => {
const { user } = createUser(testDb, { email: 'pic5@example.com' }); const { user } = createUser(testDb, { email: 'pic5@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-pic-5', oidc_issuer: MOCK_CONFIG.issuer, avatar: 'https://idp.example.com/u/old.png' });
.run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id);
await svc.findOrCreateUser( await svc.findOrCreateUser(
{ sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' }, { sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' },
MOCK_CONFIG 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'); 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. // 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 () => { it('OIDC-SVC-060: a provider without a picture claim clears the previous provider avatar', async () => {
const { user } = createUser(testDb, { email: 'switch1@example.com' }); const { user } = createUser(testDb, { email: 'switch1@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE 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' });
.run('sub-old-1', 'https://old-idp.example.com', 'https://old-idp.example.com/u/me.png', user.id);
await svc.findOrCreateUser( await svc.findOrCreateUser(
{ sub: 'sub-new-1', email: 'switch1@example.com', name: 'Switcher', email_verified: true }, { sub: 'sub-new-1', email: 'switch1@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, { ...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(); expect(row.avatar).toBeNull();
}); });
@@ -824,29 +820,27 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-061: an uploaded avatar survives the same switch', async () => { it('OIDC-SVC-061: an uploaded avatar survives the same switch', async () => {
const { user } = createUser(testDb, { email: 'switch2@example.com' }); const { user } = createUser(testDb, { email: 'switch2@example.com' });
// A local upload is a bare filename, not a URL, and belongs to the user. // 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 = ?') await setUserColumns(user.id, { oidc_sub: 'sub-old-2', oidc_issuer: 'https://old-idp.example.com', avatar: 'uploaded-abc.jpg' });
.run('sub-old-2', 'https://old-idp.example.com', 'uploaded-abc.jpg', user.id);
await svc.findOrCreateUser( await svc.findOrCreateUser(
{ sub: 'sub-new-2', email: 'switch2@example.com', name: 'Switcher', email_verified: true }, { sub: 'sub-new-2', email: 'switch2@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, { ...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'); expect(row.avatar).toBe('uploaded-abc.jpg');
}); });
it('OIDC-SVC-062: the account is relinked to the new provider sub and issuer', async () => { it('OIDC-SVC-062: the account is relinked to the new provider sub and issuer', async () => {
const { user } = createUser(testDb, { email: 'switch3@example.com' }); const { user } = createUser(testDb, { email: 'switch3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-old-3', oidc_issuer: 'https://old-idp.example.com' });
.run('sub-old-3', 'https://old-idp.example.com', user.id);
await svc.findOrCreateUser( await svc.findOrCreateUser(
{ sub: 'sub-new-3', email: 'switch3@example.com', name: 'Switcher', email_verified: true }, { sub: 'sub-new-3', email: 'switch3@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, { ...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_sub).toBe('sub-new-3');
expect(row.oidc_issuer).toBe('https://new-idp.example.com'); expect(row.oidc_issuer).toBe('https://new-idp.example.com');
}); });
@@ -858,13 +852,14 @@ describe('findOrCreateUser', () => {
// exists to foreclose (task-1-fix-report.md). // 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 () => { 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' }); 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 // 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 // past value before the snapshot so a future `linkOidcIdentity` that
// starts stamping updated_at (a CURRENT_TIMESTAMP drift) makes this test // starts stamping updated_at (a CURRENT_TIMESTAMP drift) makes this test
// fail loudly, instead of the before/after snapshot racing the mutation // fail loudly, instead of the before/after snapshot racing the mutation
// inside the same second and passing by accident. // 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>; const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
await svc.findOrCreateUser( await svc.findOrCreateUser(
@@ -872,16 +867,17 @@ describe('findOrCreateUser', () => {
MOCK_CONFIG, 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>; 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 }); 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 () => { 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' }); const { user } = createUser(testDb, { email: 'switchrow@example.com', username: 'switchrowuser' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') await setUserColumns(user.id, { oidc_sub: 'sub-old-row', oidc_issuer: 'https://old-idp.example.com' });
.run('sub-old-row', 'https://old-idp.example.com', user.id);
// Task 6 review L1 / Task 7 review, Task 6 L1: see OIDC-SVC-090's comment. // 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>; const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
await svc.findOrCreateUser( await svc.findOrCreateUser(
@@ -889,6 +885,7 @@ describe('findOrCreateUser', () => {
{ ...MOCK_CONFIG, issuer: 'https://new-idp-row.example.com' }, { ...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>; 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' }); 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. // 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 () => { 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' }); const { user: creator } = createUser(testDb, { email: 'racecreator@example.com' });
testDb.prepare( await makeInviteToken(await orm(), creator.id, { token: 'tok-race', max_uses: 1, used_count: 0 });
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-race', 1, 0, ?)"
).run(creator.id);
const [first, second] = await Promise.all([ const [first, second] = await Promise.all([
svc.findOrCreateUser({ sub: 'sub-race-1', email: 'racer1@example.com', name: 'Racer One' }, MOCK_CONFIG, 'tok-race'), 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).toHaveLength(1);
expect(losers[0]!.error).toBe('registration_disabled'); 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(await usedCount('tok-race')).toBe(1);
expect(token.used_count).toBe(1);
}); });
it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', async () => { 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 () => { 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 { user: admin } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, admin.id); const trip = createTrip(testDb, admin.id);
testDb.prepare( await makeInviteToken(await orm(), admin.id, { token: 'inv-trip-join', max_uses: 5, used_count: 0, expires_at: null, trip: trip.id });
'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);
const result = await svc.findOrCreateUser( const result = await svc.findOrCreateUser(
{ sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' }, { sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' },
@@ -956,8 +948,7 @@ describe('findOrCreateUser', () => {
); );
expect('user' in result).toBe(true); expect('user' in result).toBe(true);
const uid = (result as { user: any }).user.id; 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(await findRow(await orm(), TripMembers, { trip: trip.id, user: uid })).toBeTruthy();
expect(member).toBeTruthy();
}); });
}); });
@@ -966,19 +957,19 @@ describe('findOrCreateUser', () => {
describe('findOrCreateUser role mapping', () => { describe('findOrCreateUser role mapping', () => {
type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } }; 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 }); 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; 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 () => { 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements'; process.env.OIDC_ADMIN_CLAIM = 'entitlements';
// A second admin row, so the #1274 last-admin guard cannot be what saves them. // A second admin row, so the #1274 last-admin guard cannot be what saves them.
createUser(testDb, { email: 'bootstrap@example.com', role: 'admin' }); 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(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { try {
@@ -988,7 +979,7 @@ describe('findOrCreateUser role mapping', () => {
)) as RoleResult; )) as RoleResult;
expect(result.user.role).toBe('admin'); expect(result.user.role).toBe('admin');
expect(storedRole(user.id)).toBe('admin'); expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined(); expect(result.roleChange).toBeUndefined();
} finally { } finally {
warn.mockRestore(); 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 () => { 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements'; 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(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { try {
@@ -1008,7 +999,7 @@ describe('findOrCreateUser role mapping', () => {
)) as RoleResult; )) as RoleResult;
expect(result.user.role).toBe('user'); expect(result.user.role).toBe('user');
expect(storedRole(user.id)).toBe('user'); expect(await storedRole(user.id)).toBe('user');
expect(result.roleChange).toBeUndefined(); expect(result.roleChange).toBeUndefined();
} finally { } finally {
warn.mockRestore(); 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 () => { 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements'; 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( const result = (await svc.findOrCreateUser(
{ sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] }, { sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] },
MOCK_CONFIG MOCK_CONFIG
)) as RoleResult; )) 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' }); 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements'; process.env.OIDC_ADMIN_CLAIM = 'entitlements';
createUser(testDb, { email: 'bootstrap2@example.com', role: 'admin' }); 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( const result = (await svc.findOrCreateUser(
{ sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] }, { sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] },
MOCK_CONFIG MOCK_CONFIG
)) as RoleResult; )) 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' }); 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 () => { 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements'; 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(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { try {
@@ -1056,7 +1047,7 @@ describe('findOrCreateUser role mapping', () => {
MOCK_CONFIG MOCK_CONFIG
)) as RoleResult; )) as RoleResult;
expect(storedRole(user.id)).toBe('admin'); expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined(); expect(result.roleChange).toBeUndefined();
expect(warn.mock.calls[0][0]).toContain('only admin'); expect(warn.mock.calls[0][0]).toContain('only admin');
} finally { } 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 () => { 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'trek_roles'; 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(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// The warning is deduped per claim name for the life of the process, so this // 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. // case needs an instance that has not seen the claim yet.
@@ -1103,7 +1094,7 @@ describe('findOrCreateUser role mapping', () => {
MOCK_CONFIG MOCK_CONFIG
)) as RoleResult; )) as RoleResult;
expect(storedRole(result.user.id)).toBe('user'); expect(await storedRole(result.user.id)).toBe('user');
expect(result.roleChange).toBeUndefined(); expect(result.roleChange).toBeUndefined();
expect(warn.mock.calls[0][0]).toContain('during registration'); expect(warn.mock.calls[0][0]).toContain('during registration');
} finally { } 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 () => { 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_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements'; 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( const result = (await svc.findOrCreateUser(
{ sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] }, { sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] },
MOCK_CONFIG MOCK_CONFIG
)) as RoleResult; )) as RoleResult;
expect(storedRole(user.id)).toBe('admin'); expect(await storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined(); expect(result.roleChange).toBeUndefined();
}); });
@@ -1142,7 +1133,7 @@ describe('findOrCreateUser role mapping', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles'; process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles';
createUser(testDb, { email: 'bootstrap3@example.com', role: 'admin' }); 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(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { try {
@@ -1161,7 +1152,7 @@ describe('findOrCreateUser role mapping', () => {
expect(line).not.toContain('authentik Admins'); expect(line).not.toContain('authentik Admins');
expect(line).not.toContain('kept@example.com'); expect(line).not.toContain('kept@example.com');
} }
expect(storedRole(user.id)).toBe('admin'); expect(await storedRole(user.id)).toBe('admin');
} finally { } finally {
warn.mockRestore(); warn.mockRestore();
} }
@@ -1171,8 +1162,8 @@ describe('findOrCreateUser role mapping', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'shared_roles'; process.env.OIDC_ADMIN_CLAIM = 'shared_roles';
createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' }); createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' });
ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin'); await ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin');
ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user'); await ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); 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)); 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 ────────────────────────────────────────────────────────────────── // ── DB setup ──────────────────────────────────────────────────────────────────
vi.mock('../../../src/db/database', async () => { vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb(); const db = createSnapshotTestDb();
const mock = { // Trip access reads through TripsRepository now; the module only hands out the handle.
db, return { db, closeDb: () => {}, reinitialize: () => {} };
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;
}); });
@@ -60,6 +46,20 @@ import type { User } from '../../../src/types';
import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo } from '../../helpers/test-uow'; import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo } from '../../helpers/test-uow';
import { shareServiceRepoArgs } from '../../helpers/share-repos'; import { shareServiceRepoArgs } from '../../helpers/share-repos';
import type { TestOrm } from '../../helpers/test-orm'; 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 svc: ShareService;
let t: TestOrm; let t: TestOrm;
@@ -92,7 +92,12 @@ afterAll(async () => {
}); });
function shareRow(tripId: number | string) { 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. */ /** 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, {}); const result = await svc.createOrUpdate(String(trip.id), user.id, {});
expect(result.created).toBe(true); expect(result.created).toBe(true);
expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url 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.token).toBe(result.token);
expect(row.created_by).toBe(user.id); expect(row.created_by).toBe(user.id);
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab]) 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, { await svc.createOrUpdate(String(trip.id), user.id, {
share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true, 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]) expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
.toEqual([0, 0, 1, 1, 1]); .toEqual([0, 0, 1, 1, 1]);
}); });
@@ -170,7 +175,7 @@ describe('createOrUpdate', () => {
const first = await svc.createOrUpdate(String(trip.id), user.id, {}); const first = await svc.createOrUpdate(String(trip.id), user.id, {});
const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true }); const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
expect(second).toEqual({ token: first.token, created: false }); 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 () => { 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 }); await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
// Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it // Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it
// onto the 90-day clock. // 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(); const before = Date.now();
await svc.createOrUpdate(String(trip.id), user.id, {}); 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 // Omitted share_packing fell back to its default (off) — destructuring
// defaults apply on every call, not only on create. // defaults apply on every call, not only on create.
expect(row.share_packing).toBe(0); expect(row.share_packing).toBe(0);
@@ -225,7 +230,7 @@ describe('get and remove', () => {
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
await svc.createOrUpdate(String(trip.id), user.id, {}); await svc.createOrUpdate(String(trip.id), user.id, {});
await svc.remove(String(trip.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(); 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 () => { it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => {
const { trip, token } = await seedSharedTrip(); 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(); 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(); 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 () => { it('SHARE-SVC-040 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
expect(await svc.getSharedTripData(token)).not.toBeNull(); 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(); expect(await svc.getSharedTripData(token)).toBeNull();
}); });
@@ -278,7 +283,7 @@ describe('getSharedTripData', () => {
// The token normally cascades away with its trip; orphan it deliberately // The token normally cascades away with its trip; orphan it deliberately
// to pin the defensive `if (!trip) return null` branch. // to pin the defensive `if (!trip) return null` branch.
testDb.exec('PRAGMA foreign_keys = OFF'); 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'); testDb.exec('PRAGMA foreign_keys = ON');
expect(await svc.getSharedTripData(token)).toBeNull(); expect(await svc.getSharedTripData(token)).toBeNull();
}); });
@@ -314,12 +319,12 @@ describe('getSharedTripData', () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id); const day = createDay(testDb, trip.id);
const place = createPlace(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); const a = createDayAssignment(testDb, day.id, place.id);
let entry = (await svc.getSharedTripData(token))!.assignments[day.id][0]; let entry = (await svc.getSharedTripData(token))!.assignments[day.id][0];
expect(entry.place.place_time).toBe('09:00'); expect(entry.place.place_time).toBe('09:00');
expect(entry.place.end_time).toBe('10: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]; entry = (await svc.getSharedTripData(token))!.assignments[day.id][0];
expect(entry.place.place_time).toBe('14:00'); expect(entry.place.place_time).toBe('14:00');
expect(entry.place.end_time).toBe('15:00'); expect(entry.place.end_time).toBe('15:00');
@@ -330,12 +335,8 @@ describe('getSharedTripData', () => {
const day = createDay(testDb, trip.id); const day = createDay(testDb, trip.id);
const p1 = createPlace(testDb, trip.id, { name: 'First' }); const p1 = createPlace(testDb, trip.id, { name: 'First' });
const p2 = createPlace(testDb, trip.id, { name: 'Second' }); const p2 = createPlace(testDb, trip.id, { name: 'Second' });
testDb.prepare( await insertRow(t, DayAssignments, { day: day.id, place: p2.id, order_index: 0, created_at: '2025-01-01T11:00:00' });
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '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' });
).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);
const entries = (await svc.getSharedTripData(token))!.assignments[day.id]; const entries = (await svc.getSharedTripData(token))!.assignments[day.id];
expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']); 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 () => { it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id); const day = createDay(testDb, trip.id);
const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); const r1 = await addReservation(trip.id, { title: 'Flight', type: 'flight' });
const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id); const r2 = await addReservation(trip.id, { title: 'Hotel', type: 'hotel' });
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)') await insertRow(t, ReservationDayPositions, { reservation: r1, day: day.id, position: 3 });
.run(r1.lastInsertRowid, day.id);
const reservations = (await svc.getSharedTripData(token))!.reservations; const reservations = (await svc.getSharedTripData(token))!.reservations;
const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid); const flight = reservations.find((r: any) => r.id === r1);
const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid); const hotel = reservations.find((r: any) => r.id === r2);
expect(flight.day_positions).toEqual({ [day.id]: 3 }); expect(flight.day_positions).toEqual({ [day.id]: 3 });
expect(hotel.day_positions).toBeNull(); expect(hotel.day_positions).toBeNull();
}); });
it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => { it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => {
const { user, trip, token } = await seedSharedTrip(); 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); await insertRow(t, PackingItems, { trip: trip.id, name: 'Common', is_private: 0, owner: 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: 'Secret', is_private: 1, owner: user.id });
const packing = (await svc.getSharedTripData(token))!.packing; const packing = (await svc.getSharedTripData(token))!.packing;
expect(packing.map((p: any) => p.name)).toEqual(['Common']); 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 () => { it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => {
const { user, trip, token } = await seedSharedTrip(); 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); await insertRow(t, CollabMessages, { trip: trip.id, user: user.id, text: 'Hello', deleted: 0 });
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: 'Gone', deleted: 1 });
const collab = (await svc.getSharedTripData(token))!.collab; const collab = (await svc.getSharedTripData(token))!.collab;
expect(collab).toHaveLength(1); expect(collab).toHaveLength(1);
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username })); expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
@@ -381,9 +381,9 @@ describe('getSharedTripData', () => {
const day = createDay(testDb, trip.id); const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id); const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, day.id, place.id); createDayAssignment(testDb, day.id, place.id);
testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); await addReservation(trip.id, { title: 'Flight', type: 'flight' });
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id); await insertRow(t, PackingItems, { trip: trip.id, name: 'Socks' });
testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id); await insertRow(t, BudgetItems, { trip: trip.id, name: 'Food', category: 'food', total_price: 10 });
const data = (await svc.getSharedTripData(token))!; const data = (await svc.getSharedTripData(token))!;
expect(data.days).toEqual([]); expect(data.days).toEqual([]);
expect(data.assignments).toEqual({}); expect(data.assignments).toEqual({});
@@ -404,7 +404,7 @@ describe('getSharedTripData', () => {
const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id); const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id);
createDayAssignment(testDb, day.id, museum.id); createDayAssignment(testDb, day.id, museum.id);
const stayStop = createDayAssignment(testDb, day.id, hotel.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); createDayNote(testDb, day.id, trip.id);
for (const type of ['flight', 'hotel', 'restaurant', 'tour']) createReservation(testDb, trip.id, { type, title: type }); 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 { trip, token } = await seedSharedTrip({ share_hide_images: true });
const day = createDay(testDb, trip.id); const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' }); 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); createDayAssignment(testDb, day.id, place.id);
const data = (await svc.getSharedTripData(token))!; 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 () => { it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => {
const { user, trip, token } = await seedSharedTrip(); const { user, trip, token } = await seedSharedTrip();
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('EUR'); 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'); 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'); expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('CHF');
}); });
@@ -444,8 +444,8 @@ describe('getSharedTripData', () => {
const proxied = createPlace(testDb, trip.id, { name: 'Proxied' }); const proxied = createPlace(testDb, trip.id, { name: 'Proxied' });
const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' }); const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' });
createPlace(testDb, trip.id, { name: 'Bare' }); createPlace(testDb, trip.id, { name: 'Bare' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id); await updateRows(t, Places, { id: proxied.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id); await updateRows(t, Places, { id: uploaded.id }, { image_url: '/uploads/pic.jpg' });
const byName = Object.fromEntries( const byName = Object.fromEntries(
(await svc.getSharedTripData(token))!.places.map((p: any) => [p.name, p.image_url]), (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 () => { it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user, token } = await seedSharedTrip(); const { user, token } = await seedSharedTrip();
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe(''); 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'); 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'); 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 () => { it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state) await addReservation(trip.id, { title: 'Parked Flight', type: 'flight', status: 'confirmed', confirmation_number: 'SECRET1', ingest_state: 'staged' });
VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id); await addReservation(trip.id, { title: 'Booked Flight', type: 'flight', status: 'confirmed', confirmation_number: 'OPEN1' });
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number)
VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`).run(trip.id);
const data = (await svc.getSharedTripData(token))!; 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 () => { it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) await addReservation(trip.id, { title: `Booking ${i}`, type: 'flight', status: 'confirmed' });
VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`);
} }
expect(((await svc.getSharedTripData(token))!.reservations as any[])).toHaveLength(5); expect(((await svc.getSharedTripData(token))!.reservations as any[])).toHaveLength(5);
@@ -491,14 +488,18 @@ describe('getSharedTripData', () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' }); const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const stay = (): number => testDb.prepare(` const stay = (): Promise<number> =>
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?) insertRow(t, DayAccommodations, { trip: trip.id, place: place.id, startDay: day.id, endDay: day.id });
`).run(trip.id, place.id, day.id, day.id).lastInsertRowid as number;
const stagedStay = stay(); const stagedStay = await stay();
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, accommodation_id, ingest_state) await addReservation(trip.id, {
VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`).run(trip.id, String(stagedStay)); title: 'Parked Hotel',
const unlinkedStay = stay(); type: 'hotel',
status: 'confirmed',
accommodation_id: String(stagedStay),
ingest_state: 'staged',
});
const unlinkedStay = await stay();
const rows = (await svc.getSharedTripData(token))!.accommodations as any[]; const rows = (await svc.getSharedTripData(token))!.accommodations as any[];
@@ -511,10 +512,18 @@ describe('getSharedTripData', () => {
describe('getSharedTripData redaction (#2320)', () => { describe('getSharedTripData redaction (#2320)', () => {
it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => { it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations await addReservation(trip.id, {
(trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) title: 'Night train',
VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) type: 'train',
.run(trip.id); 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[]; 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' }, { 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) await addReservation(trip.id, { title: 'To New York', type: 'flight', status: 'confirmed', metadata });
VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata);
const data = (await svc.getSharedTripData(token))!; const data = (await svc.getSharedTripData(token))!;
const [row] = data.reservations as any[]; 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 () => { it('SHARE-SVC-034: a booking carries its ordered stops', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) const id = await addReservation(trip.id, { title: 'Coach', type: 'bus', status: 'confirmed' });
VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; await insertRow(t, ReservationEndpoints, { reservation: id, role: 'to', sequence: 1, name: 'Lyon', code: null, lat: 45.76, lng: 4.84 });
const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) await insertRow(t, ReservationEndpoints, { reservation: id, role: 'from', sequence: 0, name: 'Paris', code: 'PAR', lat: 48.85, lng: 2.35 });
VALUES (?, ?, ?, ?, ?, ?, ?)`);
ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84);
ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35);
const [row] = (await svc.getSharedTripData(token))!.reservations as any[]; const [row] = (await svc.getSharedTripData(token))!.reservations as any[];
@@ -579,10 +584,10 @@ describe('getSharedTripData redaction (#2320)', () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' }); 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', ' ']]) { 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' }); 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, {}); createDayAssignment(testDb, day.id, place.id, {});
const data = (await svc.getSharedTripData(token))!; const data = (await svc.getSharedTripData(token))!;
@@ -597,8 +602,16 @@ describe('getSharedTripData redaction (#2320)', () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' }); 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', await updateRows(t, Places, { id: place.id }, {
duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(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' }); createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
const data = (await svc.getSharedTripData(token))!; 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 () => { 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 { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Fully Populated' }); const place = createPlace(testDb, trip.id, { name: 'Fully Populated' });
testDb.prepare(`UPDATE places SET await updateRows(t, Places, { id: place.id }, {
description = 'A description', address = '1 Rue Test', price = 12.5, currency = 'EUR', 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', 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', image_url: '/uploads/pic.jpg', website: 'https://example.com', phone: '+33 1 23',
transport_mode = 'walk', transport_mode: 'walk',
reservation_status = 'confirmed', reservation_notes = 'Private booking note', reservation_datetime = '2026-01-01T09:00', 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', 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, route_geometry: '{"type":"LineString"}', route_color: '#ff0000', stop_type: 'hotel', fill_percent: 42,
source = 'import' source: 'import',
WHERE id = ?`).run(place.id); });
const data = (await svc.getSharedTripData(token))!; const data = (await svc.getSharedTripData(token))!;
const pool = (data.places as any[])[0]; const pool = (data.places as any[])[0];
@@ -659,8 +672,16 @@ describe('getSharedTripData redaction (#2320)', () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel' }); const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' }); 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) await insertRow(t, DayAccommodations, {
VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id); 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 data = (await svc.getSharedTripData(token))!;
const [stay] = data.accommodations as any[]; 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 () => { it('SHARE-SVC-022: returns null for an unknown or expired token', async () => {
expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull(); expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull();
const { trip, token } = await seedSharedTrip(); 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(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled(); 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 () => { it('SHARE-SVC-043 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => {
const { trip, token } = await seedSharedTrip(); const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id); 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(); 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(); 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 () => { it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => {
const { trip, token } = await seedSharedTrip({ share_map: false }); const { trip, token } = await seedSharedTrip({ share_map: false });
const place = createPlace(testDb, trip.id); 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(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled(); expect(serveKey).not.toHaveBeenCalled();
}); });
@@ -735,8 +756,7 @@ describe('getSharedPlacePhotoKey', () => {
const place = createPlace(testDb, trip.id); const place = createPlace(testDb, trip.id);
// Wikimedia pseudo-IDs contain characters that must round-trip encoded. // Wikimedia pseudo-IDs contain characters that must round-trip encoded.
const placeId = 'coords:48.8,2.3'; const placeId = 'coords:48.8,2.3';
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?') await updateRows(t, Places, { id: place.id }, { image_url: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes` });
.run(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, place.id);
serveKey.mockReturnValue('abc.jpg'); serveKey.mockReturnValue('abc.jpg');
expect(await svc.getSharedPlacePhotoKey(token, placeId)).toBe('abc.jpg'); expect(await svc.getSharedPlacePhotoKey(token, placeId)).toBe('abc.jpg');
expect(serveKey).toHaveBeenCalledWith(placeId); expect(serveKey).toHaveBeenCalledWith(placeId);