mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
2d7cb124b3
A trip can be bound to a folder, tag or space in a self-hosted document store, and documents then move in both directions: what someone uploads in TREK appears there, and what someone files there appears in TREK. Three decisions shape the rest. The connection belongs to the TRIP, not to a person. Every other integration here is per user, and photos are shared only through an opt-in flag; neither can deliver "everyone on the trip sees the same documents", because visibility would depend on whose credentials fetched a file, and a member without their own Paperless account would see nothing. So the trip owner binds the trip once, the server talks to the provider under that one identity, and TREK's own membership decides who sees what. The provider never learns that TREK has members. When the credential holder leaves the trip the binding goes to `orphaned` rather than quietly carrying on with an ex-member's token. TREK keeps the bytes. A synced document is an ordinary trip_files row, so download, trash, offline cache, PDF export and backup keep working untouched, and a provider outage does not blank out a trip that starts tomorrow. Deletion never crosses the boundary on its own. A document that disappears upstream is recorded as missing and waits for a human, and a run that sees more than 40 percent of its known documents vanish at once refuses the whole listing rather than acting on it: an unmounted share answers exactly like a mass delete. The core reconciles by enumerating both sides, because the weakest provider sets the model. A changed-since query cannot see a deletion, Papra does not move updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and Synology FileStation has no change feed at all. A webhook is therefore only ever "look now", never a source of truth. Echo is caught by comparing content, not timestamps: pushed_sha256 records what TREK itself wrote, so a provider reporting TREK's own upload is not mistaken for a stranger's edit. Provider differences are answered by capability data rather than by branching on an id: stable ids where they exist (Paperless document id, oc:fileid, driveId) and a normalised path where they do not, replace-in-place where the API offers it, and a content hash from the listing only where the provider really supplies one. Admins switch providers on under Admin, Addons, Documents, mirroring how photo providers hang off Journey, including the cascade and the 409 when the parent addon is off. The per-trip setup lives in the file manager, where the person has the context that makes the folder choice obvious.
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
/**
|
|
* Document sync e2e — drives /api/trips/:tripId/docsync through the REAL
|
|
* AddonGuard, JwtAuthGuard and TripAccessGuard, over the real DI services
|
|
* against a temp SQLite carrying the full schema.
|
|
*
|
|
* Two things are replaced and nothing else:
|
|
*
|
|
* - `AddonsService.isAddonEnabled`, because the gate is what is under test;
|
|
* - every provider adapter, because a test must never open a socket to
|
|
* somebody's document archive. Each one is a spy that fails loudly if a
|
|
* route this file exercises turns out to reach a provider after all.
|
|
*
|
|
* What is pinned here is the access model, which is the part of this feature
|
|
* that would hurt most if it drifted: the addon gate answering 404 before 401,
|
|
* a stranger being unable to see that a trip exists, and a plain member being
|
|
* able to READ where their documents go while only the owner may change it.
|
|
* That last split is unusual for TREK — most trip routes treat members alike —
|
|
* so it is worth a test that fails if someone "tidies" it away.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { Test } from '@nestjs/testing';
|
|
import { sessionCookie } from './harness';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
tmp.exec('PRAGMA foreign_keys = ON');
|
|
return { db: tmp };
|
|
});
|
|
|
|
vi.mock('../../src/db/database', () => ({
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: number | string, 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: () => false,
|
|
}));
|
|
|
|
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
|
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
|
|
|
/**
|
|
* The SSRF guard resolves DNS, and `paperless.example.com` does not exist — in
|
|
* CI that is a lookup failure, which the guard correctly reports as "not
|
|
* allowed". The guard is not what this file is testing, and a suite that needs
|
|
* a working resolver is a suite that fails on someone's train.
|
|
*/
|
|
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/utils/ssrfGuard')>();
|
|
return {
|
|
...actual,
|
|
checkSsrf: vi.fn(async (url: string) => ({
|
|
allowed: !url.includes('blocked.invalid'),
|
|
isPrivate: false,
|
|
error: url.includes('blocked.invalid') ? 'blocked in test' : undefined,
|
|
})),
|
|
};
|
|
});
|
|
|
|
import { createTables } from '../../src/db/schema';
|
|
import { runMigrations } from '../../src/db/migrations';
|
|
import { createTrip, createUser } from '../helpers/factories';
|
|
import { DocSyncModule } from '../../src/nest/doc-sync/doc-sync.module';
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { AddonsService } from '../../src/nest/addons/addons.service';
|
|
import { DOCUMENT_PROVIDERS } from '../../src/nest/doc-sync/document-provider';
|
|
import { PaperlessDocumentProvider } from '../../src/nest/doc-sync/providers/paperless.provider';
|
|
import { PapraProvider } from '../../src/nest/doc-sync/providers/papra.provider';
|
|
import { SynologyDriveProvider } from '../../src/nest/doc-sync/providers/synology-drive.provider';
|
|
import { NextcloudDocumentProvider, OpencloudDocumentProvider } from '../../src/nest/doc-sync/providers/webdav.provider';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
|
|
/** A provider that answers plausibly but never opens a socket. */
|
|
function fakeProvider(id: string) {
|
|
const caps = {
|
|
push: 'none' as const,
|
|
stableId: true,
|
|
remoteTrash: true,
|
|
replaceInPlace: true,
|
|
contentHashInListing: true,
|
|
maxUploadBytes: null,
|
|
acceptedMimeTypes: null,
|
|
canCreateScope: true,
|
|
};
|
|
return {
|
|
id,
|
|
capabilities: () => caps,
|
|
probe: vi.fn(async () => ({ success: true as const, data: { account: `${id}-account`, capabilities: caps } })),
|
|
listScopes: vi.fn(async () => ({
|
|
success: true as const,
|
|
data: [{ scopeKey: `tag:1`, label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' }],
|
|
})),
|
|
createScope: vi.fn(async (_c: unknown, name: string) => ({
|
|
success: true as const,
|
|
data: { scopeKey: `tag:2`, label: name, remoteRootId: '2', remoteRootPath: `/TREK/${name}` },
|
|
})),
|
|
resolveScope: vi.fn(async () => ({
|
|
success: true as const,
|
|
data: { scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' },
|
|
})),
|
|
list: vi.fn(async () => ({ success: true as const, data: { documents: [], cursor: 'c1', cursorUnchanged: false, truncated: false } })),
|
|
fetch: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
|
|
push: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
|
|
rename: vi.fn(async () => ({ success: true as const, data: { remoteVersion: 'v2' } })),
|
|
trash: vi.fn(async () => ({ success: true as const, data: undefined })),
|
|
};
|
|
}
|
|
|
|
describe('Document sync e2e (real guards + real services + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let ownerId: number;
|
|
let memberId: number;
|
|
let strangerId: number;
|
|
let tripId: number;
|
|
|
|
async function build() {
|
|
const providers = [
|
|
[PaperlessDocumentProvider, 'paperless'],
|
|
[PapraProvider, 'papra'],
|
|
[NextcloudDocumentProvider, 'nextcloud'],
|
|
[OpencloudDocumentProvider, 'opencloud'],
|
|
[SynologyDriveProvider, 'synologydrive'],
|
|
] as const;
|
|
let builder = Test.createTestingModule({ imports: [DatabaseModule, DocSyncModule] })
|
|
.overrideProvider(AddonsService)
|
|
.useValue({ isAddonEnabled });
|
|
const fakes = providers.map(([, id]) => fakeProvider(id));
|
|
providers.forEach(([token], i) => {
|
|
builder = builder.overrideProvider(token).useValue(fakes[i]);
|
|
});
|
|
builder = builder.overrideProvider(DOCUMENT_PROVIDERS).useValue(fakes);
|
|
const moduleRef = await builder.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
createTables(db as never);
|
|
runMigrations(db as never);
|
|
ownerId = createUser(db as never, { username: 'owner', email: 'owner@test.local' }).user.id;
|
|
memberId = createUser(db as never, { username: 'member', email: 'member@test.local' }).user.id;
|
|
strangerId = createUser(db as never, { username: 'stranger', email: 'stranger@test.local' }).user.id;
|
|
|
|
tripId = createTrip(db as never, ownerId, { title: 'Japan', start_date: '2026-10-01', end_date: '2026-10-07' }).id;
|
|
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, memberId);
|
|
// Providers are off by default, exactly as a fresh install ships them.
|
|
db.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud')").run();
|
|
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
db.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
isAddonEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('answers 404 rather than 401 when the addon is off, to everyone alike', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
await request(server).get(`/api/trips/${tripId}/docsync/status`).expect(404);
|
|
await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/status`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.expect(404);
|
|
});
|
|
|
|
it('refuses an unauthenticated caller', async () => {
|
|
await request(server).get(`/api/trips/${tripId}/docsync/status`).expect(401);
|
|
});
|
|
|
|
it('hides the trip from a stranger behind a 404', async () => {
|
|
await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/status`)
|
|
.set('Cookie', sessionCookie(strangerId))
|
|
.expect(404);
|
|
});
|
|
|
|
it('lists only the providers an admin switched on', async () => {
|
|
const res = await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/providers`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.expect(200);
|
|
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(['nextcloud', 'paperless']);
|
|
// Every listed provider carries its form fields, so the client renders the
|
|
// connection form from data rather than from a hard-coded list.
|
|
expect(res.body[0].fields.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('lets a plain member read the status but not change the binding', async () => {
|
|
await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/status`)
|
|
.set('Cookie', sessionCookie(memberId))
|
|
.expect(200);
|
|
|
|
await request(server)
|
|
.put(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(memberId))
|
|
.send({ providerId: 'paperless', baseUrl: 'https://paperless.example.com', credentials: { api_token: 'x' } })
|
|
.expect(403);
|
|
});
|
|
|
|
it('refuses a provider the admin has not switched on', async () => {
|
|
await request(server)
|
|
.put(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({ providerId: 'papra', baseUrl: 'https://papra.example.com', credentials: { api_key: 'x', organization_id: 'org_1' } })
|
|
.expect(400);
|
|
});
|
|
|
|
it('rejects an unknown provider id at the contract, not in the handler', async () => {
|
|
await request(server)
|
|
.put(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({ providerId: 'dropbox', baseUrl: 'https://example.com', credentials: {} })
|
|
.expect(400);
|
|
});
|
|
|
|
it('stores a connection and never gives the secret back', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({
|
|
providerId: 'paperless',
|
|
baseUrl: 'https://paperless.example.com/',
|
|
credentials: { api_token: 'super-secret-token' },
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body.baseUrl).toBe('https://paperless.example.com');
|
|
expect(res.body.secrets.api_token).toBe('••••••••');
|
|
expect(JSON.stringify(res.body)).not.toContain('super-secret-token');
|
|
|
|
// And it is encrypted at rest, not merely hidden on the way out.
|
|
const row = db.prepare('SELECT secrets FROM document_connections WHERE trip_id = ?').get(tripId) as { secrets: string };
|
|
expect(row.secrets).toMatch(/^enc:v1:/);
|
|
expect(row.secrets).not.toContain('super-secret-token');
|
|
});
|
|
|
|
it('keeps the stored secret when the form comes back with a blank field', async () => {
|
|
await request(server)
|
|
.put(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({ providerId: 'paperless', baseUrl: 'https://paperless.example.com', credentials: { api_token: '' } })
|
|
.expect(200);
|
|
const row = db.prepare('SELECT secrets FROM document_connections WHERE trip_id = ?').get(tripId) as { secrets: string };
|
|
expect(row.secrets).toMatch(/^enc:v1:/);
|
|
});
|
|
|
|
it('binds the trip to a scope and reports it to every member', async () => {
|
|
const conns = await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.expect(200);
|
|
const connectionId = conns.body[0].id;
|
|
|
|
await request(server)
|
|
.post(`/api/trips/${tripId}/docsync/links`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({ connectionId, scopeKey: 'tag:1', remoteLabel: 'Japan 2026', direction: 'both', deletePolicy: 'unlink', conflictPolicy: 'manual', syncEnabled: true })
|
|
.expect(200);
|
|
|
|
const asMember = await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/links`)
|
|
.set('Cookie', sessionCookie(memberId))
|
|
.expect(200);
|
|
expect(asMember.body).toHaveLength(1);
|
|
expect(asMember.body[0].remoteLabel).toBe('Japan 2026');
|
|
// The shared secret behind the webhook URL is never handed out, not even to
|
|
// the owner: it exists so a provider can prove itself, not to be read.
|
|
expect(asMember.body[0].webhookSecret).toBe('••••••••');
|
|
});
|
|
|
|
it('refuses a binding whose connection belongs to another trip', async () => {
|
|
const otherTripId = createTrip(db as never, ownerId, { title: 'Other' }).id;
|
|
const conns = await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/connections`)
|
|
.set('Cookie', sessionCookie(ownerId));
|
|
await request(server)
|
|
.post(`/api/trips/${otherTripId}/docsync/links`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({ connectionId: conns.body[0].id, scopeKey: 'tag:1' })
|
|
.expect(400);
|
|
});
|
|
|
|
it('answers 200 with a verdict in the body when a probe fails, not an error status', async () => {
|
|
// The settings form needs a field to render, not an exception — the same
|
|
// contract the photo providers pin as CRITICAL in their own e2e suite.
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/docsync/connections/test`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.send({ providerId: 'paperless', baseUrl: 'https://blocked.invalid', credentials: {} })
|
|
.expect(200);
|
|
expect(res.body.connected).toBe(false);
|
|
expect(typeof res.body.error).toBe('string');
|
|
});
|
|
|
|
it('unbinds without deleting anything', async () => {
|
|
const links = await request(server)
|
|
.get(`/api/trips/${tripId}/docsync/links`)
|
|
.set('Cookie', sessionCookie(ownerId));
|
|
const linkId = links.body[0].id;
|
|
const res = await request(server)
|
|
.delete(`/api/trips/${tripId}/docsync/links/${linkId}`)
|
|
.set('Cookie', sessionCookie(ownerId))
|
|
.expect(200);
|
|
expect(res.body.documentsKept).toBe(true);
|
|
expect(db.prepare('SELECT COUNT(*) AS n FROM trip_document_links WHERE id = ?').get(linkId)).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('accepts an unknown webhook token without revealing that it is unknown', async () => {
|
|
// A 404 here would let anyone enumerate which bindings exist.
|
|
await request(server).post('/api/docsync/webhook/definitely-not-a-token').expect(200);
|
|
});
|
|
});
|