fix(auth): preserve permission group names when creating API tokens

objectToSnakeCase recursively transforms all object keys, including the
permissions map. This mangles "time-entries" → "time_entries", causing
the backend to reject the permission as invalid. Override
autoTransformBeforePut and manually restore the original permissions
map after the snake_case transform in beforeCreate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bradley Erickson 2026-06-24 13:17:48 -07:00
parent f8eacca7c8
commit f5a99e4c15
2 changed files with 51 additions and 2 deletions

View File

@ -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'])
})
})

View File

@ -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<IApiToken> {
constructor() {
@ -11,6 +12,19 @@ export default class ApiTokenService extends AbstractService<IApiToken> {
})
}
// 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<IApiToken> {
created: new Date(model.created).toISOString(),
}
}
modelFactory(data: Partial<IApiToken>) {
return new ApiTokenModel(data)
}
async getAvailableRoutes() {
const cancel = this.setLoading()