diff --git a/frontend/src/services/apiToken.test.ts b/frontend/src/services/apiToken.test.ts new file mode 100644 index 000000000..c4be5d382 --- /dev/null +++ b/frontend/src/services/apiToken.test.ts @@ -0,0 +1,35 @@ +import {describe, it, expect} from 'vitest' + +import {objectToSnakeCase} from '@/helpers/case' + +// Regression test: objectToSnakeCase mangles hyphenated permission group +// names like "time-entries" → "time_entries". ApiTokenService.beforeCreate +// works around this by restoring the original permissions after the transform. +// This test ensures the underlying problem is documented and will tell us +// if the library behaviour changes. +describe('objectToSnakeCase on API token permissions', () => { + it('mangles time-entries to time_entries (the bug we work around)', () => { + const input = { + title: 'test', + expiresAt: '2099-01-01T00:00:00Z', + permissions: { + 'tasks': ['read_all', 'create'], + 'time-entries': ['read_all', 'create'], + 'tasks_assignees': ['create', 'delete'], + }, + } + + const result = objectToSnakeCase(input) + + // The outer key is fine + expect(result.expires_at).toBe('2099-01-01T00:00:00Z') + + // time-entries gets mangled — this is the bug + expect(result.permissions['time_entries']).toEqual(['read_all', 'create']) + expect(result.permissions['time-entries']).toBeUndefined() + + // Other groups survive + expect(result.permissions['tasks']).toEqual(['read_all', 'create']) + expect(result.permissions['tasks_assignees']).toEqual(['create', 'delete']) + }) +}) diff --git a/frontend/src/services/apiToken.ts b/frontend/src/services/apiToken.ts index 3f1914936..628dd91e0 100644 --- a/frontend/src/services/apiToken.ts +++ b/frontend/src/services/apiToken.ts @@ -1,6 +1,7 @@ import AbstractService from '@/services/abstractService' import type {IApiToken} from '@/modelTypes/IApiToken' import ApiTokenModel from '@/models/apiTokenModel' +import {objectToSnakeCase} from '@/helpers/case' export default class ApiTokenService extends AbstractService { constructor() { @@ -11,6 +12,19 @@ export default class ApiTokenService extends AbstractService { }) } + // Disable the default snake_case transform — beforeCreate handles it + // manually to preserve the permissions map keys (e.g. "time-entries"). + autoTransformBeforePut(): boolean { + return false + } + + beforeCreate(model: IApiToken) { + const permissions = model.permissions + const transformed = objectToSnakeCase(model) + transformed.permissions = permissions + return transformed + } + processModel(model: IApiToken) { return { ...model, @@ -18,11 +32,11 @@ export default class ApiTokenService extends AbstractService { created: new Date(model.created).toISOString(), } } - + modelFactory(data: Partial) { return new ApiTokenModel(data) } - + async getAvailableRoutes() { const cancel = this.setLoading()