import dayjs from 'dayjs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BaseToken } from './AccessToken.types' import { filterAndSortTokens, formatAccessText, getExpirationDate, getRealAccess, getResourcePermissions, handleSortChange, mapPermissionToFGA, } from './AccessToken.utils' // Mock PERMISSION_LIST so tests are deterministic and don't break when shared-types updates vi.mock('./AccessToken.constants', () => ({ PERMISSION_LIST: [ { scope: 'organization', resource: 'billing', action: 'read', id: 'org-billing-read', title: 'Read Billing', }, { scope: 'organization', resource: 'billing', action: 'write', id: 'org-billing-write', title: 'Manage Billing', }, { scope: 'organization', resource: 'members', action: 'read', id: 'org-members-read', title: 'Read Members', }, { scope: 'organization', resource: 'members', action: 'write', id: 'org-members-write', title: 'Manage Members', }, { scope: 'organization', resource: 'members', action: 'create', id: 'org-members-create', title: 'Create Members', }, { scope: 'organization', resource: 'members', action: 'delete', id: 'org-members-delete', title: 'Delete Members', }, { scope: 'project', resource: 'database', action: 'read', id: 'proj-db-read', title: 'Read Database', }, ], })) // --- handleSortChange --- describe('handleSortChange', () => { it('should toggle from asc to desc for the same column', () => { const setSort = vi.fn() handleSortChange('created_at:asc', 'created_at', setSort) expect(setSort).toHaveBeenCalledWith('created_at:desc') }) it('should reset to created_at:desc when toggling off desc on a non-default column', () => { const setSort = vi.fn() handleSortChange('last_used_at:desc', 'last_used_at', setSort) expect(setSort).toHaveBeenCalledWith('created_at:desc') }) it('should set new column to asc when switching columns', () => { const setSort = vi.fn() handleSortChange('created_at:desc', 'expires_at', setSort) expect(setSort).toHaveBeenCalledWith('expires_at:asc') }) it('should reset to created_at:desc when toggling off desc on created_at itself', () => { const setSort = vi.fn() handleSortChange('created_at:desc', 'created_at', setSort) expect(setSort).toHaveBeenCalledWith('created_at:desc') }) it('should set last_used_at to asc when switching from expires_at', () => { const setSort = vi.fn() handleSortChange('expires_at:asc', 'last_used_at', setSort) expect(setSort).toHaveBeenCalledWith('last_used_at:asc') }) }) // --- filterAndSortTokens --- const makeToken = (overrides: Partial = {}): BaseToken => ({ id: '1', name: 'Token', token_alias: 'alias', created_at: '2024-01-01T00:00:00Z', last_used_at: null, expires_at: null, ...overrides, }) describe('filterAndSortTokens', () => { const tokens: BaseToken[] = [ makeToken({ id: '1', name: 'Alpha', created_at: '2024-01-03T00:00:00Z' }), makeToken({ id: '2', name: 'Beta', created_at: '2024-01-01T00:00:00Z' }), makeToken({ id: '3', name: 'Gamma', created_at: '2024-01-02T00:00:00Z' }), ] it('should return undefined when tokens is undefined', () => { expect(filterAndSortTokens(undefined, '', 'created_at:desc')).toBeUndefined() }) it('should return all tokens when search string is empty', () => { const result = filterAndSortTokens(tokens, '', 'created_at:asc') expect(result).toHaveLength(3) }) it('should filter tokens by name case-insensitively', () => { const result = filterAndSortTokens(tokens, 'alpha', 'created_at:asc') expect(result).toHaveLength(1) expect(result![0].name).toBe('Alpha') }) it('should return empty array when no tokens match', () => { const result = filterAndSortTokens(tokens, 'nonexistent', 'created_at:asc') expect(result).toHaveLength(0) }) it('should sort by created_at ascending', () => { const result = filterAndSortTokens(tokens, '', 'created_at:asc')! expect(result.map((t) => t.id)).toEqual(['2', '3', '1']) }) it('should sort by created_at descending', () => { const result = filterAndSortTokens(tokens, '', 'created_at:desc')! expect(result.map((t) => t.id)).toEqual(['1', '3', '2']) }) it('should sort by last_used_at ascending with nulls last', () => { const tokensWithUsage: BaseToken[] = [ makeToken({ id: '1', last_used_at: '2024-03-01T00:00:00Z' }), makeToken({ id: '2', last_used_at: null }), makeToken({ id: '3', last_used_at: '2024-01-01T00:00:00Z' }), ] const result = filterAndSortTokens(tokensWithUsage, '', 'last_used_at:asc')! expect(result.map((t) => t.id)).toEqual(['3', '1', '2']) }) it('should sort by last_used_at descending with nulls last', () => { const tokensWithUsage: BaseToken[] = [ makeToken({ id: '1', last_used_at: '2024-01-01T00:00:00Z' }), makeToken({ id: '2', last_used_at: null }), makeToken({ id: '3', last_used_at: '2024-03-01T00:00:00Z' }), ] const result = filterAndSortTokens(tokensWithUsage, '', 'last_used_at:desc')! expect(result.map((t) => t.id)).toEqual(['3', '1', '2']) }) it('should keep order stable when both last_used_at values are null', () => { const tokensAllNull: BaseToken[] = [ makeToken({ id: '1', last_used_at: null }), makeToken({ id: '2', last_used_at: null }), ] const result = filterAndSortTokens(tokensAllNull, '', 'last_used_at:asc')! expect(result).toHaveLength(2) }) it('should sort by expires_at ascending with nulls last', () => { const tokensWithExpiry: BaseToken[] = [ makeToken({ id: '1', expires_at: '2025-06-01T00:00:00Z' }), makeToken({ id: '2', expires_at: null }), makeToken({ id: '3', expires_at: '2025-01-01T00:00:00Z' }), ] const result = filterAndSortTokens(tokensWithExpiry, '', 'expires_at:asc')! expect(result.map((t) => t.id)).toEqual(['3', '1', '2']) }) it('should sort by expires_at descending with nulls last', () => { const tokensWithExpiry: BaseToken[] = [ makeToken({ id: '1', expires_at: '2025-01-01T00:00:00Z' }), makeToken({ id: '2', expires_at: null }), makeToken({ id: '3', expires_at: '2025-06-01T00:00:00Z' }), ] const result = filterAndSortTokens(tokensWithExpiry, '', 'expires_at:desc')! expect(result.map((t) => t.id)).toEqual(['3', '1', '2']) }) it('should keep order stable when both expires_at values are null', () => { const tokensAllNull: BaseToken[] = [ makeToken({ id: '1', expires_at: null }), makeToken({ id: '2', expires_at: null }), ] const result = filterAndSortTokens(tokensAllNull, '', 'expires_at:asc')! expect(result).toHaveLength(2) }) it('should not mutate the original tokens array', () => { const original = [...tokens] filterAndSortTokens(tokens, '', 'created_at:asc') expect(tokens).toEqual(original) }) }) // --- mapPermissionToFGA --- describe('mapPermissionToFGA', () => { it('should return the permission id for a matching scope:resource + action', () => { const result = mapPermissionToFGA('organization:billing', 'read') expect(result).toEqual(['org-billing-read']) }) it('should return empty array when no permission matches', () => { const result = mapPermissionToFGA('nonexistent:resource', 'read') expect(result).toEqual([]) }) it('should return empty array when action does not match', () => { const result = mapPermissionToFGA('organization:billing', 'delete') expect(result).toEqual([]) }) }) // --- getResourcePermissions --- describe('getResourcePermissions', () => { it('should always include "no access" with an empty array', () => { const result = getResourcePermissions('organization:billing') expect(result['no access']).toEqual([]) }) it('should include individual actions from PERMISSION_LIST', () => { const result = getResourcePermissions('organization:billing') expect(result['read']).toEqual(['org-billing-read']) expect(result['write']).toEqual(['org-billing-write']) }) it('should include combined "read-write" when both read and write exist', () => { const result = getResourcePermissions('organization:billing') expect(result['read-write']).toEqual(['org-billing-read', 'org-billing-write']) }) it('should return only "no access" for an unknown resource', () => { const result = getResourcePermissions('fake:resource') expect(Object.keys(result)).toEqual(['no access']) }) it('should include all actions for a resource with many permissions', () => { const result = getResourcePermissions('organization:members') expect(result['read']).toEqual(['org-members-read']) expect(result['write']).toEqual(['org-members-write']) expect(result['create']).toEqual(['org-members-create']) expect(result['delete']).toEqual(['org-members-delete']) expect(result['read-write']).toEqual(['org-members-read', 'org-members-write']) }) }) // --- getRealAccess --- describe('getRealAccess', () => { it('should return "no access" when token has no matching permissions', () => { expect(getRealAccess('organization:billing', [])).toBe('no access') }) it('should return "no access" when permissions do not match the resource', () => { expect(getRealAccess('organization:billing', ['unrelated-id'])).toBe('no access') }) it('should return the single action when only one matches', () => { expect(getRealAccess('organization:billing', ['org-billing-read'])).toBe('read') }) it('should return "read-write" when both read and write permissions match', () => { expect(getRealAccess('organization:billing', ['org-billing-read', 'org-billing-write'])).toBe( 'read-write' ) }) it('should join multiple granted actions with hyphens when not exactly read+write', () => { expect( getRealAccess('organization:members', [ 'org-members-read', 'org-members-write', 'org-members-create', ]) ).toBe('read-write-create') }) it('should return single action for write-only access', () => { expect(getRealAccess('organization:billing', ['org-billing-write'])).toBe('write') }) }) // --- formatAccessText --- describe('formatAccessText', () => { it('should return "No access" for "no access"', () => { expect(formatAccessText('no access')).toBe('No access') }) it('should capitalize a single word', () => { expect(formatAccessText('read')).toBe('Read') }) it('should capitalize each hyphen-separated word', () => { expect(formatAccessText('read-write')).toBe('Read-Write') }) it('should handle multi-segment actions', () => { expect(formatAccessText('read-write-delete')).toBe('Read-Write-Delete') }) }) // --- getExpirationDate --- describe('getExpirationDate', () => { const FIXED_DATE = new Date('2025-06-15T12:00:00.000Z') beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(FIXED_DATE) }) afterEach(() => { vi.useRealTimers() }) it('should return a date exactly 1 hour from now for "hour"', () => { const result = getExpirationDate('hour')! expect(result).toBe(dayjs(FIXED_DATE).add(1, 'hours').toISOString()) }) it('should return a date exactly 1 day from now for "day"', () => { const result = getExpirationDate('day')! expect(result).toBe(dayjs(FIXED_DATE).add(1, 'day').toISOString()) }) it('should return a date exactly 7 days from now for "week"', () => { const result = getExpirationDate('week')! expect(result).toBe(dayjs(FIXED_DATE).add(7, 'days').toISOString()) }) it('should return a date exactly 30 days from now for "month"', () => { const result = getExpirationDate('month')! expect(result).toBe(dayjs(FIXED_DATE).add(30, 'days').toISOString()) }) it('should return undefined for "never"', () => { expect(getExpirationDate('never')).toBeUndefined() }) it('should return undefined for an unknown key', () => { expect(getExpirationDate('unknown')).toBeUndefined() }) it('should return a valid ISO string', () => { const result = getExpirationDate('hour')! expect(dayjs(result).isValid()).toBe(true) expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/) }) })