protocol.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * @vitest-environment node
  3. *
  4. * What a browser asks the socket for.
  5. *
  6. * The test that matters here is the boring-looking one about order. A page
  7. * subscribes to a room and then stands in it, and the subscribe is checked
  8. * against the database while the "enter" is already on its way. Handled
  9. * concurrently, the enter arrives before the room has been joined, is
  10. * refused, and nobody's chip ever appears: two people on one work order, and
  11. * neither sees the other, with nothing in any log to say why.
  12. */
  13. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  14. const mayJoin = vi.hoisted(() => vi.fn(async (_room: string, _organizationId: string) => true))
  15. vi.mock('@/lib/realtime/authorize.server', () => ({ mayJoin }))
  16. import {
  17. handleClientMessage,
  18. MAX_FRAME_BYTES,
  19. MAX_QUEUED_FRAMES,
  20. queueFor,
  21. type Client,
  22. } from '@/lib/realtime/protocol.server'
  23. import {
  24. flushPresence,
  25. onPresenceChange,
  26. presenceOf,
  27. resetPresence,
  28. } from '@/lib/realtime/presence.server'
  29. import { broadcast, isInRoom, resetRooms, stats } from '@/lib/realtime/rooms.server'
  30. import type { ServerMessage } from '@/lib/realtime/events'
  31. const ROOM = 'rec:serviceRecord:job-1'
  32. /** The same room as the server keeps it: under the desk's own workshop. */
  33. const KEPT = 'org-1|rec:serviceRecord:job-1'
  34. function desk(): { client: Client; socket: object; sent: ServerMessage[] } {
  35. const sent: ServerMessage[] = []
  36. return {
  37. client: {
  38. userId: 'u-1',
  39. userName: 'Christian',
  40. organizationId: 'org-1',
  41. send: (message) => sent.push(message),
  42. },
  43. socket: { name: 'desk' },
  44. sent,
  45. }
  46. }
  47. const say = (client: Client, socket: object, message: unknown) =>
  48. handleClientMessage(client, socket, JSON.stringify(message))
  49. /** Lets a queued chain run to the end. */
  50. async function settle() {
  51. for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0))
  52. }
  53. beforeEach(() => {
  54. mayJoin.mockReset().mockResolvedValue(true)
  55. })
  56. afterEach(() => {
  57. resetRooms()
  58. resetPresence()
  59. })
  60. describe('subscribing', () => {
  61. it('joins a room the workshop owns, and says which', async () => {
  62. const { client, socket, sent } = desk()
  63. await say(client, socket, { t: 'sub', rooms: [ROOM] })
  64. expect(isInRoom(socket, KEPT)).toBe(true)
  65. expect(sent).toEqual([{ t: 'subscribed', rooms: [ROOM] }])
  66. })
  67. it('refuses a room from another workshop, silently', async () => {
  68. mayJoin.mockResolvedValue(false)
  69. const { client, socket, sent } = desk()
  70. await say(client, socket, { t: 'sub', rooms: ['rec:serviceRecord:someone-elses'] })
  71. expect(stats().rooms).toBe(0)
  72. // Named as not joined rather than refused: a socket learns nothing about
  73. // whether that record exists.
  74. expect(sent).toEqual([{ t: 'subscribed', rooms: [] }])
  75. })
  76. it('leaves on unsub, taking the room with it', async () => {
  77. const { client, socket } = desk()
  78. await say(client, socket, { t: 'sub', rooms: [ROOM] })
  79. await say(client, socket, { t: 'enter', room: ROOM })
  80. await say(client, socket, { t: 'unsub', rooms: [ROOM] })
  81. expect(stats()).toEqual({ rooms: 0, members: 0, subscriptions: 0 })
  82. expect(presenceOf(KEPT)).toEqual([])
  83. })
  84. })
  85. describe('presence', () => {
  86. it('stands in a room it holds, and the room is told', async () => {
  87. const { client, socket } = desk()
  88. const told: string[][] = []
  89. const stop = onPresenceChange((_room, users) => told.push(users.map((user) => user.name)))
  90. await say(client, socket, { t: 'sub', rooms: [ROOM] })
  91. await say(client, socket, { t: 'enter', room: ROOM })
  92. flushPresence()
  93. stop()
  94. // The newcomer hears it the way everybody else does, through the room
  95. // (the route sends a presence change to every socket in it): one frame,
  96. // from one place.
  97. expect(presenceOf(KEPT).map((user) => user.name)).toEqual(['Christian'])
  98. expect(told).toEqual([['Christian']])
  99. })
  100. it('cannot stand in a room it does not hold', async () => {
  101. const { client, socket } = desk()
  102. await say(client, socket, { t: 'enter', room: ROOM })
  103. expect(presenceOf(KEPT)).toEqual([])
  104. })
  105. })
  106. describe('two messages sent together', () => {
  107. it('handles them in order, so "enter" is not refused while "sub" is still checking', async () => {
  108. // The real shape of it: the subscribe waits on the database.
  109. let allow: (value: boolean) => void = () => undefined
  110. mayJoin.mockImplementation(
  111. () =>
  112. new Promise<boolean>((resolve) => {
  113. allow = resolve
  114. })
  115. )
  116. const { client, socket, sent } = desk()
  117. const queue = queueFor(client, socket)
  118. queue(JSON.stringify({ t: 'sub', rooms: [ROOM] }))
  119. queue(JSON.stringify({ t: 'enter', room: ROOM }))
  120. await settle()
  121. // The check is still out, so neither message has finished: this is the
  122. // window in which the enter used to be refused.
  123. expect(isInRoom(socket, KEPT)).toBe(false)
  124. expect(presenceOf(KEPT)).toEqual([])
  125. allow(true)
  126. await settle()
  127. expect(isInRoom(socket, KEPT)).toBe(true)
  128. expect(presenceOf(KEPT).map((user) => user.name)).toEqual(['Christian'])
  129. expect(sent).toContainEqual({ t: 'subscribed', rooms: [ROOM] })
  130. })
  131. it('keeps going after one of them fails', async () => {
  132. const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  133. mayJoin.mockRejectedValueOnce(new Error('database gone')).mockResolvedValue(true)
  134. const { client, socket, sent } = desk()
  135. const queue = queueFor(client, socket)
  136. queue(JSON.stringify({ t: 'sub', rooms: [ROOM] }))
  137. queue(JSON.stringify({ t: 'ping' }))
  138. await settle()
  139. expect(errors).toHaveBeenCalled()
  140. expect(sent).toContainEqual({ t: 'pong' })
  141. })
  142. })
  143. describe('a socket that is not a page', () => {
  144. it('ignores a frame far larger than any page sends', async () => {
  145. const { client, socket, sent } = desk()
  146. const rooms = Array.from({ length: 2_000 }, (_, i) => `rec:serviceRecord:job-${i}`)
  147. await say(client, socket, { t: 'sub', rooms })
  148. expect(JSON.stringify({ t: 'sub', rooms }).length).toBeGreaterThan(MAX_FRAME_BYTES)
  149. expect(sent).toEqual([])
  150. expect(stats().rooms).toBe(0)
  151. })
  152. it('stops queueing behind a check that never returns', async () => {
  153. mayJoin.mockImplementation(() => new Promise<boolean>(() => undefined))
  154. const { client, socket, sent } = desk()
  155. const queue = queueFor(client, socket)
  156. queue(JSON.stringify({ t: 'sub', rooms: [ROOM] }))
  157. for (let i = 0; i < MAX_QUEUED_FRAMES * 3; i++) queue(JSON.stringify({ t: 'ping' }))
  158. await settle()
  159. // Nothing behind the stuck one has run, and nothing beyond the limit was
  160. // kept to run later: memory held for this socket is bounded.
  161. expect(sent).toEqual([])
  162. })
  163. })
  164. describe('anything else', () => {
  165. it('is ignored rather than answered', async () => {
  166. const { client, socket, sent } = desk()
  167. await handleClientMessage(client, socket, 'not json')
  168. await say(client, socket, { t: 'nonsense' })
  169. await say(client, socket, { t: 'sub', rooms: 'not an array' })
  170. expect(sent).toEqual([])
  171. expect(stats().rooms).toBe(0)
  172. })
  173. })
  174. describe('two workshops', () => {
  175. const other = (): { client: Client; socket: object; sent: ServerMessage[] } => {
  176. const sent: ServerMessage[] = []
  177. return {
  178. client: {
  179. userId: 'u-9',
  180. userName: 'Stranger',
  181. organizationId: 'org-2',
  182. send: (message) => sent.push(message),
  183. },
  184. socket: { name: 'another workshop' },
  185. sent,
  186. }
  187. }
  188. it('never share a room, even if the check that should refuse one says yes', async () => {
  189. // The worst case on purpose: the ownership check is broken and lets a
  190. // stranger "join" another workshop's work order by name.
  191. mayJoin.mockResolvedValue(true)
  192. const ours = desk()
  193. const theirs = other()
  194. await say(ours.client, ours.socket, { t: 'sub', rooms: [ROOM] })
  195. await say(ours.client, ours.socket, { t: 'enter', room: ROOM })
  196. await say(theirs.client, theirs.socket, { t: 'sub', rooms: [ROOM] })
  197. await say(theirs.client, theirs.socket, { t: 'enter', room: ROOM })
  198. // A change to our job goes to our workshop's room: the stranger is not in it.
  199. const told: object[] = []
  200. broadcast('org-1|rec:serviceRecord:job-1', (member) => told.push(member))
  201. expect(told).toEqual([ours.socket])
  202. // And neither sees the other standing there.
  203. expect(presenceOf('org-1|rec:serviceRecord:job-1').map((u) => u.name)).toEqual(['Christian'])
  204. expect(presenceOf('org-2|rec:serviceRecord:job-1').map((u) => u.name)).toEqual(['Stranger'])
  205. })
  206. it('cannot reach another workshop by writing its id into the room name', async () => {
  207. mayJoin.mockImplementation(async (room: string) => !room.includes('org-1'))
  208. const theirs = other()
  209. await say(theirs.client, theirs.socket, {
  210. t: 'sub',
  211. rooms: ['org:org-1', 'org-1|rec:serviceRecord:job-1', ROOM],
  212. })
  213. // Whatever was granted is kept under the stranger's own workshop.
  214. const told: object[] = []
  215. broadcast('org-1|rec:serviceRecord:job-1', (member) => told.push(member))
  216. broadcast('org-1|org:org-1', (member) => told.push(member))
  217. expect(told).toEqual([])
  218. expect(isInRoom(theirs.socket, 'org-2|rec:serviceRecord:job-1')).toBe(true)
  219. })
  220. })