|
| 1 | +// src/api.ts — the custom REST endpoints. What is covered here is |
| 2 | +// `create-ydoc`, the one handler that built its stream message out of a yhub |
| 3 | +// internal: it diffed the posted update in the compute pool's `patchYdoc`, |
| 4 | +// which yhub 0.9.1 removed. The handler wrapped that call in a catch-all that |
| 5 | +// answers "Invalid Yjs update", so the removal turned every document creation |
| 6 | +// into a 400 without a type error at runtime, and with no test failing. |
| 7 | +// |
| 8 | +// `@y/hub` is faked down to what the module imports; `@y/y` is real, so the |
| 9 | +// updates are genuine and the contentmap is decoded the way yhub decodes it. |
| 10 | +// The fake instance has the compute pool of yhub 0.10 — no `patchYdoc` — and |
| 11 | +// these tests would answer 400 again if the handler went back to it. |
| 12 | + |
| 13 | +import { beforeEach, describe, expect, it, vi } from 'vitest'; |
| 14 | + |
| 15 | +import * as Y from '@y/y'; |
| 16 | + |
| 17 | +import { makeUpdate } from './_helpers.js'; |
| 18 | + |
| 19 | +vi.mock('@y/hub', () => { |
| 20 | + const logger = { |
| 21 | + info: vi.fn(), |
| 22 | + warn: vi.fn(), |
| 23 | + error: vi.fn(), |
| 24 | + child: () => logger, |
| 25 | + }; |
| 26 | + return { |
| 27 | + logger, |
| 28 | + checkPermissions: vi.fn(), |
| 29 | + createApiEndpoint: (name: string, opts: unknown) => ({ name, opts }), |
| 30 | + createDocumentPermissions: (x: unknown) => x, |
| 31 | + }; |
| 32 | +}); |
| 33 | + |
| 34 | +vi.mock('../src/backend.js', () => ({ backendPublicJwk: null })); |
| 35 | +vi.mock('../src/authz-cache.js', () => ({ invalidateDocumentAuthz: vi.fn() })); |
| 36 | +vi.mock('../src/migration.js', () => ({ |
| 37 | + SOFT_MIGRATION: false, |
| 38 | + fullMigrate: vi.fn(), |
| 39 | +})); |
| 40 | + |
| 41 | +const DOCID = '00000000-0000-4000-8000-000000000000'; |
| 42 | + |
| 43 | +type Handler = (req: unknown) => Promise<Response>; |
| 44 | + |
| 45 | +const createYdoc = async (): Promise<Handler> => { |
| 46 | + const { api } = await import('../src/api.js'); |
| 47 | + const endpoint = ( |
| 48 | + api as unknown as Array<{ |
| 49 | + name: string; |
| 50 | + opts: { post: { handler: Handler } }; |
| 51 | + }> |
| 52 | + ).find((e) => e.name === 'create-ydoc'); |
| 53 | + if (endpoint == null) throw new Error('create-ydoc is not mounted'); |
| 54 | + return endpoint.opts.post.handler; |
| 55 | +}; |
| 56 | + |
| 57 | +// The surface of yhub 0.10 the handler reaches: the room read, the stream |
| 58 | +// write, and a compute pool without the `patchYdoc` 0.9.1 removed. |
| 59 | +const makeYhub = (gcDoc: Uint8Array | null = null) => ({ |
| 60 | + getDoc: vi.fn(async () => ({ gcDoc })), |
| 61 | + stream: { addMessage: vi.fn(async () => {}) }, |
| 62 | + computePool: { mergeUpdates: vi.fn() }, |
| 63 | +}); |
| 64 | + |
| 65 | +const makeReq = ( |
| 66 | + body: Uint8Array, |
| 67 | + { |
| 68 | + yhub = makeYhub(), |
| 69 | + authInfo = { userid: 'system', admin: true } as Record< |
| 70 | + string, |
| 71 | + unknown |
| 72 | + > | null, |
| 73 | + headers = { 'x-user-id': 'user-1' } as Record<string, string>, |
| 74 | + } = {}, |
| 75 | +) => ({ |
| 76 | + org: 'docs', |
| 77 | + docid: DOCID, |
| 78 | + branch: 'main', |
| 79 | + docRef: { org: 'docs', docid: DOCID, branch: 'main' }, |
| 80 | + // uws hands the body over as a Node Buffer, which the handler re-views |
| 81 | + bytes: async () => Buffer.from(body), |
| 82 | + headers, |
| 83 | + authInfo, |
| 84 | + permissions: {}, |
| 85 | + query: {}, |
| 86 | + yhub, |
| 87 | +}); |
| 88 | + |
| 89 | +// Every attribute the contentmap puts on the inserted ids, by name. |
| 90 | +const insertAttributes = (contentmap: Uint8Array) => { |
| 91 | + const attrs = new Map<string, unknown>(); |
| 92 | + Y.decodeContentMap(contentmap).inserts.clients.forEach((ranges) => |
| 93 | + ranges |
| 94 | + .getIds() |
| 95 | + .forEach((range) => |
| 96 | + range.attrs.forEach((attr) => attrs.set(attr.name, attr.val)), |
| 97 | + ), |
| 98 | + ); |
| 99 | + return attrs; |
| 100 | +}; |
| 101 | + |
| 102 | +beforeEach(() => { |
| 103 | + vi.resetModules(); |
| 104 | +}); |
| 105 | + |
| 106 | +describe('create-ydoc', () => { |
| 107 | + it('creates the document without the compute pool’s removed patchYdoc', async () => { |
| 108 | + const handler = await createYdoc(); |
| 109 | + const yhub = makeYhub(); |
| 110 | + const update = makeUpdate('hello world'); |
| 111 | + |
| 112 | + const res = await handler(makeReq(update, { yhub })); |
| 113 | + |
| 114 | + expect(res.status).toBe(201); |
| 115 | + expect(await res.json()).toEqual({ message: 'Document created' }); |
| 116 | + expect(yhub.computePool.mergeUpdates).not.toHaveBeenCalled(); |
| 117 | + expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1); |
| 118 | + }); |
| 119 | + |
| 120 | + it('appends the update as posted, as yhub’s own PATCH does since 0.9.1', async () => { |
| 121 | + const handler = await createYdoc(); |
| 122 | + const yhub = makeYhub(); |
| 123 | + const update = makeUpdate('hello world'); |
| 124 | + |
| 125 | + await handler(makeReq(update, { yhub })); |
| 126 | + |
| 127 | + const [docRef, message] = yhub.stream.addMessage.mock |
| 128 | + .calls[0] as unknown as [ |
| 129 | + unknown, |
| 130 | + { type: string; update: Uint8Array; contentmap: Uint8Array }, |
| 131 | + ]; |
| 132 | + expect(docRef).toEqual({ org: 'docs', docid: DOCID, branch: 'main' }); |
| 133 | + expect(message.type).toBe('ydoc:update:v1'); |
| 134 | + // an exact Uint8Array, not the Buffer `req.bytes()` resolved to: the |
| 135 | + // stream-message schema compares constructors |
| 136 | + expect(message.update.constructor).toBe(Uint8Array); |
| 137 | + expect(Array.from(message.update)).toEqual(Array.from(update)); |
| 138 | + // and it is a document yhub can read back |
| 139 | + const doc = new Y.Doc(); |
| 140 | + Y.applyUpdate(doc, message.update); |
| 141 | + expect(doc.get('t', 'text').toString()).toBe('hello world'); |
| 142 | + }); |
| 143 | + |
| 144 | + it('attributes every inserted id to the user the admin token names, now', async () => { |
| 145 | + const handler = await createYdoc(); |
| 146 | + const yhub = makeYhub(); |
| 147 | + const update = makeUpdate('hello world'); |
| 148 | + const before = Date.now(); |
| 149 | + |
| 150 | + await handler(makeReq(update, { yhub })); |
| 151 | + |
| 152 | + const [, { contentmap }] = yhub.stream.addMessage.mock |
| 153 | + .calls[0] as unknown as [unknown, { contentmap: Uint8Array }]; |
| 154 | + // the contentmap covers exactly the ids the update inserts |
| 155 | + const covered = Y.createContentIdsFromContentMap( |
| 156 | + Y.decodeContentMap(contentmap), |
| 157 | + ); |
| 158 | + const posted = Y.createContentIdsFromUpdate(update); |
| 159 | + expect(Y.excludeContentIds(posted, covered).inserts.clients.size).toBe(0); |
| 160 | + expect(Y.excludeContentIds(covered, posted).inserts.clients.size).toBe(0); |
| 161 | + // with the author and the timestamp the activity timeline reads |
| 162 | + const attrs = insertAttributes(contentmap); |
| 163 | + expect(attrs.get('insert')).toBe('user-1'); |
| 164 | + expect(attrs.get('insertAt')).toBeGreaterThanOrEqual(before); |
| 165 | + expect(attrs.get('insertAt')).toBeLessThanOrEqual(Date.now()); |
| 166 | + }); |
| 167 | + |
| 168 | + it('attributes a user’s own create to them, whatever X-User-Id says', async () => { |
| 169 | + const handler = await createYdoc(); |
| 170 | + const yhub = makeYhub(); |
| 171 | + |
| 172 | + await handler( |
| 173 | + makeReq(makeUpdate(), { |
| 174 | + yhub, |
| 175 | + authInfo: { userid: 'user-2' }, |
| 176 | + headers: { 'x-user-id': 'someone-else' }, |
| 177 | + }), |
| 178 | + ); |
| 179 | + |
| 180 | + const [, { contentmap }] = yhub.stream.addMessage.mock |
| 181 | + .calls[0] as unknown as [unknown, { contentmap: Uint8Array }]; |
| 182 | + expect(insertAttributes(contentmap).get('insert')).toBe('user-2'); |
| 183 | + }); |
| 184 | + |
| 185 | + it('falls back to the admin identity when no X-User-Id is sent', async () => { |
| 186 | + const handler = await createYdoc(); |
| 187 | + const yhub = makeYhub(); |
| 188 | + |
| 189 | + await handler(makeReq(makeUpdate(), { yhub, headers: {} })); |
| 190 | + |
| 191 | + const [, { contentmap }] = yhub.stream.addMessage.mock |
| 192 | + .calls[0] as unknown as [unknown, { contentmap: Uint8Array }]; |
| 193 | + expect(insertAttributes(contentmap).get('insert')).toBe('system'); |
| 194 | + }); |
| 195 | + |
| 196 | + it.each([ |
| 197 | + ['truncated', makeUpdate('hello world').slice(0, -5)], |
| 198 | + ['garbage', new Uint8Array([7, 7, 7, 7, 7, 7, 7, 7, 7, 7])], |
| 199 | + [ |
| 200 | + 'corrupted', |
| 201 | + (() => { |
| 202 | + const update = makeUpdate('hello world'); |
| 203 | + update[5] ^= 0xff; |
| 204 | + update[10] ^= 0xff; |
| 205 | + return update; |
| 206 | + })(), |
| 207 | + ], |
| 208 | + ])('refuses a %s update with 400 and writes nothing', async (_, update) => { |
| 209 | + const handler = await createYdoc(); |
| 210 | + const yhub = makeYhub(); |
| 211 | + |
| 212 | + const res = await handler(makeReq(update, { yhub })); |
| 213 | + |
| 214 | + expect(res.status).toBe(400); |
| 215 | + expect(await res.json()).toEqual({ error: 'Invalid Yjs update' }); |
| 216 | + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); |
| 217 | + }); |
| 218 | + |
| 219 | + it('refuses an update that inserts nothing, which would create no room', async () => { |
| 220 | + // a delete set alone is a valid update of more than three bytes |
| 221 | + const doc = new Y.Doc(); |
| 222 | + doc.get('t', 'text').insert(0, 'abc'); |
| 223 | + const stateVector = Y.encodeStateVector(doc); |
| 224 | + doc.get('t', 'text').delete(0, 3); |
| 225 | + const deleteOnly = Y.encodeStateAsUpdate(doc, stateVector); |
| 226 | + expect(deleteOnly.byteLength).toBeGreaterThan(3); |
| 227 | + const handler = await createYdoc(); |
| 228 | + const yhub = makeYhub(); |
| 229 | + |
| 230 | + const res = await handler(makeReq(deleteOnly, { yhub })); |
| 231 | + |
| 232 | + expect(res.status).toBe(400); |
| 233 | + expect(await res.json()).toEqual({ error: 'Empty update' }); |
| 234 | + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); |
| 235 | + }); |
| 236 | + |
| 237 | + it('refuses an empty body before reading the room', async () => { |
| 238 | + const handler = await createYdoc(); |
| 239 | + const yhub = makeYhub(); |
| 240 | + |
| 241 | + const res = await handler(makeReq(new Uint8Array([0, 0]), { yhub })); |
| 242 | + |
| 243 | + expect(res.status).toBe(400); |
| 244 | + expect(yhub.getDoc).not.toHaveBeenCalled(); |
| 245 | + }); |
| 246 | + |
| 247 | + it('answers 409 on a room that already has content, and writes nothing', async () => { |
| 248 | + const handler = await createYdoc(); |
| 249 | + const yhub = makeYhub(makeUpdate('already here')); |
| 250 | + |
| 251 | + const res = await handler(makeReq(makeUpdate(), { yhub })); |
| 252 | + |
| 253 | + expect(res.status).toBe(409); |
| 254 | + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); |
| 255 | + }); |
| 256 | + |
| 257 | + it('refuses an anonymous caller', async () => { |
| 258 | + const handler = await createYdoc(); |
| 259 | + const yhub = makeYhub(); |
| 260 | + |
| 261 | + const res = await handler(makeReq(makeUpdate(), { yhub, authInfo: null })); |
| 262 | + |
| 263 | + expect(res.status).toBe(401); |
| 264 | + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); |
| 265 | + }); |
| 266 | +}); |
0 commit comments