mirror of
https://github.com/supabase/supabase.git
synced 2026-06-13 01:39:53 +08:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
375 lines
12 KiB
TypeScript
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/)
|
|
})
|
|
})
|