mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
77162aa8d5
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
236 lines
12 KiB
TypeScript
236 lines
12 KiB
TypeScript
/**
|
|
* Settings e2e — exercises the migrated /api/settings endpoints through the real
|
|
* JwtAuthGuard against a temp SQLite db. DI-native: SettingsService runs its
|
|
* real SQL over the temp db (no service mock); this covers auth, the DTO 400s
|
|
* (ZodValidationPipe), the masked-sentinel no-op, status codes and the actual
|
|
* persisted rows.
|
|
*/
|
|
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 { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { Test } from '@nestjs/testing';
|
|
import { seedUser, 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(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
|
tmp.exec(`CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
|
key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));`);
|
|
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
|
return { db: tmp };
|
|
});
|
|
|
|
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
|
|
|
import { SettingsModule } from '../../src/nest/settings/settings.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
|
|
describe('Settings e2e (real auth guard + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, SettingsModule] }).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
seedUser(db as never, { id: 1 });
|
|
seedUser(db as never, { id: 2, role: 'admin', email: 'e2e-admin@example.test' });
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
db.exec('DELETE FROM settings');
|
|
db.exec('DELETE FROM app_settings');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('401 without a session cookie', async () => {
|
|
expect((await request(server).get('/api/settings')).status).toBe(401);
|
|
});
|
|
|
|
it('200 list with a session', async () => {
|
|
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'theme', 'dark')").run();
|
|
const res = await request(server).get('/api/settings').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ settings: { theme: 'dark' } });
|
|
});
|
|
|
|
it('PUT 400 without a key (pipe envelope via SettingUpsertDto)', async () => {
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ value: 'x' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/^key: /);
|
|
expect(db.prepare('SELECT COUNT(*) AS n FROM settings').get()).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('POST /bulk 400 without a settings object (pipe envelope via SettingsBulkDto)', async () => {
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: null });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/^settings: /);
|
|
});
|
|
|
|
it('PUT persists the setting', async () => {
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'language', value: 'fr' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, key: 'language', value: 'fr' });
|
|
const row = db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'language'").get() as { value: string };
|
|
expect(row.value).toBe('fr');
|
|
});
|
|
|
|
it('PUT no-ops on the masked sentinel', async () => {
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'secret', value: '••••••••' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, key: 'secret', unchanged: true });
|
|
expect(db.prepare("SELECT COUNT(*) AS n FROM settings WHERE user_id = 1 AND key = 'secret'").get()).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('POST /bulk 200', async () => {
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: { a: 1, b: 2 } });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, updated: 2 });
|
|
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = 1 ORDER BY key').all();
|
|
expect(rows).toEqual([
|
|
{ key: 'a', value: '1' },
|
|
{ key: 'b', value: '2' },
|
|
]);
|
|
});
|
|
|
|
it('POST /bulk skips masked-sentinel values (a stored secret survives)', async () => {
|
|
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'ntfy_token', 'tok-real')").run();
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
|
|
.send({ settings: { ntfy_topic: 'trek', ntfy_token: '••••••••' } });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, updated: 1 });
|
|
expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'ntfy_token'").get()).toEqual({ value: 'tok-real' });
|
|
});
|
|
|
|
// #1772: a free-form LLM endpoint is admin-only. The resolver ignores such a
|
|
// row for a non-admin either way; these routes refuse it so the user is told.
|
|
describe('LLM endpoint settings are admin-only (#1772)', () => {
|
|
const countRows = () => (db.prepare('SELECT COUNT(*) AS n FROM settings').get() as { n: number }).n;
|
|
|
|
it('PUT 403 for a non-admin naming a base URL', async () => {
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'llm_base_url', value: 'http://192.168.1.5:11434' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('PUT 403 for a non-admin picking the local provider', async () => {
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'llm_provider', value: 'local' });
|
|
expect(res.status).toBe(403);
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('PUT lets a non-admin clear the base URL and pick a hosted provider', async () => {
|
|
expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'llm_base_url', value: ' ' })).status).toBe(200);
|
|
expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'llm_provider', value: 'anthropic' })).status).toBe(200);
|
|
expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'llm_provider'").get())
|
|
.toEqual({ value: 'anthropic' });
|
|
});
|
|
|
|
it('PUT 403 for a non-admin naming a routing engine (#1797)', async () => {
|
|
// Same class for a different reason: this origin has to appear in the CSP
|
|
// connect-src the server emits at boot, and that list is built from the
|
|
// admin default. A value on a personal row is read by the route
|
|
// calculator and then refused by the browser, so the app stops routing
|
|
// with no error it could report. Refusing tells the caller instead.
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'routing_base_url', value: 'https://osrm.example.org' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('PUT lets a non-admin clear a routing engine somebody set before the rule', async () => {
|
|
expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'routing_base_url', value: '' })).status).toBe(200);
|
|
});
|
|
|
|
it('POST bulk refuses a routing engine from a non-admin', async () => {
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
|
|
.send({ settings: { routing_base_url: 'https://osrm.example.org' } });
|
|
expect(res.status).toBe(403);
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('PUT 403 for a non-admin naming the second routing engine', async () => {
|
|
// The Valhalla is the same class as the OSRM above and fails the same way: its
|
|
// origin has to be in the boot-time connect-src, which is assembled from the
|
|
// admin default alone. A personal row would be read by the route calculator and
|
|
// then refused by the browser, so "other ways" would quietly stop offering any.
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'valhalla_base_url', value: 'https://valhalla.example.org' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('PUT lets a non-admin clear the second routing engine', async () => {
|
|
expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'valhalla_base_url', value: '' })).status).toBe(200);
|
|
});
|
|
|
|
it('PUT is unaffected for a non-LLM key with the same value', async () => {
|
|
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
|
|
.send({ key: 'start_page', value: 'local' });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('POST /bulk 403 for a non-admin, and nothing in the payload is written', async () => {
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
|
|
.send({ settings: { llm_provider: 'local', llm_base_url: 'http://192.168.1.5:11434', llm_model: 'nuextract' } });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('POST /bulk 200 for a non-admin bringing their own hosted key', async () => {
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
|
|
.send({ settings: { llm_provider: 'anthropic', llm_base_url: '', llm_model: 'claude-sonnet' } });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, updated: 3 });
|
|
});
|
|
|
|
it('POST /bulk 403 for an admin too, since this is not where the endpoint lives', async () => {
|
|
// An instance has one endpoint and it is set on the addon, or through
|
|
// PUT /api/admin/default-user-settings. A personal row would be a second,
|
|
// invisible place for the same value, so the route refuses it for everyone.
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(2))
|
|
.send({ settings: { llm_provider: 'local', llm_base_url: 'http://192.168.1.5:11434' } });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Admin access required' });
|
|
expect(countRows()).toBe(0);
|
|
});
|
|
|
|
it('POST /bulk 200 for an admin bringing their own hosted key', async () => {
|
|
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(2))
|
|
.send({ settings: { llm_provider: 'openai', llm_base_url: '', llm_model: 'gpt-4o-mini' } });
|
|
expect(res.status).toBe(200);
|
|
expect(db.prepare("SELECT value FROM settings WHERE user_id = 2 AND key = 'llm_provider'").get())
|
|
.toEqual({ value: 'openai' });
|
|
});
|
|
});
|
|
});
|