Skip to content

Commit 14a57d6

Browse files
dnplkndllclaude
andcommitted
test: add unit tests for API token scope enforcement
- scopes.test.ts: 8 tests for hasScope() and getRequiredScope() logic - apiTokenScopes.test.ts: 7 tests for createApiToken scope validation (valid scopes, multiple scopes, no scopes backward compat, invalid format rejection, empty array rejection, domain-scope rejection) and listApiTokens scopes inclusion - Export hasScope/getRequiredScope from rpc.ts for testability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Don Kendall <kendall@donkendall.com>
1 parent 58c00a1 commit 14a57d6

3 files changed

Lines changed: 312 additions & 2 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// Copyright © 2025 Hardcore Engineering Inc.
3+
//
4+
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License. You may
6+
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
//
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
import { hasScope, getRequiredScope } from '../rpc'
17+
18+
describe('hasScope', () => {
19+
test('returns true when scope is present', () => {
20+
expect(hasScope(['read:*', 'write:*'], 'read:*')).toBe(true)
21+
expect(hasScope(['read:*', 'write:*'], 'write:*')).toBe(true)
22+
})
23+
24+
test('returns false when scope is missing', () => {
25+
expect(hasScope(['read:*'], 'write:*')).toBe(false)
26+
expect(hasScope(['read:*'], 'delete:*')).toBe(false)
27+
})
28+
29+
test('returns false for empty scopes array', () => {
30+
expect(hasScope([], 'read:*')).toBe(false)
31+
})
32+
33+
test('requires exact match', () => {
34+
expect(hasScope(['read:tracker'], 'read:*')).toBe(false)
35+
expect(hasScope(['read:*'], 'read:tracker')).toBe(false)
36+
})
37+
})
38+
39+
describe('getRequiredScope', () => {
40+
test('ping and generateId require no scope', () => {
41+
expect(getRequiredScope('ping')).toBeNull()
42+
expect(getRequiredScope('generateId')).toBeNull()
43+
})
44+
45+
test('read methods require read:*', () => {
46+
expect(getRequiredScope('findAll')).toBe('read:*')
47+
expect(getRequiredScope('searchFulltext')).toBe('read:*')
48+
expect(getRequiredScope('loadModel')).toBe('read:*')
49+
expect(getRequiredScope('account')).toBe('read:*')
50+
})
51+
52+
test('write methods require write:*', () => {
53+
expect(getRequiredScope('tx')).toBe('write:*')
54+
expect(getRequiredScope('domainRequest')).toBe('write:*')
55+
expect(getRequiredScope('ensurePerson')).toBe('write:*')
56+
})
57+
58+
test('unknown methods default to read:*', () => {
59+
expect(getRequiredScope('someUnknownMethod')).toBe('read:*')
60+
})
61+
})

pods/server/src/rpc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClie
161161
// ── Token Scope Enforcement ─────────────────────────────────────────
162162
// Phase 1: coarse scopes only (read:*, write:*, delete:*)
163163

164-
function hasScope (scopes: string[], required: string): boolean {
164+
export function hasScope (scopes: string[], required: string): boolean {
165165
return scopes.includes(required)
166166
}
167167

168-
function getRequiredScope (method: string): string | null {
168+
export function getRequiredScope (method: string): string | null {
169169
switch (method) {
170170
case 'ping':
171171
case 'generateId':
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
//
2+
// Copyright © 2025 Hardcore Engineering Inc.
3+
//
4+
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License. You may
6+
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
//
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
import {
17+
AccountRole,
18+
type MeasureContext,
19+
type PersonUuid,
20+
type WorkspaceUuid
21+
} from '@hcengineering/core'
22+
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
23+
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
24+
25+
import { type AccountDB } from '../types'
26+
import { getMethods } from '../operations'
27+
28+
jest.mock('@hcengineering/platform', () => {
29+
const actual = jest.requireActual('@hcengineering/platform')
30+
return {
31+
...actual,
32+
...actual.default,
33+
getMetadata: jest.fn(),
34+
translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`)
35+
}
36+
})
37+
38+
jest.mock('@hcengineering/server-token', () => {
39+
class TokenError extends Error {
40+
constructor (msg: string) {
41+
super(msg)
42+
this.name = 'TokenError'
43+
}
44+
}
45+
return {
46+
decodeTokenVerbose: jest.fn(),
47+
decodeToken: jest.fn(),
48+
TokenError,
49+
generateToken: jest.fn().mockImplementation((account: string, workspace: string, extra: any) => {
50+
return `mocked-token-${account}-${workspace}-${JSON.stringify(extra)}`
51+
})
52+
}
53+
})
54+
55+
describe('createApiToken scopes', () => {
56+
const mockCtx = {
57+
error: jest.fn(),
58+
info: jest.fn(),
59+
warn: jest.fn()
60+
} as unknown as MeasureContext
61+
62+
const accountUuid = 'account-uuid' as PersonUuid
63+
const workspaceUuid = 'workspace-uuid' as WorkspaceUuid
64+
65+
const mockDb = {
66+
account: { findOne: jest.fn() },
67+
workspace: { find: jest.fn().mockResolvedValue([]) },
68+
apiToken: {
69+
find: jest.fn().mockResolvedValue([]),
70+
findOne: jest.fn(),
71+
insertOne: jest.fn(),
72+
update: jest.fn()
73+
},
74+
getWorkspaceRole: jest.fn()
75+
} as unknown as AccountDB
76+
77+
const methods = getMethods()
78+
const createApiToken = methods.createApiToken!
79+
80+
beforeEach(() => {
81+
jest.clearAllMocks()
82+
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
83+
account: accountUuid,
84+
workspace: workspaceUuid,
85+
extra: {}
86+
})
87+
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Owner)
88+
;(mockDb.apiToken.find as jest.Mock).mockResolvedValue([])
89+
;(mockDb.apiToken.insertOne as jest.Mock).mockResolvedValue(undefined)
90+
})
91+
92+
test('creates token with valid scopes', async () => {
93+
const result = await createApiToken(
94+
mockCtx, mockDb, null,
95+
{ id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:*'] } },
96+
'test-token'
97+
)
98+
99+
expect(result.result).toBeDefined()
100+
expect(result.result.id).toBeDefined()
101+
expect(result.result.token).toContain('mocked-token')
102+
103+
// Verify scopes were passed to generateToken
104+
expect(generateToken).toHaveBeenCalledWith(
105+
accountUuid,
106+
workspaceUuid,
107+
expect.objectContaining({ scopes: '["read:*"]' }),
108+
undefined,
109+
expect.any(Object)
110+
)
111+
112+
// Verify scopes were persisted to DB
113+
expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith(
114+
expect.objectContaining({ scopes: ['read:*'] })
115+
)
116+
})
117+
118+
test('creates token with multiple valid scopes', async () => {
119+
const result = await createApiToken(
120+
mockCtx, mockDb, null,
121+
{ id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:*', 'write:*', 'delete:*'] } },
122+
'test-token'
123+
)
124+
125+
expect(result.result).toBeDefined()
126+
expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith(
127+
expect.objectContaining({ scopes: ['read:*', 'write:*', 'delete:*'] })
128+
)
129+
})
130+
131+
test('creates token without scopes (full access, backward compat)', async () => {
132+
const result = await createApiToken(
133+
mockCtx, mockDb, null,
134+
{ id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30 } },
135+
'test-token'
136+
)
137+
138+
expect(result.result).toBeDefined()
139+
140+
// No scopes in JWT extra
141+
expect(generateToken).toHaveBeenCalledWith(
142+
accountUuid,
143+
workspaceUuid,
144+
expect.not.objectContaining({ scopes: expect.anything() }),
145+
undefined,
146+
expect.any(Object)
147+
)
148+
149+
// scopes field should be undefined in DB insert
150+
const insertArg = (mockDb.apiToken.insertOne as jest.Mock).mock.calls[0][0]
151+
expect(insertArg.scopes).toBeUndefined()
152+
})
153+
154+
test('rejects invalid scope format', async () => {
155+
const result = await createApiToken(
156+
mockCtx, mockDb, null,
157+
{ id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['invalid'] } },
158+
'test-token'
159+
)
160+
161+
expect(result.error).toBeDefined()
162+
})
163+
164+
test('rejects empty scopes array', async () => {
165+
const result = await createApiToken(
166+
mockCtx, mockDb, null,
167+
{ id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: [] } },
168+
'test-token'
169+
)
170+
171+
expect(result.error).toBeDefined()
172+
})
173+
174+
test('rejects domain-scoped scopes in Phase 1', async () => {
175+
const result = await createApiToken(
176+
mockCtx, mockDb, null,
177+
{ id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:tracker'] } },
178+
'test-token'
179+
)
180+
181+
expect(result.error).toBeDefined()
182+
})
183+
})
184+
185+
describe('listApiTokens includes scopes', () => {
186+
const mockCtx = {
187+
error: jest.fn(),
188+
info: jest.fn(),
189+
warn: jest.fn()
190+
} as unknown as MeasureContext
191+
192+
const accountUuid = 'account-uuid' as PersonUuid
193+
const workspaceUuid = 'workspace-uuid' as WorkspaceUuid
194+
195+
const mockDb = {
196+
workspace: {
197+
find: jest.fn().mockResolvedValue([{ uuid: workspaceUuid, name: 'Test' }])
198+
},
199+
apiToken: {
200+
find: jest.fn().mockResolvedValue([
201+
{
202+
id: 'token-1',
203+
accountUuid,
204+
name: 'Read Only',
205+
workspaceUuid,
206+
createdOn: 1000,
207+
expiresOn: 2000,
208+
revoked: false,
209+
scopes: ['read:*']
210+
},
211+
{
212+
id: 'token-2',
213+
accountUuid,
214+
name: 'Legacy',
215+
workspaceUuid,
216+
createdOn: 1000,
217+
expiresOn: 2000,
218+
revoked: false
219+
// no scopes field (legacy)
220+
}
221+
])
222+
}
223+
} as unknown as AccountDB
224+
225+
const methods = getMethods()
226+
const listApiTokens = methods.listApiTokens!
227+
228+
beforeEach(() => {
229+
jest.clearAllMocks()
230+
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
231+
account: accountUuid,
232+
workspace: workspaceUuid,
233+
extra: {}
234+
})
235+
})
236+
237+
test('returns scopes for scoped tokens and undefined for legacy', async () => {
238+
const result = await listApiTokens(
239+
mockCtx, mockDb, null,
240+
{ id: 1, params: {} },
241+
'test-token'
242+
)
243+
244+
const tokens = result.result
245+
expect(tokens).toHaveLength(2)
246+
expect(tokens[0].scopes).toEqual(['read:*'])
247+
expect(tokens[1].scopes).toBeUndefined()
248+
})
249+
})

0 commit comments

Comments
 (0)