Files
TREK/server/tests/unit/nest/journey-share.service.test.ts
T
Maurice 5947631db6 test(server): seed the days, tours, assignments and journey share specs through the factories
The days and tours e2e suites, the assignments service suite and the
journey share suite build their fixtures through the ORM factories and
read back through findRow/findRows/countRows. Fixtures that pin ids (the
trip, day and places a session cookie or a route targets) keep them.
Readers that the old suites called synchronously (order, dayOrder,
viaAnchors, insertJourneyPhoto) are async and awaited, Promise.all where
a case compares several at once.

Left raw and marked: the legacy statements the parity cases hold the
repository reads to, the COLLATE NOCASE lookups that prove the token
match is exact, and total_changes(), which counts writes on the
connection itself.
2026-10-08 18:46:37 +02:00

1074 lines
52 KiB
TypeScript

/**
* Unit tests for JourneyShareService — JOURNEY-SHARE-001 through JOURNEY-SHARE-018.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// -- DB setup -----------------------------------------------------------------
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return mock;
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, createJourneyEntry, addJourneyContributor } from '../../helpers/factories';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { db as dbConn } from '../../../src/db/database';
import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
import type { TestOrm } from '../../helpers/test-orm';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import type { JourneyPublicGalleryRow, JourneyShareTokensRepository } from '../../../src/db/repositories/JourneyShareTokens.repository';
import type { JourneyEntriesRepository } from '../../../src/db/repositories/JourneyEntries.repository';
import type { JourneyEntryPhotosRepository } from '../../../src/db/repositories/JourneyEntryPhotos.repository';
import { GALLERY_CHRONOLOGICAL_ORDER } from '../../helpers/legacy-gallery-order';
import { countRows, findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../../helpers/factories/rows';
import { setAppSetting, setUserSetting } from '../../helpers/factories/settings';
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
import { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos.entity';
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
import { JourneyShareTokens } from '../../../src/db/entities/JourneyShareTokens.entity';
import { Journeys } from '../../../src/db/entities/Journeys.entity';
let svc: JourneyShareService;
let t: TestOrm;
// `t`, `uow` and the settings repositories all derive from the SAME
// `sharedTestOrm(testDb)` (task-2-review.md I2) — see settings.service.test.ts's
// own comment on this pattern for why a second, independent `createTestOrm`
// call here would be unsafe once `SettingsService`'s writes go through
// `uow.transactional(...)`.
beforeAll(async () => {
const uow = await createTestUnitOfWork(testDb);
t = await sharedTestOrm(testDb);
const journeysRepo = await createTestJourneysRepo(testDb);
svc = new JourneyShareService(
new JourneyDomainService(
new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), uow,
journeysRepo, await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb),
),
new SettingsService(uow, await createTestAppSettingsRepo(testDb), await createTestSettingsRepo(testDb)),
// Plan 3g Task 3: JourneyShareTokensRepository (JS1-JS15) + the
// already-built JourneysRepository (JS8/JS12), same instance the domain
// service above uses.
await createTestJourneyShareTokensRepo(testDb), journeysRepo,
// task-5-fix-brief constructor-ripple: `UnitOfWork` (L1's transactional
// create/update) + `JourneyPhotosRepository` (L2's `galleryRead` reuse).
uow, await createTestJourneyPhotosRepo(testDb),
// Plan 4 Task 8b constructor-ripple: `JourneyEntriesRepository` (JS13) +
// `JourneyEntryPhotosRepository` (JS14), relocated off
// `JourneyShareTokensRepository`'s own fallback stub.
await createTestJourneyEntriesRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb),
);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
// -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
async function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
): Promise<number> {
const provider = opts.assetId ? 'immich' : 'local';
const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null;
const trekId = await insertRow(t, TrekPhotos, {
provider,
asset_id: opts.assetId ?? null,
file_path: filePath,
owner: opts.ownerId ?? null,
// Epoch milliseconds, as these fixtures always stored them; the DATETIME
// column's numeric affinity keeps the digits an integer.
created_at: String(Date.now()),
});
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = await findRow(t, JourneyEntries, { id: entryId });
if (!entryRow) throw new Error(`no journey entry ${entryId}`);
const journeyId = entryRow.journey_id;
const now = Date.now();
await insertRowIgnoringConflict(t, JourneyPhotos, { journey: journeyId, photo: trekId, caption: null, sort_order: 0, created_at: now });
const galleryRow = await findRow(t, JourneyPhotos, { journey: journeyId, photo: trekId });
if (!galleryRow) throw new Error(`no gallery row for photo ${trekId}`);
await insertRowIgnoringConflict(t, JourneyEntryPhotos, { entry: entryId, journeyPhoto: galleryRow.id, sort_order: 0, created_at: now });
// Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId;
}
// -- Tests --------------------------------------------------------------------
describe('createOrUpdateJourneyShareLink', () => {
it('JOURNEY-SHARE-001: creates a new share link with default permissions', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(result.created).toBe(true);
expect(result.token).toBeTruthy();
expect(result.token.length).toBeGreaterThan(10);
});
it('JOURNEY-SHARE-002: creates a share link with custom permissions', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
});
const link = await svc.getJourneyShareLink(journey.id);
expect(link).not.toBeNull();
expect(link!.share_timeline).toBe(true);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const first = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: true,
});
const second = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
});
expect(second.created).toBe(false);
expect(second.token).toBe(first.token);
const link = await svc.getJourneyShareLink(journey.id);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
it('JOURNEY-SHARE-029: reading the link separates "no link" from "not yours"', async () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, helper.id, 'editor');
// Nothing published yet: an owner is allowed to look and finds nothing.
expect(await svc.readJourneyShareLink(journey.id, owner.id)).toEqual({ allowed: true, link: null });
await svc.createOrUpdateJourneyShareLink(journey.id, owner.id, {});
const asOwner = await svc.readJourneyShareLink(journey.id, owner.id);
expect(asOwner.allowed).toBe(true);
expect(asOwner.allowed && asOwner.link?.token).toBeTruthy();
// An editor is refused outright. Answering { link: null } here would tell a
// published journey's editor it is unpublished and offer to publish it.
expect(await svc.readJourneyShareLink(journey.id, helper.id)).toEqual({ allowed: false });
});
it('JOURNEY-SHARE-027: an update leaves out flags it was not given', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
newest_first: true,
});
// A caller that only flips the timeline must not silently re-publish the
// gallery and map at the unchanged token.
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false });
const link = await svc.getJourneyShareLink(journey.id);
expect(link!.share_timeline).toBe(false);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
expect(link!.newest_first).toBe(true);
});
it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(false);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true });
expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(true);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false });
expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(true);
});
it('JOURNEY-SHARE-004: different journeys get different tokens', async () => {
const { user } = createUser(testDb);
const j1 = createJourney(testDb, user.id);
const j2 = createJourney(testDb, user.id);
const r1 = await svc.createOrUpdateJourneyShareLink(j1.id, user.id, {});
const r2 = await svc.createOrUpdateJourneyShareLink(j2.id, user.id, {});
expect(r1.token).not.toBe(r2.token);
});
// L1 (task-5-review.md) — two concurrent FIRST creates for the same
// journey used to race the existing-link read against the insert: the
// loser hit `UNIQUE(journey_id)` and threw (a 500 over HTTP) instead of
// the base's `{created:false}`. Races two real `Promise.all` callers
// through the actual service on the shared connection.
it('JOURNEY-SHARE-L1: two concurrent first-creates for the same journey — one created, one {created:false}, no throw', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const [a, b] = await Promise.all([
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true }),
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false }),
]);
const results = [a, b];
const created = results.filter((r) => r!.created);
const notCreated = results.filter((r) => !r!.created);
expect(created).toHaveLength(1);
expect(notCreated).toHaveLength(1);
// Both callers agree on the one token that exists.
expect(a!.token).toBe(b!.token);
expect(await countRows(t, JourneyShareTokens, { journey: journey.id })).toBe(1);
});
});
describe('getJourneyShareLink', () => {
it('JOURNEY-SHARE-005: returns null when no share link exists', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = await svc.getJourneyShareLink(journey.id);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-006: returns share link info when it exists', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: true,
});
const result = await svc.getJourneyShareLink(journey.id);
expect(result).not.toBeNull();
expect(result!.token).toBeTruthy();
expect(result!.share_timeline).toBe(true);
expect(result!.share_gallery).toBe(false);
expect(result!.share_map).toBe(true);
expect(result!.created_at).toBeTruthy();
});
});
describe('deleteJourneyShareLink', () => {
it('JOURNEY-SHARE-007: owner can remove an existing share link', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const ok = await svc.deleteJourneyShareLink(journey.id, user.id);
expect(ok).toBe(true);
expect(await svc.getJourneyShareLink(journey.id)).toBeNull();
});
it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await expect(svc.deleteJourneyShareLink(journey.id, user.id)).resolves.toBe(true);
});
});
describe('validateShareTokenForPhoto', () => {
it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.journeyId).toBe(journey.id);
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-010: returns null for invalid token', async () => {
const result = await svc.validateShareTokenForPhoto('nonexistent-token', 1);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', async () => {
const { user } = createUser(testDb);
const journey1 = createJourney(testDb, user.id);
const journey2 = createJourney(testDb, user.id);
const entry2 = createJourneyEntry(testDb, journey2.id, user.id);
const photoId = await insertJourneyPhoto(entry2.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {});
const result = await svc.validateShareTokenForPhoto(token, photoId);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = await insertJourneyPhoto(entry.id, { ownerId: undefined });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
// Regression — GHSA-9hc8 sibling: the byte proxy must honour share_gallery.
it('JOURNEY-SHARE-017: returns null when the owner disabled the gallery (share_gallery=false)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(await svc.validateShareTokenForPhoto(token, photoId)).toBeNull();
});
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', async () => {
// Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
// trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
// instead of jp.photo_id, causing a 404 for Immich photos in public shares.
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
// Pre-populate trek_photos to push the autoincrement higher
for (let i = 0; i < 5; i++) {
await insertRow(t, TrekPhotos, { provider: 'immich', asset_id: `bulk-asset-${i}`, owner: user.id, created_at: String(Date.now()) });
}
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
const trekPhotoId = await insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// photoId = trek_photos.id (6), not journey_photos.id (1)
const result = await svc.validateShareTokenForPhoto(token, trekPhotoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
expect(result!.journeyId).toBe(journey.id);
});
});
describe('validateShareTokenForAsset', () => {
it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
await insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.validateShareTokenForAsset(token, 'immich-asset-123');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-014: returns null for invalid token', async () => {
const result = await svc.validateShareTokenForAsset('bad-token', 'some-asset');
expect(result).toBeNull();
});
// Regression — GHSA-9hc8 sibling: the asset proxy must honour share_gallery.
it('JOURNEY-SHARE-018: returns null when the owner disabled the gallery (share_gallery=false)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
await insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(await svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull();
});
it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
await insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// Without the fallback the controller filled the gap from the :ownerId path
// segment, i.e. an anonymous caller picked whose provider credentials to try.
const result = await svc.validateShareTokenForAsset(token, 'immich-asset-orphan');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// A valid share token must NOT resolve arbitrary asset IDs to the owner —
// otherwise it could proxy any asset out of the owner's Immich/Synology
// library (IDOR). Only assets actually in the journey may resolve.
const result = await svc.validateShareTokenForAsset(token, 'nonexistent-asset');
expect(result).toBeNull();
});
// M5a (task-5-review.md) — JOURNEY-SHARE-015 above only tries an asset id
// that does not exist at all; it stays green even with `gp.journey_id = ?`
// dropped from `findAssetForValidation` (mutation M23), because the join
// still fails to find a row. This test uses an asset id that IS real and
// gallery-linked, just to a DIFFERENT journey, so a dropped scope check
// would resolve it (leaking a cross-journey asset through a valid token)
// where JOURNEY-SHARE-015 cannot catch that.
it('JOURNEY-SHARE-M5A: denies an asset that is real but belongs to a DIFFERENT journey (findAssetForValidation journey scope)', async () => {
const { user } = createUser(testDb);
const sharedJourney = createJourney(testDb, user.id);
const otherJourney = createJourney(testDb, user.id);
const otherEntry = createJourneyEntry(testDb, otherJourney.id, user.id);
await insertJourneyPhoto(otherEntry.id, { assetId: 'other-journey-asset', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(sharedJourney.id, user.id, {});
const result = await svc.validateShareTokenForAsset(token, 'other-journey-asset');
expect(result).toBeNull();
});
});
describe('getPublicJourney', () => {
it('JOURNEY-SHARE-016: returns null for invalid token', async () => {
const result = await svc.getPublicJourney('invalid-token');
expect(result).toBeNull();
});
it('JOURNEY-SHARE-017: returns journey data with entries, stats, and permissions', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, {
title: 'Japan 2026',
subtitle: 'Cherry blossom season',
});
const entry1 = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
title: 'Arrived in Tokyo',
entry_date: '2026-03-20',
location_name: 'Tokyo',
});
createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
title: 'Kyoto Day Trip',
entry_date: '2026-03-22',
location_name: 'Kyoto',
});
await insertJourneyPhoto(entry1.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: false,
});
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.journey.title).toBe('Japan 2026');
expect(result!.journey.subtitle).toBe('Cherry blossom season');
// The "this journey does not use that field" switches travel with the share:
// the phone card reads them, and reading an absent one made every field look
// switched on, so a reader saw chips the owner had turned off.
expect(result!.journey).toMatchObject({ show_verdict: 1, show_mood: 1, show_weather: 1 });
expect(result!.entries).toHaveLength(2);
expect(result!.stats.entries).toBe(2);
expect(result!.stats.photos).toBe(1);
expect(result!.stats.places).toBe(2);
expect(result!.permissions.share_timeline).toBe(true);
expect(result!.permissions.share_gallery).toBe(true);
expect(result!.permissions.share_map).toBe(false);
});
it('JOURNEY-SHARE-017b: a field the owner switched off is switched off for the reader too', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await updateRows(t, Journeys, { id: journey.id }, { show_mood: 0, show_weather: 0 });
createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Tag 1', entry_date: '2026-03-20' });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = (await svc.getPublicJourney(token))!;
expect(result.journey).toMatchObject({ show_mood: 0, show_weather: 0, show_verdict: 1 });
});
it('JOURNEY-SHARE-018: excludes skeleton entries from public view', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
title: 'Visible Entry',
entry_date: '2026-01-10',
});
createJourneyEntry(testDb, journey.id, user.id, {
type: 'skeleton',
title: 'Skeleton Entry',
entry_date: '2026-01-11',
});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toHaveLength(1);
expect(result!.entries[0].title).toBe('Visible Entry');
});
it('JOURNEY-SHARE-018b: leaves drafts and the photos only they hold off the public page (#696)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const published = createJourneyEntry(testDb, journey.id, user.id, { title: 'Published', entry_date: '2026-01-10' });
const draft = createJourneyEntry(testDb, journey.id, user.id, { title: 'Draft', entry_date: '2026-01-11' });
await updateRows(t, JourneyEntries, { id: draft.id }, { is_draft: 1 });
const shownPhoto = await insertJourneyPhoto(published.id, { ownerId: user.id });
const draftPhoto = await insertJourneyPhoto(draft.id, { ownerId: user.id });
// A photo on both a draft and a published entry is published.
const sharedPhoto = await insertJourneyPhoto(draft.id, { ownerId: user.id, filePath: '/photos/both.jpg' });
const sharedRow = await findRow(t, JourneyPhotos, { photo: sharedPhoto });
if (!sharedRow) throw new Error('the shared photo should be in the gallery');
await insertRow(t, JourneyEntryPhotos, { entry: published.id, journeyPhoto: sharedRow.id, sort_order: 1, created_at: 0 });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true });
const result = (await svc.getPublicJourney(token))!;
expect(result.entries.map((e: any) => e.title)).toEqual(['Published']);
expect(result.stats.entries).toBe(1);
const galleryIds = (result as any).gallery.map((p: any) => p.photo_id).sort();
expect(galleryIds).toEqual([shownPhoto, sharedPhoto].sort());
expect(await svc.validateShareTokenForPhoto(token, draftPhoto)).toBeNull();
expect(await svc.validateShareTokenForPhoto(token, shownPhoto)).not.toBeNull();
});
it('JOURNEY-SHARE-019: enriches entries with parsed tags and photos', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
entry_date: '2026-04-01',
});
// Set tags on the entry directly
await updateRows(t, JourneyEntries, { id: entry.id }, { tags: JSON.stringify(['food', 'culture']) });
await insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' });
await insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
const enriched = result!.entries[0];
expect(enriched.tags).toEqual(['food', 'culture']);
expect(enriched.photos).toHaveLength(2);
});
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Empty Journey' });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toEqual([]);
expect(result!.stats.entries).toBe(0);
expect(result!.stats.photos).toBe(0);
expect(result!.stats.places).toBe(0);
});
it('JOURNEY-SHARE-021: withholds timeline, gallery and GPS when all flags are off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Secret' });
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris',
});
await updateRows(t, JourneyEntries, { id: entry.id }, { location_lat: 48.8566, location_lng: 2.3522 });
await insertJourneyPhoto(entry.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: false,
});
const result = (await svc.getPublicJourney(token))!;
expect(result.entries).toEqual([]); // no timeline / story / GPS leaked
expect(result.gallery).toEqual([]); // no gallery leaked
expect(result.stats.entries).toBe(1); // counts stay accurate
});
it('JOURNEY-SHARE-022: shares the timeline but strips GPS when the map flag is off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', location_name: 'Paris',
});
await updateRows(t, JourneyEntries, { id: entry.id }, { location_lat: 48.8566, location_lng: 2.3522 });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = (await svc.getPublicJourney(token))!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.story).toBe('notes'); // narrative present
expect(e.location_lat).toBeNull(); // GPS withheld
expect(e.location_lng).toBeNull();
});
it('JOURNEY-SHARE-023: map-only share exposes coordinates but not the story', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris',
});
await updateRows(t, JourneyEntries, { id: entry.id }, { location_lat: 48.8566, location_lng: 2.3522 });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: true,
});
const result = (await svc.getPublicJourney(token))!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.location_lat).toBe(48.8566); // coords for the map
expect(e.story).toBeUndefined(); // narrative withheld
});
// #1614 — a photo now carries the coordinates it was taken at. That is a place
// the owner never typed, so it has to follow the same switch the entry
// coordinates follow rather than riding in on the gallery flag.
it('JOURNEY-SHARE-025: withholds photo capture coordinates when the map flag is off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
const trekId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
await updateRows(t, TrekPhotos, { id: trekId }, { lat: 48.8584, lng: 2.2945, taken_at: '2026-05-01T10:00:00Z' });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = (await svc.getPublicJourney(token))!;
const gallery = result.gallery as JourneyPublicGalleryRow[];
expect(gallery).toHaveLength(1);
expect(gallery[0].lat).toBeNull();
expect(gallery[0].lng).toBeNull();
// The capture time is not a location and stays — it is what a blog view sorts by.
expect(gallery[0].taken_at).toBe('2026-05-01T10:00:00Z');
const inline = (result.entries[0] as Record<string, unknown>).photos as Record<string, unknown>[];
expect(inline[0].lat).toBeNull();
expect(inline[0].lng).toBeNull();
});
it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', entry_date: '2026-05-01',
});
const trekId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
await updateRows(t, TrekPhotos, { id: trekId }, { lat: 48.8584, lng: 2.2945 });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = (await svc.getPublicJourney(token))!.gallery as JourneyPublicGalleryRow[];
expect(gallery[0].lat).toBe(48.8584);
expect(gallery[0].lng).toBe(2.2945);
});
it('JOURNEY-SHARE-024: strips inline entry photos (and their asset metadata) when the gallery is off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
await insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = (await svc.getPublicJourney(token))!;
expect(result.gallery).toEqual([]); // gallery array withheld
expect(result.entries).toHaveLength(1);
expect((result.entries[0] as Record<string, unknown>).photos).toEqual([]); // inline photos withheld too
});
// #2200: the reader of a shared journey gets the same chronology as the owner,
// so the public gallery cannot fall back to upload order.
it('JOURNEY-SHARE-031: the public gallery reads in capture order, not upload order', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const day1 = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', entry_date: '2026-05-01',
});
const day2 = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 2', entry_date: '2026-05-02',
});
const late = await insertJourneyPhoto(day2.id, { filePath: '/photos/day2.jpg' });
await insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' });
await updateRows(t, TrekPhotos, { id: late }, { taken_at: '2026-05-02T16:00:00.000Z' });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = (await svc.getPublicJourney(token))!.gallery as JourneyPublicGalleryRow[];
expect(gallery.map(p => p.file_path)).toEqual(['/photos/day1.jpg', '/photos/day2.jpg']);
});
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('');
await setAppSetting(t, 'default_user_setting_carto_api_key', 'instance-key');
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('instance-key');
await setUserSetting(t, user.id, 'carto_api_key', ' owner-key ');
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('owner-key');
});
});
// -- Parity: JourneyShareTokensRepository reads vs. the legacy raw statement --
describe('parity — JourneyShareTokensRepository reads match the legacy statement run raw', () => {
let shareTokensRepo: JourneyShareTokensRepository;
// L2 — JS15 moved to `JourneyPhotosRepository.galleryRead`; P06 below now
// pins that method's output against the legacy statement instead of the
// (now-deleted) `JourneyShareTokensRepository.listGalleryForPublicJourney`.
let journeyPhotosRepo: Awaited<ReturnType<typeof createTestJourneyPhotosRepo>>;
// Plan 4 Task 8b — JS7/JS10/JS13/JS14 relocated the same way JS15 was: P04
// and P05 below now pin `JourneyEntriesRepository.listPublicEntries` and
// `JourneyEntryPhotosRepository.listForPublicJourney` instead of the
// (now-deleted) `JourneyShareTokensRepository` fallback stub methods.
let journeyEntriesRepo: JourneyEntriesRepository;
let journeyEntryPhotosRepo: JourneyEntryPhotosRepository;
beforeAll(async () => {
shareTokensRepo = await createTestJourneyShareTokensRepo(testDb);
journeyPhotosRepo = await createTestJourneyPhotosRepo(testDb);
journeyEntriesRepo = await createTestJourneyEntriesRepo(testDb);
journeyEntryPhotosRepo = await createTestJourneyEntryPhotosRepo(testDb);
});
it('JOURNEY-SHARE-P01: findFlagsByJourneyId (JS1) matches the legacy 5-column read', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: true, share_map: false, newest_first: true,
});
const legacy = testDb
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
.prepare('SELECT token, share_timeline, share_gallery, share_map, newest_first FROM journey_share_tokens WHERE journey_id = ?')
.get(journey.id);
expect(await shareTokensRepo.findFlagsByJourneyId(journey.id)).toEqual(legacy);
});
it('JOURNEY-SHARE-P02: findByJourneyId (JS4) and findByToken (JS11) match the legacy `SELECT *` reads', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true, newest_first: false,
});
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyByJourney = testDb.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journey.id);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyByToken = testDb.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token);
expect(await shareTokensRepo.findByJourneyId(journey.id)).toEqual(legacyByJourney);
expect(await shareTokensRepo.findByToken(token)).toEqual(legacyByToken);
});
it('JOURNEY-SHARE-P03: findAccessByToken (JS6/JS9) matches the legacy narrow projection', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false });
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacy = testDb.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token);
expect(await shareTokensRepo.findAccessByToken(token)).toEqual(legacy);
});
it('JOURNEY-SHARE-P04: listPublicEntries (JS13) matches the legacy statement for entries with and without GPS', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01', location_name: 'No GPS' });
const withGps = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-02', location_name: 'Has GPS' });
await updateRows(t, JourneyEntries, { id: withGps.id }, { location_lat: 48.85, location_lng: 2.35 });
// A skeleton and a dismissed entry — both must be excluded, same as the legacy WHERE.
createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', entry_date: '2026-01-03' });
const dismissed = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-04' });
await updateRows(t, JourneyEntries, { id: dismissed.id }, { dismissed: 1 });
const legacy = testDb
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton' AND je.dismissed = 0
ORDER BY je.entry_date, je.sort_order
`)
.all(journey.id);
expect(await journeyEntriesRepo.listPublicEntries(journey.id)).toEqual(legacy);
});
it('JOURNEY-SHARE-P05: JourneyEntryPhotosRepository.listForPublicJourney (JS14) matches the legacy JP_SELECT-shaped statement', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
await insertJourneyPhoto(entry.id, { ownerId: user.id });
const legacy = testDb
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY jep.sort_order
`)
.all(journey.id);
expect(await journeyEntryPhotosRepo.listForPublicJourney(journey.id)).toEqual(legacy);
});
it('JOURNEY-SHARE-P06: JourneyPhotosRepository.galleryRead (JS15, GALLERY_CHRONOLOGICAL_ORDER) matches the legacy statement — a photo linked to an entry AND an unattached gallery photo', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
await insertJourneyPhoto(entry.id, { ownerId: user.id, filePath: '/photos/linked.jpg' }); // linked to an entry
// An unattached gallery photo — uploaded straight to the gallery, no journey_entry_photos row.
const unattachedTrekId = await insertRow(t, TrekPhotos, {
provider: 'local',
asset_id: null,
owner: user.id,
file_path: '/photos/unattached.jpg',
created_at: String(Date.now()),
});
await insertRow(t, JourneyPhotos, { journey: journey.id, photo: unattachedTrekId, sort_order: 1, created_at: Date.now() });
const legacy = testDb
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
FROM journey_photos gp
JOIN trek_photos tp ON tp.id = gp.photo_id
WHERE gp.journey_id = ?
${GALLERY_CHRONOLOGICAL_ORDER}
`)
.all(journey.id);
expect(await journeyPhotosRepo.galleryRead(journey.id)).toEqual(legacy);
expect(legacy).toHaveLength(2);
});
it('JOURNEY-SHARE-P07: every true/false combination of the three share flags (+ newest_first) round-trips through the converted read exactly', async () => {
const { user } = createUser(testDb);
for (const share_timeline of [true, false]) {
for (const share_gallery of [true, false]) {
for (const share_map of [true, false]) {
for (const newest_first of [true, false]) {
const journey = createJourney(testDb, user.id);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline, share_gallery, share_map, newest_first });
const link = await svc.getJourneyShareLink(journey.id);
expect(link).toEqual({
token: link!.token,
created_at: link!.created_at,
share_timeline, share_gallery, share_map, newest_first,
});
}
}
}
}
});
});
// -- R4: the six anonymous share-token statements — exact-match only ---------
describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-case / NUL)', () => {
it('JOURNEY-SHARE-R01: validateShareTokenForPhoto 404s (null) for a revoked token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
await svc.deleteJourneyShareLink(journey.id, user.id);
expect(await svc.validateShareTokenForPhoto(token, photoId)).toBeNull();
});
it('JOURNEY-SHARE-R02: validateShareTokenForAsset 404s (null) for a revoked token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
await insertJourneyPhoto(entry.id, { assetId: 'revoked-asset', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
await svc.deleteJourneyShareLink(journey.id, user.id);
expect(await svc.validateShareTokenForAsset(token, 'revoked-asset')).toBeNull();
});
it('JOURNEY-SHARE-R03: getPublicJourney 404s (null) for a revoked token, not a stale cached response', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(await svc.getPublicJourney(token)).not.toBeNull();
await svc.deleteJourneyShareLink(journey.id, user.id);
expect(await svc.getPublicJourney(token)).toBeNull();
});
it('JOURNEY-SHARE-R04: validateShareTokenForPhoto does not match a differently-cased token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const wrongCase = flipCase(token);
expect(await svc.validateShareTokenForPhoto(wrongCase, photoId)).toBeNull();
// Mutation proof (loosen → red): a COLLATE NOCASE lookup on the SAME row
// WOULD wrongly match — proving the null above depends on the real
// exact-match WHERE, not a coincidence of the fixture.
// test-sql-allow: a COLLATE NOCASE lookup is the mutation this pins against, so it has to run as that SQL.
expect(testDb.prepare('SELECT 1 FROM journey_share_tokens WHERE token = ? COLLATE NOCASE').get(wrongCase)).toBeTruthy();
});
it('JOURNEY-SHARE-R05: validateShareTokenForAsset does not match a differently-cased token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
await insertJourneyPhoto(entry.id, { assetId: 'case-asset', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const wrongCase = flipCase(token);
expect(await svc.validateShareTokenForAsset(wrongCase, 'case-asset')).toBeNull();
// test-sql-allow: a COLLATE NOCASE lookup is the mutation this pins against, so it has to run as that SQL.
expect(testDb.prepare('SELECT 1 FROM journey_share_tokens WHERE token = ? COLLATE NOCASE').get(wrongCase)).toBeTruthy();
});
it('JOURNEY-SHARE-R06: getPublicJourney does not match a differently-cased token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const wrongCase = flipCase(token);
expect(await svc.getPublicJourney(wrongCase)).toBeNull();
// test-sql-allow: a COLLATE NOCASE lookup is the mutation this pins against, so it has to run as that SQL.
expect(testDb.prepare('SELECT 1 FROM journey_share_tokens WHERE token = ? COLLATE NOCASE').get(wrongCase)).toBeTruthy();
});
it('JOURNEY-SHARE-R07: validateShareTokenForPhoto does not match a token with an embedded NUL byte', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(await svc.validateShareTokenForPhoto(`${token}\0trailing`, photoId)).toBeNull();
expect(await svc.validateShareTokenForPhoto(`${token.slice(0, 5)}\0${token.slice(5)}`, photoId)).toBeNull();
});
it('JOURNEY-SHARE-R08: validateShareTokenForAsset does not match a token with an embedded NUL byte', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
await insertJourneyPhoto(entry.id, { assetId: 'nul-asset', ownerId: user.id });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(await svc.validateShareTokenForAsset(`${token}\0trailing`, 'nul-asset')).toBeNull();
});
it('JOURNEY-SHARE-R09: getPublicJourney does not match a token with an embedded NUL byte', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(await svc.getPublicJourney(`${token}\0trailing`)).toBeNull();
});
});
/** A token with at least one letter flipped in case — base64url alphabet, near-certain to differ. */
function flipCase(token: string): string {
const upper = token.toUpperCase();
return upper !== token ? upper : token.toLowerCase();
}