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