mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(docsync): a paused binding no longer resets its own settings
PATCH links validated its body with a schema derived from the create contract through .partial(). In Zod 4 a .default() still fires inside an optional field, so a patch naming one setting arrived with every other one filled in: pausing a pull-only binding switched it to two-way, reset both policies and blanked its label. The update contract is now written out without defaults, and an e2e test drives a patch through the real pipe, which the controller unit tests never see. Also, the cosmetic leftovers from the review: - POST connections/:connectionId/scopes no longer wants the connection in the body as well. It was required, never read, and let one request name two connections; a client that still sends it has the key stripped rather than refused. - createLink loses the scope argument every caller passed as null. - The controller's owner check is now assertCanManage, because it lets instance admins through just as canManageDocSync does on the client. Who may manage a binding is unchanged. - document_provider_fields drops settings_key and payload_key. Nothing read them: the connect form takes its fields from /docsync/providers and the admin shelf sends none for document providers. - The e2e test imports the provider classes under their new names.
This commit is contained in:
@@ -267,6 +267,55 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () =>
|
||||
expect(row.secrets).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('creates a folder or tag through the connection the path names', async () => {
|
||||
const conns = await request(server)
|
||||
.get(`/api/trips/${tripId}/docsync/connections`)
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.expect(200);
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/docsync/connections/${conns.body[0].id}/scopes`)
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ name: 'Norway' })
|
||||
.expect(201);
|
||||
expect(res.body).toMatchObject({ scopeKey: 'tag:2', label: 'Norway' });
|
||||
});
|
||||
|
||||
it('still takes a scope request from a client that sends the connection in the body too', async () => {
|
||||
// The body field was dropped in favour of the path. A client from before
|
||||
// that change has it stripped by the contract instead of being refused.
|
||||
const conns = await request(server)
|
||||
.get(`/api/trips/${tripId}/docsync/connections`)
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
await request(server)
|
||||
.post(`/api/trips/${tripId}/docsync/connections/${conns.body[0].id}/scopes`)
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ connectionId: conns.body[0].id, name: 'Norway' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('creates no scope through a connection that belongs to another trip', async () => {
|
||||
const otherTripId = createTrip(db as never, ownerId, { title: 'Elsewhere' }).id;
|
||||
const conns = await request(server)
|
||||
.get(`/api/trips/${tripId}/docsync/connections`)
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
await request(server)
|
||||
.post(`/api/trips/${otherTripId}/docsync/connections/${conns.body[0].id}/scopes`)
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ name: 'Norway' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('does not let a plain member create a folder or tag', async () => {
|
||||
const conns = await request(server)
|
||||
.get(`/api/trips/${tripId}/docsync/connections`)
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
await request(server)
|
||||
.post(`/api/trips/${tripId}/docsync/connections/${conns.body[0].id}/scopes`)
|
||||
.set('Cookie', sessionCookie(memberId))
|
||||
.send({ name: 'Norway' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
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`)
|
||||
@@ -291,6 +340,34 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () =>
|
||||
expect(asMember.body[0].webhookSecret).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('changes only the setting a patch names, through the real contract', async () => {
|
||||
// The unit tests call the handler directly and never see the pipe. Through
|
||||
// it, a schema that fills in defaults turns one switch into a full reset.
|
||||
const links = await request(server)
|
||||
.get(`/api/trips/${tripId}/docsync/links`)
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
const linkId = links.body[0].id;
|
||||
await request(server)
|
||||
.patch(`/api/trips/${tripId}/docsync/links/${linkId}`)
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ direction: 'pull', deletePolicy: 'trash' })
|
||||
.expect(200);
|
||||
|
||||
const paused = await request(server)
|
||||
.patch(`/api/trips/${tripId}/docsync/links/${linkId}`)
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ syncEnabled: false })
|
||||
.expect(200);
|
||||
|
||||
expect(paused.body).toMatchObject({
|
||||
syncEnabled: false,
|
||||
direction: 'pull',
|
||||
deletePolicy: 'trash',
|
||||
conflictPolicy: 'manual',
|
||||
remoteLabel: 'Japan 2026',
|
||||
});
|
||||
});
|
||||
|
||||
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)
|
||||
|
||||
@@ -106,7 +106,7 @@ function linkInput(connectionId: number, over: Partial<DocsyncLinkInput> = {}):
|
||||
}
|
||||
|
||||
function link(connectionId: number, over: Partial<DocsyncLinkInput> = {}): LinkRow {
|
||||
const result = svc.createLink(TRIP, OWNER, linkInput(connectionId, over), null);
|
||||
const result = svc.createLink(TRIP, OWNER, linkInput(connectionId, over));
|
||||
if (docFailed(result)) throw new Error(`fixture link refused: ${JSON.stringify(result.error)}`);
|
||||
return result.data;
|
||||
}
|
||||
@@ -363,12 +363,11 @@ describe('toScopeRef', () => {
|
||||
// The adapters take a ref object rather than positional arguments because
|
||||
// an id and a path swapped compile cleanly and then read the wrong folder.
|
||||
const conn = await connect();
|
||||
const created = svc.createLink(TRIP, OWNER, linkInput(conn.id, { scopeKey: 'fileid:437' }), {
|
||||
scopeKey: 'fileid:437',
|
||||
label: 'Japan 2026',
|
||||
remoteRootId: '437',
|
||||
remoteRootPath: '/TREK/japan',
|
||||
});
|
||||
const created = svc.createLink(
|
||||
TRIP,
|
||||
OWNER,
|
||||
linkInput(conn.id, { scopeKey: 'fileid:437', remoteRootId: '437', remoteRootPath: '/TREK/japan' }),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
const row = created.success ? created.data : ({} as LinkRow);
|
||||
testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id);
|
||||
@@ -388,7 +387,7 @@ describe('createLink', () => {
|
||||
it('returns the binding that already exists rather than making a second one', async () => {
|
||||
const conn = await connect();
|
||||
const first = link(conn.id);
|
||||
const again = svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }), null);
|
||||
const again = svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }));
|
||||
expect(again.success && again.data.id).toBe(first.id);
|
||||
expect(svc.listLinks(TRIP)).toHaveLength(1);
|
||||
});
|
||||
@@ -404,32 +403,39 @@ describe('createLink', () => {
|
||||
it('refuses a connection that belongs to somebody else\'s trip', async () => {
|
||||
const otherTrip = createTrip(testDb, MEMBER, { title: 'Not yours' }).id;
|
||||
const conn = await connect();
|
||||
const result = svc.createLink(otherTrip, MEMBER, linkInput(conn.id), null);
|
||||
const result = svc.createLink(otherTrip, MEMBER, linkInput(conn.id));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.success === false && result.error.code).toBe('not_found');
|
||||
expect(svc.listLinks(otherTrip)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a connection id that does not exist at all', async () => {
|
||||
const result = svc.createLink(TRIP, OWNER, linkInput(9999), null);
|
||||
const result = svc.createLink(TRIP, OWNER, linkInput(9999));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('takes the anchor from the scope the user picked, not from what the client sent', async () => {
|
||||
it('stores the anchor the picker sent back with the scope key', async () => {
|
||||
const conn = await connect();
|
||||
const result = svc.createLink(
|
||||
TRIP,
|
||||
OWNER,
|
||||
linkInput(conn.id, { remoteRootId: 'stale', remoteRootPath: '/stale', remoteLabel: 'stale' }),
|
||||
{ scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '17', remoteRootPath: '/TREK/japan' },
|
||||
linkInput(conn.id, { remoteRootId: '17', remoteRootPath: '/TREK/japan', remoteLabel: 'Japan 2026' }),
|
||||
);
|
||||
expect(result.success && result.data).toMatchObject({
|
||||
remote_scope_key: 'tag:1',
|
||||
remote_root_id: '17',
|
||||
remote_root_path: '/TREK/japan',
|
||||
remote_label: 'Japan 2026',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores NULL for an anchor field the client left out', async () => {
|
||||
// Synology binds by path alone, so its options carry no root id at all.
|
||||
const conn = await connect();
|
||||
const result = svc.createLink(TRIP, OWNER, linkInput(conn.id));
|
||||
expect(result.success && result.data).toMatchObject({ remote_root_id: null, remote_root_path: null });
|
||||
});
|
||||
|
||||
it('gives every binding its own webhook token and keeps the secret out of the public view', async () => {
|
||||
// A leaked webhook URL has to identify exactly one binding, so revoking one
|
||||
// cannot be used to poke another.
|
||||
|
||||
@@ -440,14 +440,24 @@ describe('the scope picker', () => {
|
||||
data: { scopeKey: 'tag:9', label: 'Norway', remoteRootId: '9', remoteRootPath: '/TREK/norway' },
|
||||
} as never);
|
||||
|
||||
await expect(controller.createScope(String(tripId), String(conn.id), owner, { connectionId: conn.id, name: 'Norway' }))
|
||||
await expect(controller.createScope(String(tripId), String(conn.id), owner, { name: 'Norway' }))
|
||||
.resolves.toMatchObject({ scopeKey: 'tag:9', label: 'Norway' });
|
||||
expect(paperless.createScope).toHaveBeenCalledWith(expect.objectContaining({ connectionId: conn.id }), 'Norway');
|
||||
});
|
||||
|
||||
it('creates nothing through a connection that belongs to a different trip', async () => {
|
||||
// The connection comes from the path and nowhere else, so the path is what
|
||||
// has to match the trip the caller was admitted to.
|
||||
const conn = await storedPaperless();
|
||||
const err = await thrown(() => controller.createScope(String(otherTripId), String(conn.id), admin, { name: 'Norway' }));
|
||||
expect(err.getStatus()).toBe(404);
|
||||
expect(paperless.createScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns a refused creation into a 400 carrying the provider code', async () => {
|
||||
const conn = await storedPaperless();
|
||||
paperless.createScope.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
|
||||
const err = await thrown(() => controller.createScope(String(tripId), String(conn.id), owner, { connectionId: conn.id, name: 'Norway' }));
|
||||
const err = await thrown(() => controller.createScope(String(tripId), String(conn.id), owner, { name: 'Norway' }));
|
||||
expect(err.getStatus()).toBe(400);
|
||||
expect(err.message).toBe('forbidden');
|
||||
});
|
||||
@@ -695,9 +705,9 @@ describe('a manual run and the shelved rows', () => {
|
||||
/**
|
||||
* The trip the route was authorised against is now passed on to the service.
|
||||
*
|
||||
* `assertOwner` proves the caller owns the trip in the URL and says nothing
|
||||
* about whether the item id in the path belongs to that trip — the id is a
|
||||
* plain integer, and nothing tied the two together. The check itself lives in
|
||||
* `assertCanManage` proves the caller may manage the trip in the URL and says
|
||||
* nothing about whether the item id in the path belongs to that trip: the id is
|
||||
* a plain integer, and nothing tied the two together. The check itself lives in
|
||||
* the service (where the row is), so what belongs here is that the trip reaches
|
||||
* it at all.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user