fix(docsync): keep the Synology device token across a restart, tied to its row

The DSM trusted-device token lived in process memory only. After a
restart every token was gone and the stored one-time code long spent,
so every two-factor NAS binding stopped syncing until its owner typed a
fresh code, and nothing on screen said why.

The token is now kept in the connection's encrypted secrets, together
with a digest of the instance and account it was issued for. A saved
connection's ref carries a saveSecret callback that writes through
DocSyncConfigService.saveEarnedSecret, which rereads the row inside a
transaction so a sync run holding an old ref cannot undo a password
the owner changed meanwhile. The key belongs to no form field, so
maskSecrets never reports it and mergeSecrets never touches it: the GET
and PUT payloads do not carry it, an edit keeps it, and a client cannot
plant one. It survives a password edit and a spent or cleared code. A
connection repointed at another NAS or account drops it on its next
call, and a token DSM refuses with 403 or 404 is dropped in memory and
in storage. A probe of form values and an unsaved form may use what is
stored but never write. A refused token no longer shadows a fresh code:
the code in the form gets its turn in the same login call.

In memory the slot is keyed by the connection id together with the
row's created_at. A backup restored in place or the demo reset rolls
the id sequence back without a restart, and a new connection that
reused an id must not log in on the token of the row it replaced.

The Synology adapter receives its client through DI like Paperless and
WebDAV, instead of building a second instance beside the one the module
registers.

Two readings of a failed request are settled as well:
- INVALID_PURPOSE, INVALID_CA and PATH_LENGTH_EXCEEDED count as an
  untrusted certificate again, so the settings screen offers the
  self-signed switch. Papra used to find them through the message text,
  and the shared classifier reads codes only.
- A host that never completes the TCP handshake reads as unreachable on
  every client. WebDAV said timeout before the shared transport; the
  change is now documented and tested.
This commit is contained in:
Maurice
2026-09-19 13:36:16 +02:00
parent 71400b7e85
commit 25d4e34959
14 changed files with 689 additions and 128 deletions
@@ -5,7 +5,7 @@
* The database here is real in-memory SQLite with the full schema, and the
* at-rest crypto is real too. Both are deliberate. Almost everything this
* service owns is either a credential or a decision about one, and a spy on
* `db.run` would only prove that some strings were handed along — reading the
* `db.run` would only prove that some strings were handed along. Reading the
* row back is the assertion. A stubbed `maybe_encrypt_api_key` would let a
* regression that stores a Paperless token in plaintext pass every case below.
*
@@ -63,7 +63,7 @@ import {
type LinkRow,
} from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
// `if (!result.success)` does not narrow in this workspace — strictNullChecks is
// `if (!result.success)` does not narrow in this workspace: strictNullChecks is
// off, so the boolean discriminant stops discriminating. The domain's own
// predicate is what every call site uses instead.
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
@@ -123,7 +123,7 @@ beforeAll(() => {
beforeEach(() => {
// The document_* tables are not in resetTestDb's list, and they reference
// trips and users — so they go first, before the rows they hang off vanish.
// trips and users, so they go first, before the rows they hang off vanish.
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections;');
resetTestDb(testDb);
vi.clearAllMocks();
@@ -226,6 +226,83 @@ describe('publicConnection', () => {
});
});
describe('a secret the provider earned itself', () => {
// The shape the Synology adapter stores: account digest, colon, DSM's token.
const EARNED = 'a1b2c3:DEVICE-7';
function nas(credentials: Record<string, string> = { username: 'anna', password: 'nas-pw', otp_code: '123456' }) {
return connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials });
}
function secretsOf(connectionId: number): Readonly<Record<string, string>> {
return svc.toRef(svc.getConnection(connectionId) as ConnectionRow).secrets;
}
it('is stored through the ref the adapter was handed, encrypted with the form\'s secrets', async () => {
const conn = await nas();
svc.toRef(conn).saveSecret!('device_token', EARNED);
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED });
expect((svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7');
});
it('never reaches a client, not even as a mask', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
expect(view.secrets).toEqual({ password: DOCSYNC_SECRET_MASK, otp_code: DOCSYNC_SECRET_MASK });
expect(JSON.stringify(view)).not.toContain('DEVICE-7');
expect(JSON.stringify(view)).not.toContain('device_token');
});
it('survives a form edit, whatever the form sends for the secrets it does show', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', password: 'rotated', otp_code: '' });
await nas({ username: 'anna', password: DOCSYNC_SECRET_MASK });
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
});
it('cannot be planted or overwritten through the form', async () => {
const conn = await nas();
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect(secretsOf(conn.id).device_token).toBeUndefined();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect(secretsOf(conn.id).device_token).toBe(EARNED);
expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).settings).not.toHaveProperty('device_token');
});
it('is written against the row as it is now, so a run holding an old ref cannot undo a password edit', async () => {
const conn = await nas();
const ref = svc.toRef(conn);
await nas({ username: 'anna', password: 'rotated' });
ref.saveSecret!('device_token', EARNED);
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
});
it('is dropped with null, and the form\'s secrets stay', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
svc.saveEarnedSecret(conn.id, 'device_token', null);
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
it('brings back no connection that was deleted while a run still held its ref', async () => {
const conn = await nas();
const ref = svc.toRef(conn);
svc.deleteConnection(conn.id);
ref.saveSecret!('device_token', EARNED);
expect(svc.getConnection(conn.id)).toBeUndefined();
});
});
describe('validateBaseUrl', () => {
it('stores a private address, because a self-hosted instance is the point', async () => {
checkSsrf.mockResolvedValue({ allowed: false, isPrivate: true, error: 'private network' });
@@ -338,6 +415,24 @@ describe('deleteConnection', () => {
});
});
describe('toRef', () => {
it('names the row and not only its id, which a backup restored in place hands out again', async () => {
const first = await connect();
testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id);
const before = svc.toRef(svc.getConnection(first.id) as ConnectionRow);
// What the restore of an older backup does to this table: the row is gone
// and the id sequence is back where it stood before the row was made.
testDb.prepare('DELETE FROM document_connections WHERE id = ?').run(first.id);
testDb.prepare("UPDATE sqlite_sequence SET seq = ? WHERE name = 'document_connections'").run(first.id - 1);
const after = svc.toRef(await connect());
expect(after.connectionId).toBe(before.connectionId);
expect(before.createdAt).toBe('2026-09-01 08:00:00');
expect(after.createdAt).not.toBe(before.createdAt);
});
});
describe('updateLink', () => {
it('changes only the fields the patch names', async () => {
const conn = await connect();
@@ -370,6 +370,52 @@ describe('probing form values', () => {
describe('reading the trip state', () => {
it('asks the reconciler with a number, because the path hands the handler a string', () => {
describe('a secret the adapter earned itself', () => {
const EARNED = 'a1b2c3:DEVICE-SECRET';
it('is in neither connection payload the form is rendered from', async () => {
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'synologydrive'").run();
const nas = connBody({
providerId: 'synologydrive',
baseUrl: 'https://nas.example.com:5001',
credentials: { username: 'anna', password: 'nas-pw' },
});
const created = (await controller.upsertConnection(String(tripId), owner, nas)) as { id: number };
config.saveEarnedSecret(created.id, 'device_token', EARNED);
const listed = JSON.stringify(controller.listConnections(String(tripId)));
const edited = JSON.stringify(
await controller.upsertConnection(String(tripId), owner, { ...nas, credentials: { username: 'anna', password: 'rotated' } }),
);
for (const payload of [listed, edited]) {
expect(payload).not.toContain('DEVICE-SECRET');
expect(payload).not.toContain('device_token');
}
expect(config.toRef(config.getConnection(created.id)!).secrets.device_token).toBe(EARNED);
});
it('reaches a probe of the saved form, which gets no way to write', async () => {
const conn = await storedPaperless();
config.saveEarnedSecret(conn.id, 'device_token', EARNED);
const res = await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
// The row as well as its id: together they are where the saved connection keeps its device token.
expect(ref).toMatchObject({ connectionId: conn.id, createdAt: conn.created_at, secrets: { device_token: EARNED } });
expect(ref.createdAt).not.toBe('');
expect(ref.saveSecret).toBeUndefined();
expect(JSON.stringify(res)).not.toContain('DEVICE-SECRET');
});
it('gives a probe of an unsaved form no way to write either', async () => {
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
expect(ref.connectionId).toBe(0);
expect(ref.createdAt).toBe('');
expect(ref.saveSecret).toBeUndefined();
});
});
controller.status(String(tripId));
expect(sync.status).toHaveBeenCalledWith(tripId);
});
@@ -1,5 +1,5 @@
/**
* The Paperless-ngx adapter — PAPERLESS-001..095.
* The Paperless-ngx adapter: PAPERLESS-001..095.
*
* Two things are pinned here. What the client puts on the wire: the pinned API
* version (an unpinned Accept header is how the `checksum` field silently moved
@@ -86,6 +86,7 @@ const BASE = 'https://papers.example.org';
const CONN: DocumentConnectionRef = {
connectionId: 4,
createdAt: '2026-09-01 08:00:00',
ownerId: 9,
baseUrl: BASE,
secrets: { api_token: TOKEN },
@@ -285,7 +286,7 @@ beforeEach(() => {
);
});
describe('PaperlessClient — the request it builds', () => {
describe('PaperlessClient: the request it builds', () => {
it('PAPERLESS-001: appends /api to whatever the operator pasted', () => {
expect(apiBase(BASE)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/`)).toBe(`${BASE}/api`);
@@ -338,7 +339,7 @@ describe('PaperlessClient — the request it builds', () => {
});
});
describe('PaperlessProvider — probing a connection', () => {
describe('PaperlessProvider: probing a connection', () => {
it('PAPERLESS-010: reports the self-registering webhook when the token may create workflows', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'admin' },
@@ -416,7 +417,7 @@ describe('PaperlessProvider — probing a connection', () => {
});
});
describe('PaperlessProvider — scopes are tags', () => {
describe('PaperlessProvider: scopes are tags', () => {
it('PAPERLESS-020: a tag becomes a scope key, a label and the instance filter URL', async () => {
on('GET /api/tags/', reply(page([tagRow(3, 'japan-2026', 4), tagRow(9, 'privat')])));
const options = expectOk(await provider.listScopes(CONN));
@@ -468,7 +469,7 @@ describe('PaperlessProvider — scopes are tags', () => {
});
});
describe('PaperlessProvider — listing a scope', () => {
describe('PaperlessProvider: listing a scope', () => {
it('PAPERLESS-030: maps a document onto the core’s shape', async () => {
on('GET /api/documents/', reply(page([docRow()])));
const listed = expectOk(await provider.list(CONN, SCOPE));
@@ -544,7 +545,7 @@ describe('PaperlessProvider — listing a scope', () => {
const second = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(second.cursorUnchanged).toBe(true);
// A document leaving the tag moves no timestamp — only the count notices.
// A document leaving the tag moves no timestamp: only the count notices.
on('GET /api/documents/', reply(page([], null, 0)));
const third = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(third.cursorUnchanged).toBe(false);
@@ -583,7 +584,7 @@ describe('PaperlessProvider — listing a scope', () => {
});
});
describe('PaperlessProvider — fetching bytes', () => {
describe('PaperlessProvider: fetching bytes', () => {
it('PAPERLESS-040: asks for the original file and hands back the stream with its version', async () => {
on('GET /api/documents/11/', reply(docRow()));
on('GET /api/documents/11/download/', streamReply('%PDF-1.4 hotel', {
@@ -635,7 +636,7 @@ describe('PaperlessProvider — fetching bytes', () => {
});
});
describe('PaperlessProvider — pushing bytes', () => {
describe('PaperlessProvider: pushing bytes', () => {
function stubSuccessfulCreate(): void {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
@@ -731,7 +732,7 @@ describe('PaperlessProvider — pushing bytes', () => {
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('conflict');
});
it('PAPERLESS-055: the task filter is not trusted either — a foreign task is ignored', async () => {
it('PAPERLESS-055: the task filter is not trusted either, so a foreign task is ignored', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'someone-elses-task', status: 'success' })])));
@@ -765,7 +766,7 @@ describe('PaperlessProvider — pushing bytes', () => {
it('PAPERLESS-057a: a document with the same bytes but another tag is not adopted', async () => {
// The tag goes on at upload, so only a tagged document can be the one this
// push made. Taking the untagged twin instead put a stranger's document in
// the trip — and delete-through would have trashed it on a later run.
// the trip, and delete-through would have trashed it on a later run.
vi.spyOn(client, 'awaitConsume').mockRejectedValue(
new PaperlessError('timeout', 'Paperless is still processing the uploaded file'),
);
@@ -842,7 +843,7 @@ describe('PaperlessProvider — pushing bytes', () => {
});
});
describe('PaperlessProvider — renaming and trashing', () => {
describe('PaperlessProvider: renaming and trashing', () => {
it('PAPERLESS-070: renaming sets the title without the extension it would add back', async () => {
on('PATCH /api/documents/11/', reply(docRow({ modified: '2026-09-18T19:00:00.000000+02:00' })));
const renamed = expectOk(await provider.rename(CONN, SCOPE, '11', ' Hotelrechnung Kyoto.pdf '));
@@ -866,7 +867,7 @@ describe('PaperlessProvider — renaming and trashing', () => {
});
});
describe('PaperlessProvider — the self-registered webhook', () => {
describe('PaperlessProvider: the self-registered webhook', () => {
it('PAPERLESS-090: subscribes both triggers, filtered on the tag, with the secret in a header', async () => {
on('POST /api/workflows/', reply({ id: 6 }, 201));
const registered = expectOk(
@@ -6,7 +6,7 @@
* nothing and restores a trashed twin instead still answers 200, a missing key
* scope is a 401 rather than a 403, and no timestamp moves when a document
* changes. So the cases pinned here are mostly the ones where believing the
* obvious reading would corrupt the sync — the dedup paths, the version
* obvious reading would corrupt the sync: the dedup paths, the version
* marker, and the scope filter.
*
* `safeFetch` is mocked because the SSRF guard does a real DNS lookup and
@@ -51,6 +51,7 @@ const TAG = 'tag_egz8zvs4gcesi99d00mma0f3';
const CONN: DocumentConnectionRef = {
connectionId: 7,
createdAt: '2026-09-01 08:00:00',
ownerId: 3,
baseUrl: 'https://papra.example.org',
secrets: { api_key: API_KEY },
@@ -1069,7 +1070,7 @@ describe('the version marker', () => {
/**
* The media type goes into a header, so it has to survive being one.
*
* It comes from `trip_files.mime_type` — influenced by an upload — and went in
* It comes from `trip_files.mime_type` (influenced by an upload) and went in
* untouched while the filename beside it was escaped. A CR or LF ends the
* header early and lets what follows be read as headers of its own.
*/
@@ -135,6 +135,10 @@ describe('classifyTransportFailure', () => {
'CERT_SIGNATURE_FAILURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
'HOSTNAME_MISMATCH',
// A leaf without serverAuth, signed by a CA this host does trust.
'INVALID_PURPOSE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
])('PROVIDER-HTTP-003: %s is an untrusted certificate', (code) => {
expect(classifyTransportFailure(wrapped(coded('certificate verdict', code))).code).toBe('tls_untrusted');
});
@@ -160,6 +164,15 @@ describe('classifyTransportFailure', () => {
expect(classifyTransportFailure(error).code).toBe('tls_untrusted');
expect(classifyTransportFailure(wrapped(error)).code).toBe('unreachable');
});
it('PROVIDER-HTTP-007: a host that never takes the connection is unreachable, not slow', () => {
// undici gives up on the handshake after ten seconds, before the request's
// own timeout does: a NAS that is switched off, or a firewall that drops the SYN.
const error = wrapped(
Object.assign(new Error('Connect Timeout Error'), { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT' }),
);
expect(classifyTransportFailure(error).code).toBe('unreachable');
});
});
describe('providerFetch', () => {
@@ -5,7 +5,7 @@
* FileStation is the provider the sync core's weakest-common-denominator model
* was shaped around: no change feed, no stable file id, app-level errors inside
* HTTP 200, and one number meaning different things on two endpoints. So both
* halves of that contract are pinned here — the exact parameters and their JSON
* halves of that contract are pinned here: the exact parameters and their JSON
* encoding, and the docsync error code each way a NAS can disappoint maps to.
*
* Only `safeFetch` is mocked. `cappedFetch` runs for real against the fake
@@ -38,6 +38,7 @@ import {
normalizeBaseUrl,
normalizeSynoPath,
snapshotVersion,
SynologyDriveClient,
} from '../../../../src/nest/doc-sync/providers/synology-drive.client';
import type {
DocumentConnectionRef,
@@ -192,6 +193,7 @@ function callsTo(api: string, method?: string): Recorded[] {
function connection(overrides: Partial<DocumentConnectionRef> = {}): DocumentConnectionRef {
return {
connectionId: 3,
createdAt: '2026-09-01 08:00:00',
ownerId: 11,
baseUrl: BASE_URL,
secrets: { password: 'nas-secret' },
@@ -250,7 +252,7 @@ beforeEach(() => {
throw new Error('unstubbed safeFetch');
});
install();
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
});
// ── capabilities ─────────────────────────────────────────────────────────────
@@ -320,7 +322,7 @@ describe('SynologyDriveDocumentProvider: login', () => {
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized', detail: 'syno_code=400' } });
});
it('SYNO-PROVIDER-014: a rejected credential is not retried — that is what earns an auto-block', async () => {
it('SYNO-PROVIDER-014: a rejected credential is not retried, since that is what earns an auto-block', async () => {
route({ 'SYNO.API.Auth:login': fail(400) });
await provider.probe(connection());
const after = calls.length;
@@ -430,13 +432,20 @@ describe('SynologyDriveDocumentProvider: login', () => {
// ── device tokens ────────────────────────────────────────────────────────────
describe('SynologyDriveDocumentProvider: device tokens', () => {
/** DSM with two-factor on: a login needs a trusted device or a code, and a code earns a device. */
/** Codes the fake NAS has accepted. A TOTP code is good for one login, as on DSM. */
let spent = new Set<string>();
/** DSM with two-factor on: a login needs a trusted device or a fresh code, and a code earns a device. */
function twoFactorNas(refused: string[] = []): Handler {
return (call) => {
const { device_id: deviceId, otp_code: otpCode } = call.params;
if (deviceId && refused.includes(deviceId)) return fail(403);
if (deviceId) return ok({ sid: `SID-${deviceId}` });
if (otpCode) return ok({ sid: `SID-${otpCode}`, did: `DEVICE-${otpCode}` });
if (otpCode && spent.has(otpCode)) return fail(404);
if (otpCode) {
spent.add(otpCode);
return ok({ sid: `SID-${otpCode}`, did: `DEVICE-${otpCode}` });
}
return fail(403);
};
}
@@ -450,8 +459,42 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
calls = [];
}
/** Every write a saved connection made into its stored secrets. */
let writes: Array<[string, string | null]> = [];
/**
* A saved connection the way `DocSyncConfigService.toRef` hands one out: the
* secrets as they were stored when the ref was made, and a way to write back
* into `store`, which stands in for the connection's row.
*/
function saved(store: Record<string, string>, overrides: Partial<DocumentConnectionRef> = {}): DocumentConnectionRef {
return connection({
secrets: { ...store },
saveSecret: (key, value) => {
writes.push([key, value]);
if (value === null) delete store[key];
else store[key] = value;
},
...overrides,
});
}
/** A new process: nothing in memory, only what the connections stored. */
function restart(): void {
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
writes = [];
}
const picker = {
'SYNO.FileStation.List:list_share': ok({ shares: [] }),
'SYNO.FileStation.List:list': ok({ total: 0, files: [] }),
};
beforeEach(() => {
vi.useFakeTimers();
spent = new Set();
writes = [];
});
afterEach(() => {
@@ -499,13 +542,20 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
});
expireSessions();
expect(await provider.probe(first)).toMatchObject({ success: false, error: { code: 'unauthorized' } });
// The code in the form gets its turn in the same call; this one was spent
// long ago, so DSM turns it down as well.
expect(logins().map((call) => [call.params.device_id, call.params.otp_code])).toEqual([
['DEVICE-111111', undefined],
[undefined, '111111'],
]);
expect(await provider.probe(second)).toMatchObject({ success: true });
// Once the lockout has passed, the first connection offers the code in its
// form again instead of the token DSM refused.
// form again and never the token DSM refused.
vi.setSystemTime(Date.now() + 61 * 1000);
calls = [];
await provider.probe(first);
expect(logins()).toHaveLength(1);
expect(logins()[0].params).toMatchObject({ otp_code: '111111', enable_device_token: 'yes' });
expect(logins()[0].params.device_id).toBeUndefined();
});
@@ -549,6 +599,154 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
expect(logins()[0].params.otp_code).toBeUndefined();
});
it('SYNO-PROVIDER-100: the token a code earns is stored with the connection once, tied to its NAS and account', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
await provider.listScopes(saved(store));
expect(store.device_token).toMatch(/^[0-9a-f]{64}:DEVICE-111111$/);
// Four FileStation calls, one change, one write.
expect(writes).toEqual([['device_token', store.device_token]]);
});
it('SYNO-PROVIDER-101: after a restart the stored token logs in, and nobody has to type a code', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
restart();
expect(await provider.listScopes(saved(store))).toMatchObject({ success: true });
expect(logins()).toHaveLength(1);
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
expect(logins()[0].params.otp_code).toBeUndefined();
expect(writes).toEqual([]);
});
it('SYNO-PROVIDER-102: the stored token outlives a password edit and a spent or cleared code', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
store.password = 'rotated';
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params).toMatchObject({ device_id: 'DEVICE-111111', passwd: '"rotated"' });
expect(logins()[0].params.otp_code).toBeUndefined();
delete store.otp_code;
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
expect(store.device_token).toMatch(/:DEVICE-111111$/);
});
it('SYNO-PROVIDER-103: an unsaved form writes nothing, even when handed a place to write', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), 'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }) });
const saveSecret = vi.fn();
await provider.probe(connection({ connectionId: 0, ...withCode('111111'), saveSecret }));
expect(logins()[0].params.otp_code).toBe('111111');
expect(saveSecret).not.toHaveBeenCalled();
});
it('SYNO-PROVIDER-104: a token DSM refuses is dropped from storage too, and no error names it', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
route({ 'SYNO.API.Auth:login': twoFactorNas(['DEVICE-111111']), ...picker });
restart();
const result = await provider.listScopes(saved(store));
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } });
expect(store.device_token).toBeUndefined();
expect(JSON.stringify(result)).not.toContain('DEVICE-111111');
// The next process has nothing left to offer DSM but the code.
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBeUndefined();
});
it('SYNO-PROVIDER-105: a fresh code wins over a refused token in the same test, and the saved connection keeps it', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), 'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
// TREK was removed from the account's trusted devices. After a restart the
// owner types a new code and tests the form, which merges in what is stored
// but has no way to write.
route({
'SYNO.API.Auth:login': twoFactorNas(['DEVICE-111111']),
'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }),
...picker,
});
restart();
expect(await provider.probe(connection({ secrets: { ...store, otp_code: '222222' } }))).toMatchObject({ success: true });
expect(logins().map((call) => [call.params.device_id, call.params.otp_code])).toEqual([
['DEVICE-111111', undefined],
[undefined, '222222'],
]);
expect(store.device_token).toMatch(/:DEVICE-111111$/);
// Saved with that code: the connection's next call stores what the test earned.
store.otp_code = '222222';
await provider.listScopes(saved(store));
expect(store.device_token).toMatch(/:DEVICE-222222$/);
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBe('DEVICE-222222');
});
it('SYNO-PROVIDER-106: a connection repointed at another account forgets its token, in memory and in storage', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
calls = [];
await provider.listScopes(saved(store, { settings: { username: 'someone-else', base_path: '/trek' } }));
expect(logins()[0].params.device_id).toBeUndefined();
expect(store.device_token).toBeUndefined();
// Pointed back, it has passed nobody's second factor there either.
expireSessions();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBeUndefined();
});
it('SYNO-PROVIDER-107: after a restart, a token stored for another NAS is dropped rather than offered there', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
restart();
await provider.listScopes(saved(store, { baseUrl: 'https://other-nas.example.org:5001' }));
expect(logins()[0].params.device_id).toBeUndefined();
expect(store.device_token).toBeUndefined();
});
it('SYNO-PROVIDER-108: a connection that a restore hands the same id is a stranger to the token under it', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
// A backup from before that connection is restored without a restart, and
// another trip's owner, who knows the password but has no authenticator,
// saves a connection to the same account. The id sequence went back with
// the database, so the new row gets the same id.
const reissued: Record<string, string> = { password: 'nas-secret' };
const stranger = await provider.listScopes(saved(reissued, { ownerId: 12, createdAt: '2026-09-02 09:30:00' }));
expect(stranger).toMatchObject({ success: false, error: { code: 'unauthorized' } });
expect(logins()[1].params.device_id).toBeUndefined();
expect(reissued.device_token).toBeUndefined();
// A later backup brings the first row back, and it logs in on its token as before.
expireSessions();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
});
});
// ── scopes ───────────────────────────────────────────────────────────────────
@@ -672,7 +870,7 @@ describe('SynologyDriveDocumentProvider: list', () => {
size: 12,
mimeType: 'application/pdf',
// MD5 is the only digest FileStation computes, and it is not the sha256
// the core compares against — so there is nothing honest to put here.
// the core compares against, so there is nothing honest to put here.
contentHash: null,
remoteModifiedAt: new Date(1_700_000_500 * 1000).toISOString(),
isDeleted: false,
@@ -1031,7 +1229,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
error: { code: 'unauthorized' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
route({ 'SYNO.FileStation.List:getinfo': fail(408) });
expect(await provider.resolveScope(connection(), scope())).toMatchObject({
@@ -1052,7 +1250,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
[117, 'forbidden'],
];
for (const [synoCode, code] of expected) {
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
route({ 'SYNO.FileStation.List:list': sequence(fail(synoCode), fail(synoCode)) });
const result = await provider.list(connection(), scope());
@@ -1107,7 +1305,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
it('SYNO-PROVIDER-088: the remaining HTTP statuses a NAS or its proxy answers', async () => {
for (const [status, code] of [[403, 'forbidden'], [404, 'not_found'], [413, 'too_large'], [429, 'rate_limited']] as const) {
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
route({ 'SYNO.API.Auth:login': reply({}, { status }) });
expect(await provider.probe(connection()), `http ${status}`).toMatchObject({
@@ -1133,7 +1331,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
error: { code: 'quota_exceeded', status: 507 },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({ 'SYNO.FileStation.Upload:upload': reply({}, { status: 500 }) });
expect(await provider.push(connection(), scope(), push())).toMatchObject({
success: false,
@@ -1154,21 +1352,21 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
error: { code: 'scope_missing' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({ 'SYNO.FileStation.CreateFolder:create': ok({ folders: [] }) });
expect(await provider.createScope(connection(), 'kyoto')).toMatchObject({
success: false,
error: { code: 'provider_error' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({ 'SYNO.FileStation.Rename:rename': ok({ files: [] }) });
expect(await provider.rename(connection(), scope(), `${SCOPE}/a.pdf`, 'b.pdf')).toMatchObject({
success: false,
error: { code: 'provider_error' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({
'SYNO.FileStation.Upload:upload': ok({}),
'SYNO.FileStation.List:getinfo': ok({ files: [entry(`${SCOPE}/itinerary.pdf`)] }),
@@ -1238,7 +1436,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
// ── path handling ────────────────────────────────────────────────────────────
describe('synology-drive.client — paths and versions', () => {
describe('synology-drive.client: paths and versions', () => {
it('SYNO-CLIENT-090: normalises the spellings that mean the same folder', () => {
expect(normalizeSynoPath('/trek//japan-2026/')).toBe('/trek/japan-2026');
expect(normalizeSynoPath(' /trek/./japan-2026 ')).toBe('/trek/japan-2026');
@@ -69,6 +69,7 @@ const OC_ROOT = `/dav/spaces/${OC_DRIVE}`;
const ncConn: DocumentConnectionRef = {
connectionId: 7,
createdAt: '2026-09-01 08:00:00',
ownerId: 3,
baseUrl: NC_ORIGIN,
secrets: { app_password: 'app-pw' },
@@ -78,6 +79,7 @@ const ncConn: DocumentConnectionRef = {
const ocConn: DocumentConnectionRef = {
connectionId: 8,
createdAt: '2026-09-01 08:00:00',
ownerId: 3,
baseUrl: OC_ORIGIN,
secrets: { app_token: 'app-token' },