AccessToken.utils.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import dayjs from 'dayjs'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import type { BaseToken } from './AccessToken.types'
  4. import {
  5. filterAndSortTokens,
  6. formatAccessText,
  7. getExpirationDate,
  8. getRealAccess,
  9. getResourcePermissions,
  10. handleSortChange,
  11. mapPermissionToFGA,
  12. } from './AccessToken.utils'
  13. // Mock PERMISSION_LIST so tests are deterministic and don't break when shared-types updates
  14. vi.mock('./AccessToken.constants', () => ({
  15. PERMISSION_LIST: [
  16. {
  17. scope: 'organization',
  18. resource: 'billing',
  19. action: 'read',
  20. id: 'org-billing-read',
  21. title: 'Read Billing',
  22. },
  23. {
  24. scope: 'organization',
  25. resource: 'billing',
  26. action: 'write',
  27. id: 'org-billing-write',
  28. title: 'Manage Billing',
  29. },
  30. {
  31. scope: 'organization',
  32. resource: 'members',
  33. action: 'read',
  34. id: 'org-members-read',
  35. title: 'Read Members',
  36. },
  37. {
  38. scope: 'organization',
  39. resource: 'members',
  40. action: 'write',
  41. id: 'org-members-write',
  42. title: 'Manage Members',
  43. },
  44. {
  45. scope: 'organization',
  46. resource: 'members',
  47. action: 'create',
  48. id: 'org-members-create',
  49. title: 'Create Members',
  50. },
  51. {
  52. scope: 'organization',
  53. resource: 'members',
  54. action: 'delete',
  55. id: 'org-members-delete',
  56. title: 'Delete Members',
  57. },
  58. {
  59. scope: 'project',
  60. resource: 'database',
  61. action: 'read',
  62. id: 'proj-db-read',
  63. title: 'Read Database',
  64. },
  65. ],
  66. }))
  67. // --- handleSortChange ---
  68. describe('handleSortChange', () => {
  69. it('should toggle from asc to desc for the same column', () => {
  70. const setSort = vi.fn()
  71. handleSortChange('created_at:asc', 'created_at', setSort)
  72. expect(setSort).toHaveBeenCalledWith('created_at:desc')
  73. })
  74. it('should reset to created_at:desc when toggling off desc on a non-default column', () => {
  75. const setSort = vi.fn()
  76. handleSortChange('last_used_at:desc', 'last_used_at', setSort)
  77. expect(setSort).toHaveBeenCalledWith('created_at:desc')
  78. })
  79. it('should set new column to asc when switching columns', () => {
  80. const setSort = vi.fn()
  81. handleSortChange('created_at:desc', 'expires_at', setSort)
  82. expect(setSort).toHaveBeenCalledWith('expires_at:asc')
  83. })
  84. it('should reset to created_at:desc when toggling off desc on created_at itself', () => {
  85. const setSort = vi.fn()
  86. handleSortChange('created_at:desc', 'created_at', setSort)
  87. expect(setSort).toHaveBeenCalledWith('created_at:desc')
  88. })
  89. it('should set last_used_at to asc when switching from expires_at', () => {
  90. const setSort = vi.fn()
  91. handleSortChange('expires_at:asc', 'last_used_at', setSort)
  92. expect(setSort).toHaveBeenCalledWith('last_used_at:asc')
  93. })
  94. })
  95. // --- filterAndSortTokens ---
  96. const makeToken = (overrides: Partial<BaseToken> = {}): BaseToken => ({
  97. id: '1',
  98. name: 'Token',
  99. token_alias: 'alias',
  100. created_at: '2024-01-01T00:00:00Z',
  101. last_used_at: null,
  102. expires_at: null,
  103. ...overrides,
  104. })
  105. describe('filterAndSortTokens', () => {
  106. const tokens: BaseToken[] = [
  107. makeToken({ id: '1', name: 'Alpha', created_at: '2024-01-03T00:00:00Z' }),
  108. makeToken({ id: '2', name: 'Beta', created_at: '2024-01-01T00:00:00Z' }),
  109. makeToken({ id: '3', name: 'Gamma', created_at: '2024-01-02T00:00:00Z' }),
  110. ]
  111. it('should return undefined when tokens is undefined', () => {
  112. expect(filterAndSortTokens(undefined, '', 'created_at:desc')).toBeUndefined()
  113. })
  114. it('should return all tokens when search string is empty', () => {
  115. const result = filterAndSortTokens(tokens, '', 'created_at:asc')
  116. expect(result).toHaveLength(3)
  117. })
  118. it('should filter tokens by name case-insensitively', () => {
  119. const result = filterAndSortTokens(tokens, 'alpha', 'created_at:asc')
  120. expect(result).toHaveLength(1)
  121. expect(result![0].name).toBe('Alpha')
  122. })
  123. it('should return empty array when no tokens match', () => {
  124. const result = filterAndSortTokens(tokens, 'nonexistent', 'created_at:asc')
  125. expect(result).toHaveLength(0)
  126. })
  127. it('should sort by created_at ascending', () => {
  128. const result = filterAndSortTokens(tokens, '', 'created_at:asc')!
  129. expect(result.map((t) => t.id)).toEqual(['2', '3', '1'])
  130. })
  131. it('should sort by created_at descending', () => {
  132. const result = filterAndSortTokens(tokens, '', 'created_at:desc')!
  133. expect(result.map((t) => t.id)).toEqual(['1', '3', '2'])
  134. })
  135. it('should sort by last_used_at ascending with nulls last', () => {
  136. const tokensWithUsage: BaseToken[] = [
  137. makeToken({ id: '1', last_used_at: '2024-03-01T00:00:00Z' }),
  138. makeToken({ id: '2', last_used_at: null }),
  139. makeToken({ id: '3', last_used_at: '2024-01-01T00:00:00Z' }),
  140. ]
  141. const result = filterAndSortTokens(tokensWithUsage, '', 'last_used_at:asc')!
  142. expect(result.map((t) => t.id)).toEqual(['3', '1', '2'])
  143. })
  144. it('should sort by last_used_at descending with nulls last', () => {
  145. const tokensWithUsage: BaseToken[] = [
  146. makeToken({ id: '1', last_used_at: '2024-01-01T00:00:00Z' }),
  147. makeToken({ id: '2', last_used_at: null }),
  148. makeToken({ id: '3', last_used_at: '2024-03-01T00:00:00Z' }),
  149. ]
  150. const result = filterAndSortTokens(tokensWithUsage, '', 'last_used_at:desc')!
  151. expect(result.map((t) => t.id)).toEqual(['3', '1', '2'])
  152. })
  153. it('should keep order stable when both last_used_at values are null', () => {
  154. const tokensAllNull: BaseToken[] = [
  155. makeToken({ id: '1', last_used_at: null }),
  156. makeToken({ id: '2', last_used_at: null }),
  157. ]
  158. const result = filterAndSortTokens(tokensAllNull, '', 'last_used_at:asc')!
  159. expect(result).toHaveLength(2)
  160. })
  161. it('should sort by expires_at ascending with nulls last', () => {
  162. const tokensWithExpiry: BaseToken[] = [
  163. makeToken({ id: '1', expires_at: '2025-06-01T00:00:00Z' }),
  164. makeToken({ id: '2', expires_at: null }),
  165. makeToken({ id: '3', expires_at: '2025-01-01T00:00:00Z' }),
  166. ]
  167. const result = filterAndSortTokens(tokensWithExpiry, '', 'expires_at:asc')!
  168. expect(result.map((t) => t.id)).toEqual(['3', '1', '2'])
  169. })
  170. it('should sort by expires_at descending with nulls last', () => {
  171. const tokensWithExpiry: BaseToken[] = [
  172. makeToken({ id: '1', expires_at: '2025-01-01T00:00:00Z' }),
  173. makeToken({ id: '2', expires_at: null }),
  174. makeToken({ id: '3', expires_at: '2025-06-01T00:00:00Z' }),
  175. ]
  176. const result = filterAndSortTokens(tokensWithExpiry, '', 'expires_at:desc')!
  177. expect(result.map((t) => t.id)).toEqual(['3', '1', '2'])
  178. })
  179. it('should keep order stable when both expires_at values are null', () => {
  180. const tokensAllNull: BaseToken[] = [
  181. makeToken({ id: '1', expires_at: null }),
  182. makeToken({ id: '2', expires_at: null }),
  183. ]
  184. const result = filterAndSortTokens(tokensAllNull, '', 'expires_at:asc')!
  185. expect(result).toHaveLength(2)
  186. })
  187. it('should not mutate the original tokens array', () => {
  188. const original = [...tokens]
  189. filterAndSortTokens(tokens, '', 'created_at:asc')
  190. expect(tokens).toEqual(original)
  191. })
  192. })
  193. // --- mapPermissionToFGA ---
  194. describe('mapPermissionToFGA', () => {
  195. it('should return the permission id for a matching scope:resource + action', () => {
  196. const result = mapPermissionToFGA('organization:billing', 'read')
  197. expect(result).toEqual(['org-billing-read'])
  198. })
  199. it('should return empty array when no permission matches', () => {
  200. const result = mapPermissionToFGA('nonexistent:resource', 'read')
  201. expect(result).toEqual([])
  202. })
  203. it('should return empty array when action does not match', () => {
  204. const result = mapPermissionToFGA('organization:billing', 'delete')
  205. expect(result).toEqual([])
  206. })
  207. })
  208. // --- getResourcePermissions ---
  209. describe('getResourcePermissions', () => {
  210. it('should always include "no access" with an empty array', () => {
  211. const result = getResourcePermissions('organization:billing')
  212. expect(result['no access']).toEqual([])
  213. })
  214. it('should include individual actions from PERMISSION_LIST', () => {
  215. const result = getResourcePermissions('organization:billing')
  216. expect(result['read']).toEqual(['org-billing-read'])
  217. expect(result['write']).toEqual(['org-billing-write'])
  218. })
  219. it('should include combined "read-write" when both read and write exist', () => {
  220. const result = getResourcePermissions('organization:billing')
  221. expect(result['read-write']).toEqual(['org-billing-read', 'org-billing-write'])
  222. })
  223. it('should return only "no access" for an unknown resource', () => {
  224. const result = getResourcePermissions('fake:resource')
  225. expect(Object.keys(result)).toEqual(['no access'])
  226. })
  227. it('should include all actions for a resource with many permissions', () => {
  228. const result = getResourcePermissions('organization:members')
  229. expect(result['read']).toEqual(['org-members-read'])
  230. expect(result['write']).toEqual(['org-members-write'])
  231. expect(result['create']).toEqual(['org-members-create'])
  232. expect(result['delete']).toEqual(['org-members-delete'])
  233. expect(result['read-write']).toEqual(['org-members-read', 'org-members-write'])
  234. })
  235. })
  236. // --- getRealAccess ---
  237. describe('getRealAccess', () => {
  238. it('should return "no access" when token has no matching permissions', () => {
  239. expect(getRealAccess('organization:billing', [])).toBe('no access')
  240. })
  241. it('should return "no access" when permissions do not match the resource', () => {
  242. expect(getRealAccess('organization:billing', ['unrelated-id'])).toBe('no access')
  243. })
  244. it('should return the single action when only one matches', () => {
  245. expect(getRealAccess('organization:billing', ['org-billing-read'])).toBe('read')
  246. })
  247. it('should return "read-write" when both read and write permissions match', () => {
  248. expect(getRealAccess('organization:billing', ['org-billing-read', 'org-billing-write'])).toBe(
  249. 'read-write'
  250. )
  251. })
  252. it('should join multiple granted actions with hyphens when not exactly read+write', () => {
  253. expect(
  254. getRealAccess('organization:members', [
  255. 'org-members-read',
  256. 'org-members-write',
  257. 'org-members-create',
  258. ])
  259. ).toBe('read-write-create')
  260. })
  261. it('should return single action for write-only access', () => {
  262. expect(getRealAccess('organization:billing', ['org-billing-write'])).toBe('write')
  263. })
  264. })
  265. // --- formatAccessText ---
  266. describe('formatAccessText', () => {
  267. it('should return "No access" for "no access"', () => {
  268. expect(formatAccessText('no access')).toBe('No access')
  269. })
  270. it('should capitalize a single word', () => {
  271. expect(formatAccessText('read')).toBe('Read')
  272. })
  273. it('should capitalize each hyphen-separated word', () => {
  274. expect(formatAccessText('read-write')).toBe('Read-Write')
  275. })
  276. it('should handle multi-segment actions', () => {
  277. expect(formatAccessText('read-write-delete')).toBe('Read-Write-Delete')
  278. })
  279. })
  280. // --- getExpirationDate ---
  281. describe('getExpirationDate', () => {
  282. const FIXED_DATE = new Date('2025-06-15T12:00:00.000Z')
  283. beforeEach(() => {
  284. vi.useFakeTimers()
  285. vi.setSystemTime(FIXED_DATE)
  286. })
  287. afterEach(() => {
  288. vi.useRealTimers()
  289. })
  290. it('should return a date exactly 1 hour from now for "hour"', () => {
  291. const result = getExpirationDate('hour')!
  292. expect(result).toBe(dayjs(FIXED_DATE).add(1, 'hours').toISOString())
  293. })
  294. it('should return a date exactly 1 day from now for "day"', () => {
  295. const result = getExpirationDate('day')!
  296. expect(result).toBe(dayjs(FIXED_DATE).add(1, 'day').toISOString())
  297. })
  298. it('should return a date exactly 7 days from now for "week"', () => {
  299. const result = getExpirationDate('week')!
  300. expect(result).toBe(dayjs(FIXED_DATE).add(7, 'days').toISOString())
  301. })
  302. it('should return a date exactly 30 days from now for "month"', () => {
  303. const result = getExpirationDate('month')!
  304. expect(result).toBe(dayjs(FIXED_DATE).add(30, 'days').toISOString())
  305. })
  306. it('should return undefined for "never"', () => {
  307. expect(getExpirationDate('never')).toBeUndefined()
  308. })
  309. it('should return undefined for an unknown key', () => {
  310. expect(getExpirationDate('unknown')).toBeUndefined()
  311. })
  312. it('should return a valid ISO string', () => {
  313. const result = getExpirationDate('hour')!
  314. expect(dayjs(result).isValid()).toBe(true)
  315. expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/)
  316. })
  317. })