Files
supabase/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.test.ts
2026-04-01 10:22:37 +02:00

375 lines
12 KiB
TypeScript

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> = {}): 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/)
})
})