@trailmix-cms/cms 0.4.4 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.guard.d.ts +5 -13
- package/dist/auth.guard.d.ts.map +1 -1
- package/dist/auth.guard.js +24 -95
- package/dist/auth.guard.js.map +1 -1
- package/dist/collections/account.collection.d.ts +5 -3
- package/dist/collections/account.collection.d.ts.map +1 -1
- package/dist/collections/account.collection.js +15 -8
- package/dist/collections/account.collection.js.map +1 -1
- package/dist/collections/api-key.collection.d.ts +54 -0
- package/dist/collections/api-key.collection.d.ts.map +1 -0
- package/dist/collections/api-key.collection.js +142 -0
- package/dist/collections/api-key.collection.js.map +1 -0
- package/dist/collections/index.d.ts +4 -2
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/collections/index.js +9 -5
- package/dist/collections/index.js.map +1 -1
- package/dist/collections/organization.collection.d.ts +20 -0
- package/dist/collections/organization.collection.d.ts.map +1 -0
- package/dist/collections/{file.collection.js → organization.collection.js} +17 -17
- package/dist/collections/organization.collection.js.map +1 -0
- package/dist/collections/role.collection.d.ts +32 -0
- package/dist/collections/role.collection.d.ts.map +1 -0
- package/dist/collections/role.collection.js +90 -0
- package/dist/collections/role.collection.js.map +1 -0
- package/dist/collections/security-audit.collection.d.ts +30 -0
- package/dist/collections/security-audit.collection.d.ts.map +1 -0
- package/dist/collections/security-audit.collection.js +79 -0
- package/dist/collections/security-audit.collection.js.map +1 -0
- package/dist/constants/cms-collection-names.d.ts +4 -2
- package/dist/constants/cms-collection-names.d.ts.map +1 -1
- package/dist/constants/cms-collection-names.js +4 -2
- package/dist/constants/cms-collection-names.js.map +1 -1
- package/dist/constants/provider-symbols.d.ts +10 -12
- package/dist/constants/provider-symbols.d.ts.map +1 -1
- package/dist/constants/provider-symbols.js +10 -12
- package/dist/constants/provider-symbols.js.map +1 -1
- package/dist/controllers/account.controller.d.ts +11 -15
- package/dist/controllers/account.controller.d.ts.map +1 -1
- package/dist/controllers/account.controller.js +69 -13
- package/dist/controllers/account.controller.js.map +1 -1
- package/dist/controllers/api-keys.controller.d.ts +13 -0
- package/dist/controllers/api-keys.controller.d.ts.map +1 -0
- package/dist/controllers/api-keys.controller.js +125 -0
- package/dist/controllers/api-keys.controller.js.map +1 -0
- package/dist/controllers/audit.controller.d.ts.map +1 -1
- package/dist/controllers/audit.controller.js +3 -3
- package/dist/controllers/audit.controller.js.map +1 -1
- package/dist/controllers/audits.controller.d.ts +10 -0
- package/dist/controllers/audits.controller.d.ts.map +1 -0
- package/dist/controllers/audits.controller.js +107 -0
- package/dist/controllers/audits.controller.js.map +1 -0
- package/dist/controllers/global-roles.controller.d.ts +16 -0
- package/dist/controllers/global-roles.controller.d.ts.map +1 -0
- package/dist/controllers/global-roles.controller.js +137 -0
- package/dist/controllers/global-roles.controller.js.map +1 -0
- package/dist/controllers/index.d.ts +6 -1
- package/dist/controllers/index.d.ts.map +1 -1
- package/dist/controllers/index.js +6 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/organization-roles.controller.d.ts +16 -0
- package/dist/controllers/organization-roles.controller.d.ts.map +1 -0
- package/dist/controllers/organization-roles.controller.js +145 -0
- package/dist/controllers/organization-roles.controller.js.map +1 -0
- package/dist/controllers/organizations.controller.d.ts +65 -0
- package/dist/controllers/organizations.controller.d.ts.map +1 -0
- package/dist/controllers/organizations.controller.js +140 -0
- package/dist/controllers/organizations.controller.js.map +1 -0
- package/dist/controllers/security-audits.controller.d.ts +11 -0
- package/dist/controllers/security-audits.controller.d.ts.map +1 -0
- package/dist/controllers/security-audits.controller.js +130 -0
- package/dist/controllers/security-audits.controller.js.map +1 -0
- package/dist/decorators/account.decorator.d.ts +1 -3
- package/dist/decorators/account.decorator.d.ts.map +1 -1
- package/dist/decorators/account.decorator.js +3 -10
- package/dist/decorators/account.decorator.js.map +1 -1
- package/dist/decorators/audit-context.decorator.d.ts +6 -0
- package/dist/decorators/audit-context.decorator.d.ts.map +1 -1
- package/dist/decorators/audit-context.decorator.js +12 -3
- package/dist/decorators/audit-context.decorator.js.map +1 -1
- package/dist/decorators/auth.decorator.d.ts +7 -6
- package/dist/decorators/auth.decorator.d.ts.map +1 -1
- package/dist/decorators/auth.decorator.js +38 -5
- package/dist/decorators/auth.decorator.js.map +1 -1
- package/dist/decorators/index.d.ts +4 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +20 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/dto/account.dto.d.ts +33 -0
- package/dist/dto/account.dto.d.ts.map +1 -0
- package/dist/dto/account.dto.js +14 -0
- package/dist/dto/account.dto.js.map +1 -0
- package/dist/dto/api-key.dto.d.ts +89 -0
- package/dist/dto/api-key.dto.d.ts.map +1 -0
- package/dist/dto/api-key.dto.js +27 -0
- package/dist/dto/api-key.dto.js.map +1 -0
- package/dist/dto/audit.dto.d.ts +11 -5
- package/dist/dto/audit.dto.d.ts.map +1 -1
- package/dist/dto/audit.dto.js +1 -1
- package/dist/dto/audit.dto.js.map +1 -1
- package/dist/dto/global-role.dto.d.ts +99 -0
- package/dist/dto/global-role.dto.d.ts.map +1 -0
- package/dist/dto/global-role.dto.js +26 -0
- package/dist/dto/global-role.dto.js.map +1 -0
- package/dist/dto/organization-role.dto.d.ts +107 -0
- package/dist/dto/organization-role.dto.d.ts.map +1 -0
- package/dist/dto/organization-role.dto.js +26 -0
- package/dist/dto/organization-role.dto.js.map +1 -0
- package/dist/dto/organization.dto.d.ts +57 -0
- package/dist/dto/organization.dto.d.ts.map +1 -0
- package/dist/dto/organization.dto.js +32 -0
- package/dist/dto/organization.dto.js.map +1 -0
- package/dist/dto/security-audit.dto.d.ts +95 -0
- package/dist/dto/security-audit.dto.d.ts.map +1 -0
- package/dist/dto/security-audit.dto.js +26 -0
- package/dist/dto/security-audit.dto.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/managers/global-role.manager.d.ts +42 -0
- package/dist/managers/global-role.manager.d.ts.map +1 -0
- package/dist/managers/global-role.manager.js +117 -0
- package/dist/managers/global-role.manager.js.map +1 -0
- package/dist/managers/index.d.ts +4 -0
- package/dist/managers/index.d.ts.map +1 -0
- package/dist/managers/index.js +20 -0
- package/dist/managers/index.js.map +1 -0
- package/dist/managers/organization-role.manager.d.ts +47 -0
- package/dist/managers/organization-role.manager.d.ts.map +1 -0
- package/dist/managers/organization-role.manager.js +218 -0
- package/dist/managers/organization-role.manager.js.map +1 -0
- package/dist/managers/organization.manager.d.ts +39 -0
- package/dist/managers/organization.manager.d.ts.map +1 -0
- package/dist/managers/organization.manager.js +196 -0
- package/dist/managers/organization.manager.js.map +1 -0
- package/dist/module.d.ts +92 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +137 -0
- package/dist/module.js.map +1 -0
- package/dist/pipes/api-key.pipe.d.ts +8 -0
- package/dist/pipes/api-key.pipe.d.ts.map +1 -0
- package/dist/pipes/api-key.pipe.js +28 -0
- package/dist/pipes/api-key.pipe.js.map +1 -0
- package/dist/pipes/organization.pipe.d.ts +8 -0
- package/dist/pipes/organization.pipe.d.ts.map +1 -0
- package/dist/pipes/organization.pipe.js +28 -0
- package/dist/pipes/organization.pipe.js.map +1 -0
- package/dist/pipes/role.pipe.d.ts +8 -0
- package/dist/pipes/{file.pipe.d.ts.map → role.pipe.d.ts.map} +1 -1
- package/dist/pipes/{file.pipe.js → role.pipe.js} +8 -8
- package/dist/pipes/{file.pipe.js.map → role.pipe.js.map} +1 -1
- package/dist/services/account.service.d.ts +0 -2
- package/dist/services/account.service.d.ts.map +1 -1
- package/dist/services/account.service.js +1 -37
- package/dist/services/account.service.js.map +1 -1
- package/dist/services/api-key.service.d.ts +42 -0
- package/dist/services/api-key.service.d.ts.map +1 -0
- package/dist/services/api-key.service.js +306 -0
- package/dist/services/api-key.service.js.map +1 -0
- package/dist/services/auth.service.d.ts +50 -0
- package/dist/services/auth.service.d.ts.map +1 -0
- package/dist/services/auth.service.js +259 -0
- package/dist/services/auth.service.js.map +1 -0
- package/dist/services/authorization.service.d.ts +44 -9
- package/dist/services/authorization.service.d.ts.map +1 -1
- package/dist/services/authorization.service.js +107 -41
- package/dist/services/authorization.service.js.map +1 -1
- package/dist/services/feature.service.d.ts +23 -0
- package/dist/services/feature.service.d.ts.map +1 -0
- package/dist/services/feature.service.js +49 -0
- package/dist/services/feature.service.js.map +1 -0
- package/dist/services/global-role.service.d.ts +17 -0
- package/dist/services/global-role.service.d.ts.map +1 -0
- package/dist/services/global-role.service.js +99 -0
- package/dist/services/global-role.service.js.map +1 -0
- package/dist/services/index.d.ts +9 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +25 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/organization-role.service.d.ts +33 -0
- package/dist/services/organization-role.service.d.ts.map +1 -0
- package/dist/services/organization-role.service.js +102 -0
- package/dist/services/organization-role.service.js.map +1 -0
- package/dist/services/organization.service.d.ts +29 -0
- package/dist/services/organization.service.d.ts.map +1 -0
- package/dist/services/organization.service.js +95 -0
- package/dist/services/organization.service.js.map +1 -0
- package/dist/types/feature-config.d.ts +9 -0
- package/dist/types/feature-config.d.ts.map +1 -0
- package/dist/types/feature-config.js +3 -0
- package/dist/types/feature-config.js.map +1 -0
- package/dist/types/hooks/auth-guard-hook.d.ts.map +1 -0
- package/dist/types/hooks/auth-guard-hook.js.map +1 -0
- package/dist/types/hooks/index.d.ts +3 -0
- package/dist/types/hooks/index.d.ts.map +1 -0
- package/dist/types/hooks/index.js +19 -0
- package/dist/types/hooks/index.js.map +1 -0
- package/dist/types/hooks/organization-delete-hook.d.ts +20 -0
- package/dist/types/hooks/organization-delete-hook.d.ts.map +1 -0
- package/dist/types/hooks/organization-delete-hook.js +3 -0
- package/dist/types/hooks/organization-delete-hook.js.map +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +21 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/request-principal.d.ts +9 -0
- package/dist/types/request-principal.d.ts.map +1 -0
- package/dist/types/request-principal.js +3 -0
- package/dist/types/request-principal.js.map +1 -0
- package/dist/utils/provider-helpers.d.ts +6 -1
- package/dist/utils/provider-helpers.d.ts.map +1 -1
- package/dist/utils/provider-helpers.js +11 -1
- package/dist/utils/provider-helpers.js.map +1 -1
- package/package.json +52 -17
- package/test/unit/auth.guard.spec.ts +355 -0
- package/test/unit/collections/api-key.collection.spec.ts +416 -0
- package/test/unit/managers/global-role.manager.spec.ts +269 -0
- package/test/unit/managers/organization-role.manager.spec.ts +632 -0
- package/test/unit/managers/organization.manager.spec.ts +395 -0
- package/test/unit/module.spec.ts +596 -0
- package/test/unit/services/account.service.spec.ts +90 -0
- package/test/unit/services/api-key.service.spec.ts +1244 -0
- package/test/unit/services/auth.service.spec.ts +1036 -0
- package/test/unit/services/authorization.service.spec.ts +636 -0
- package/test/unit/services/feature.service.spec.ts +56 -0
- package/test/unit/services/global-role.service.spec.ts +289 -0
- package/test/unit/services/organization-role.service.spec.ts +300 -0
- package/test/unit/services/organization.service.spec.ts +385 -0
- package/test/utils/auth-guard.ts +114 -0
- package/test/utils/base.ts +16 -0
- package/test/utils/entities/account.ts +13 -0
- package/test/utils/entities/api-key.ts +15 -0
- package/test/utils/entities/audit.ts +18 -0
- package/test/utils/entities/index.ts +6 -0
- package/test/utils/entities/mapping.ts +20 -0
- package/test/utils/entities/organization.ts +13 -0
- package/test/utils/entities/role.ts +21 -0
- package/test/utils/entities/security-audit.ts +16 -0
- package/test/utils/index.ts +4 -0
- package/test/utils/models/audit-context.ts +10 -0
- package/test/utils/models/authorization.ts +7 -0
- package/test/utils/models/global-role.ts +22 -0
- package/test/utils/models/index.ts +5 -0
- package/test/utils/models/organization-role.ts +23 -0
- package/test/utils/models/publishable.ts +7 -0
- package/tsconfig.build.json +36 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/dist/auth-guard-hook.d.ts.map +0 -1
- package/dist/auth-guard-hook.js.map +0 -1
- package/dist/cms.module.d.ts +0 -8
- package/dist/cms.module.d.ts.map +0 -1
- package/dist/cms.module.js +0 -44
- package/dist/cms.module.js.map +0 -1
- package/dist/cms.providers.d.ts +0 -120
- package/dist/cms.providers.d.ts.map +0 -1
- package/dist/cms.providers.js +0 -126
- package/dist/cms.providers.js.map +0 -1
- package/dist/collections/file.collection.d.ts +0 -21
- package/dist/collections/file.collection.d.ts.map +0 -1
- package/dist/collections/file.collection.js.map +0 -1
- package/dist/collections/text.collection.d.ts +0 -20
- package/dist/collections/text.collection.d.ts.map +0 -1
- package/dist/collections/text.collection.js +0 -56
- package/dist/collections/text.collection.js.map +0 -1
- package/dist/pipes/file.pipe.d.ts +0 -8
- /package/dist/{auth-guard-hook.d.ts → types/hooks/auth-guard-hook.d.ts} +0 -0
- /package/dist/{auth-guard-hook.js → types/hooks/auth-guard-hook.js} +0 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { BadRequestException, InternalServerErrorException, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
|
3
|
+
import { ObjectId } from 'mongodb';
|
|
4
|
+
import { faker } from '@faker-js/faker';
|
|
5
|
+
|
|
6
|
+
import * as trailmixModels from '@trailmix-cms/models';
|
|
7
|
+
|
|
8
|
+
import * as TestUtils from '../../utils';
|
|
9
|
+
|
|
10
|
+
import { ApiKeyService, GetApiKeysParams } from '@/services';
|
|
11
|
+
import { ApiKeyCollection, OrganizationCollection, SecurityAuditCollection } from '@/collections';
|
|
12
|
+
import { AuthorizationService } from '@/services/authorization.service';
|
|
13
|
+
import { FeatureService } from '@/services/feature.service';
|
|
14
|
+
import { RequestPrincipal } from '@/types';
|
|
15
|
+
import { createAuditContextForPrincipal } from '@/decorators/audit-context.decorator';
|
|
16
|
+
import { Utils } from '@trailmix-cms/db';
|
|
17
|
+
|
|
18
|
+
describe('ApiKeyService', () => {
|
|
19
|
+
let service: ApiKeyService;
|
|
20
|
+
let apiKeyCollection: jest.Mocked<ApiKeyCollection>;
|
|
21
|
+
let authorizationService: jest.Mocked<AuthorizationService>;
|
|
22
|
+
let securityAuditCollection: jest.Mocked<SecurityAuditCollection>;
|
|
23
|
+
let featureService: jest.Mocked<FeatureService>;
|
|
24
|
+
let organizationCollection: jest.Mocked<OrganizationCollection>;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
// Mock Logger methods to prevent console output during tests
|
|
28
|
+
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
|
29
|
+
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
|
30
|
+
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
|
31
|
+
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
|
32
|
+
jest.spyOn(Logger.prototype, 'verbose').mockImplementation();
|
|
33
|
+
const mockApiKeyCollection = {
|
|
34
|
+
create: jest.fn(),
|
|
35
|
+
find: jest.fn(),
|
|
36
|
+
deleteOne: jest.fn(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mockAuthorizationService = {
|
|
40
|
+
isGlobalAdmin: jest.fn(),
|
|
41
|
+
resolveOrganizationAuthorization: jest.fn(),
|
|
42
|
+
authorizeApiKeyAccessForPrincipal: jest.fn(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const mockOrganizationCollection = {
|
|
46
|
+
findOne: jest.fn(),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const mockSecurityAuditCollection = {
|
|
50
|
+
insertOne: jest.fn().mockResolvedValue(undefined),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const mockFeatureService = {
|
|
54
|
+
isOrganizationsEnabled: jest.fn().mockReturnValue(true),
|
|
55
|
+
isFileEnabled: jest.fn(),
|
|
56
|
+
isTextEnabled: jest.fn(),
|
|
57
|
+
isApiKeysEnabled: jest.fn(),
|
|
58
|
+
getApiKeyScopes: jest.fn().mockReturnValue([trailmixModels.ApiKeyScope.Account, trailmixModels.ApiKeyScope.Organization, trailmixModels.ApiKeyScope.Global]), // Default: no scope restrictions
|
|
59
|
+
getFeatures: jest.fn(),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
63
|
+
providers: [
|
|
64
|
+
ApiKeyService,
|
|
65
|
+
{
|
|
66
|
+
provide: ApiKeyCollection,
|
|
67
|
+
useValue: mockApiKeyCollection,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
provide: AuthorizationService,
|
|
71
|
+
useValue: mockAuthorizationService,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
provide: OrganizationCollection,
|
|
75
|
+
useValue: mockOrganizationCollection,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
provide: SecurityAuditCollection,
|
|
79
|
+
useValue: mockSecurityAuditCollection,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
provide: FeatureService,
|
|
83
|
+
useValue: mockFeatureService,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
}).compile();
|
|
87
|
+
|
|
88
|
+
service = module.get<ApiKeyService>(ApiKeyService);
|
|
89
|
+
apiKeyCollection = module.get(ApiKeyCollection);
|
|
90
|
+
authorizationService = module.get(AuthorizationService);
|
|
91
|
+
securityAuditCollection = module.get(SecurityAuditCollection);
|
|
92
|
+
featureService = module.get(FeatureService);
|
|
93
|
+
organizationCollection = module.get(OrganizationCollection);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
jest.clearAllMocks();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(() => {
|
|
101
|
+
// Restore Logger methods after all tests
|
|
102
|
+
jest.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('createApiKey', () => {
|
|
106
|
+
it('throws BadRequestException when principal is not an Account (ensuring only accounts can create API keys)', async () => {
|
|
107
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
108
|
+
const apiKeyPrincipal: RequestPrincipal = {
|
|
109
|
+
principal_type: trailmixModels.Principal.ApiKey,
|
|
110
|
+
entity: apiKeyEntity,
|
|
111
|
+
};
|
|
112
|
+
const auditContext = createAuditContextForPrincipal(apiKeyPrincipal);
|
|
113
|
+
|
|
114
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
115
|
+
name: faker.word.noun(),
|
|
116
|
+
api_key: faker.string.alphanumeric(32),
|
|
117
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
118
|
+
scope_id: new ObjectId(),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await expect(service.createApiKey(params, apiKeyPrincipal, auditContext)).rejects.toThrow(BadRequestException);
|
|
122
|
+
await expect(service.createApiKey(params, apiKeyPrincipal, auditContext)).rejects.toThrow('Only accounts can create account-scoped API keys');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Scope validation', () => {
|
|
126
|
+
it('throws BadRequestException when scopes array is empty (ensuring empty scope configuration prevents all API key creation)', async () => {
|
|
127
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
128
|
+
const accountPrincipal: RequestPrincipal = {
|
|
129
|
+
principal_type: trailmixModels.Principal.Account,
|
|
130
|
+
entity: accountEntity,
|
|
131
|
+
};
|
|
132
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
133
|
+
|
|
134
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
135
|
+
name: faker.word.noun(),
|
|
136
|
+
api_key: faker.string.alphanumeric(32),
|
|
137
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
138
|
+
scope_id: accountEntity._id,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
featureService.getApiKeyScopes.mockReturnValue([]);
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
145
|
+
).rejects.toThrow(BadRequestException);
|
|
146
|
+
|
|
147
|
+
expect(featureService.getApiKeyScopes).toHaveBeenCalled();
|
|
148
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
149
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
150
|
+
principal_id: accountEntity._id,
|
|
151
|
+
principal_type: trailmixModels.Principal.Account,
|
|
152
|
+
message: `Unauthorized attempt to create API key with scope ${trailmixModels.ApiKeyScope.Account} which is not in the allowed scopes`,
|
|
153
|
+
source: ApiKeyService.name,
|
|
154
|
+
});
|
|
155
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('allows creation when scope is in the allowed scopes list (ensuring valid scopes pass validation)', async () => {
|
|
159
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
160
|
+
const accountPrincipal: RequestPrincipal = {
|
|
161
|
+
principal_type: trailmixModels.Principal.Account,
|
|
162
|
+
entity: accountEntity,
|
|
163
|
+
};
|
|
164
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
165
|
+
|
|
166
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
167
|
+
name: faker.word.noun(),
|
|
168
|
+
api_key: faker.string.alphanumeric(32),
|
|
169
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
170
|
+
scope_id: accountEntity._id,
|
|
171
|
+
};
|
|
172
|
+
const createdApiKey = TestUtils.Entities.createApiKey({
|
|
173
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
174
|
+
scope_id: accountEntity._id,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
featureService.getApiKeyScopes.mockReturnValue([
|
|
178
|
+
trailmixModels.ApiKeyScope.Account,
|
|
179
|
+
]);
|
|
180
|
+
apiKeyCollection.create.mockResolvedValue(createdApiKey);
|
|
181
|
+
|
|
182
|
+
const result = await service.createApiKey(params, accountPrincipal, auditContext);
|
|
183
|
+
|
|
184
|
+
expect(featureService.getApiKeyScopes).toHaveBeenCalled();
|
|
185
|
+
expect(apiKeyCollection.create).toHaveBeenCalledWith(params, auditContext);
|
|
186
|
+
expect(result).toEqual(createdApiKey);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('throws BadRequestException and logs security audit when scope is not in the allowed scopes list (ensuring unauthorized scope attempts are blocked and audited)', async () => {
|
|
190
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
191
|
+
const accountPrincipal: RequestPrincipal = {
|
|
192
|
+
principal_type: trailmixModels.Principal.Account,
|
|
193
|
+
entity: accountEntity,
|
|
194
|
+
};
|
|
195
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
196
|
+
|
|
197
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
198
|
+
name: faker.word.noun(),
|
|
199
|
+
api_key: faker.string.alphanumeric(32),
|
|
200
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
featureService.getApiKeyScopes.mockReturnValue([
|
|
204
|
+
trailmixModels.ApiKeyScope.Account,
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
await expect(
|
|
208
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
209
|
+
).rejects.toThrow(BadRequestException);
|
|
210
|
+
|
|
211
|
+
expect(featureService.getApiKeyScopes).toHaveBeenCalled();
|
|
212
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
213
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
214
|
+
principal_id: accountEntity._id,
|
|
215
|
+
principal_type: trailmixModels.Principal.Account,
|
|
216
|
+
message: `Unauthorized attempt to create API key with scope ${trailmixModels.ApiKeyScope.Organization} which is not in the allowed scopes`,
|
|
217
|
+
source: ApiKeyService.name,
|
|
218
|
+
});
|
|
219
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('Global scope', () => {
|
|
224
|
+
it('creates global-scoped API key for global admin (ensuring global admins can create global-scoped keys)', async () => {
|
|
225
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
226
|
+
const accountPrincipal: RequestPrincipal = {
|
|
227
|
+
principal_type: trailmixModels.Principal.Account,
|
|
228
|
+
entity: accountEntity,
|
|
229
|
+
};
|
|
230
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
231
|
+
|
|
232
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
233
|
+
name: faker.word.noun(),
|
|
234
|
+
api_key: faker.string.alphanumeric(32),
|
|
235
|
+
scope_type: trailmixModels.ApiKeyScope.Global,
|
|
236
|
+
};
|
|
237
|
+
const createdApiKey = TestUtils.Entities.createApiKey({
|
|
238
|
+
scope_type: trailmixModels.ApiKeyScope.Global
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Global]);
|
|
242
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
243
|
+
apiKeyCollection.create.mockResolvedValue(createdApiKey);
|
|
244
|
+
|
|
245
|
+
const result = await service.createApiKey(params, accountPrincipal, auditContext);
|
|
246
|
+
|
|
247
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
248
|
+
accountEntity._id,
|
|
249
|
+
trailmixModels.Principal.Account
|
|
250
|
+
);
|
|
251
|
+
expect(apiKeyCollection.create).toHaveBeenCalledWith(params, auditContext);
|
|
252
|
+
expect(result).toEqual(createdApiKey);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('throws BadRequestException when scope_type is Global and scope_id is provided (ensuring global-scoped keys cannot have scope_id)', async () => {
|
|
256
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
257
|
+
const accountPrincipal: RequestPrincipal = {
|
|
258
|
+
principal_type: trailmixModels.Principal.Account,
|
|
259
|
+
entity: accountEntity,
|
|
260
|
+
};
|
|
261
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
262
|
+
|
|
263
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
264
|
+
name: faker.word.noun(),
|
|
265
|
+
api_key: faker.string.alphanumeric(32),
|
|
266
|
+
scope_type: trailmixModels.ApiKeyScope.Global,
|
|
267
|
+
scope_id: new ObjectId(),
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Global]);
|
|
271
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
272
|
+
|
|
273
|
+
await expect(service.createApiKey(params, accountPrincipal, auditContext)).rejects.toThrow(BadRequestException);
|
|
274
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('throws BadRequestException and logs security audit when non-global admin tries to create global-scoped key (ensuring only global admins can create global-scoped keys)', async () => {
|
|
278
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
279
|
+
const accountPrincipal: RequestPrincipal = {
|
|
280
|
+
principal_type: trailmixModels.Principal.Account,
|
|
281
|
+
entity: accountEntity,
|
|
282
|
+
};
|
|
283
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
284
|
+
|
|
285
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
286
|
+
name: faker.word.noun(),
|
|
287
|
+
api_key: faker.string.alphanumeric(32),
|
|
288
|
+
scope_type: trailmixModels.ApiKeyScope.Global,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Global]);
|
|
292
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
293
|
+
|
|
294
|
+
await expect(
|
|
295
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
296
|
+
).rejects.toThrow(BadRequestException);
|
|
297
|
+
|
|
298
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
299
|
+
accountEntity._id,
|
|
300
|
+
trailmixModels.Principal.Account
|
|
301
|
+
);
|
|
302
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
303
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
304
|
+
principal_id: accountEntity._id,
|
|
305
|
+
principal_type: trailmixModels.Principal.Account,
|
|
306
|
+
message: 'Unauthorized attempt to create global-scoped API key',
|
|
307
|
+
source: ApiKeyService.name,
|
|
308
|
+
});
|
|
309
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('Account scope', () => {
|
|
314
|
+
it('creates account-scoped API key when scope_id matches principal ID (ensuring users can only create keys for themselves)', async () => {
|
|
315
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
316
|
+
const accountPrincipal: RequestPrincipal = {
|
|
317
|
+
principal_type: trailmixModels.Principal.Account,
|
|
318
|
+
entity: accountEntity,
|
|
319
|
+
};
|
|
320
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
321
|
+
|
|
322
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
323
|
+
name: faker.word.noun(),
|
|
324
|
+
api_key: faker.string.alphanumeric(32),
|
|
325
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
326
|
+
scope_id: accountEntity._id,
|
|
327
|
+
};
|
|
328
|
+
const createdApiKey = TestUtils.Entities.createApiKey({
|
|
329
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
330
|
+
scope_id: accountEntity._id,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
apiKeyCollection.create.mockResolvedValue(createdApiKey);
|
|
334
|
+
|
|
335
|
+
const result = await service.createApiKey(params, accountPrincipal, auditContext);
|
|
336
|
+
|
|
337
|
+
expect(apiKeyCollection.create).toHaveBeenCalledWith(params, auditContext);
|
|
338
|
+
expect(result).toEqual(createdApiKey);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('creates account-scoped API key for global admin (ensuring global admins can create keys for any account)', async () => {
|
|
342
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
343
|
+
const accountPrincipal: RequestPrincipal = {
|
|
344
|
+
principal_type: trailmixModels.Principal.Account,
|
|
345
|
+
entity: accountEntity,
|
|
346
|
+
};
|
|
347
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
348
|
+
|
|
349
|
+
const otherAccountId = new ObjectId();
|
|
350
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
351
|
+
name: faker.word.noun(),
|
|
352
|
+
api_key: faker.string.alphanumeric(32),
|
|
353
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
354
|
+
scope_id: otherAccountId,
|
|
355
|
+
};
|
|
356
|
+
const createdApiKey = TestUtils.Entities.createApiKey({
|
|
357
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
358
|
+
scope_id: otherAccountId,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Account]);
|
|
362
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
363
|
+
apiKeyCollection.create.mockResolvedValue(createdApiKey);
|
|
364
|
+
|
|
365
|
+
const result = await service.createApiKey(params, accountPrincipal, auditContext);
|
|
366
|
+
|
|
367
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
368
|
+
accountEntity._id,
|
|
369
|
+
trailmixModels.Principal.Account
|
|
370
|
+
);
|
|
371
|
+
expect(apiKeyCollection.create).toHaveBeenCalledWith(params, auditContext);
|
|
372
|
+
expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
|
|
373
|
+
expect(result).toEqual(createdApiKey);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('throws BadRequestException and logs security audit when scope_id does not match principal ID (ensuring users cannot create keys for other accounts)', async () => {
|
|
377
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
378
|
+
const accountPrincipal: RequestPrincipal = {
|
|
379
|
+
principal_type: trailmixModels.Principal.Account,
|
|
380
|
+
entity: accountEntity,
|
|
381
|
+
};
|
|
382
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
383
|
+
|
|
384
|
+
const otherAccountId = new ObjectId();
|
|
385
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
386
|
+
name: faker.word.noun(),
|
|
387
|
+
api_key: faker.string.alphanumeric(32),
|
|
388
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
389
|
+
scope_id: otherAccountId,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Account]);
|
|
393
|
+
|
|
394
|
+
await expect(
|
|
395
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
396
|
+
).rejects.toThrow(BadRequestException);
|
|
397
|
+
|
|
398
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
399
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
400
|
+
principal_id: accountEntity._id,
|
|
401
|
+
principal_type: trailmixModels.Principal.Account,
|
|
402
|
+
message: 'Unauthorized attempt to create account-scoped API key for another principal',
|
|
403
|
+
source: ApiKeyService.name,
|
|
404
|
+
});
|
|
405
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('throws BadRequestException when scope_id is missing (ensuring account-scoped keys require scope_id)', async () => {
|
|
409
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
410
|
+
const accountPrincipal: RequestPrincipal = {
|
|
411
|
+
principal_type: trailmixModels.Principal.Account,
|
|
412
|
+
entity: accountEntity,
|
|
413
|
+
};
|
|
414
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
415
|
+
|
|
416
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
417
|
+
name: faker.word.noun(),
|
|
418
|
+
api_key: faker.string.alphanumeric(32),
|
|
419
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Account]);
|
|
423
|
+
|
|
424
|
+
await expect(service.createApiKey(params, accountPrincipal, auditContext)).rejects.toThrow(BadRequestException);
|
|
425
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
426
|
+
expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('Organization scope', () => {
|
|
431
|
+
it('successfully creates organization-scoped API key when user is a global admin (ensuring global admins can create org-scoped keys without organization role)', async () => {
|
|
432
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
433
|
+
const accountPrincipal: RequestPrincipal = {
|
|
434
|
+
principal_type: trailmixModels.Principal.Account,
|
|
435
|
+
entity: accountEntity,
|
|
436
|
+
};
|
|
437
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
438
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
439
|
+
|
|
440
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
441
|
+
name: faker.word.noun(),
|
|
442
|
+
api_key: faker.string.alphanumeric(32),
|
|
443
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
444
|
+
scope_id: organizationEntity._id,
|
|
445
|
+
};
|
|
446
|
+
const createdApiKey = TestUtils.Entities.createApiKey({
|
|
447
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
448
|
+
scope_id: organizationEntity._id,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
452
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
453
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
454
|
+
apiKeyCollection.create.mockResolvedValue(createdApiKey);
|
|
455
|
+
|
|
456
|
+
const result = await service.createApiKey(params, accountPrincipal, auditContext);
|
|
457
|
+
|
|
458
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
459
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
460
|
+
accountEntity._id,
|
|
461
|
+
trailmixModels.Principal.Account
|
|
462
|
+
);
|
|
463
|
+
expect(authorizationService.resolveOrganizationAuthorization).not.toHaveBeenCalled();
|
|
464
|
+
expect(apiKeyCollection.create).toHaveBeenCalledWith(params, auditContext);
|
|
465
|
+
expect(result).toEqual(createdApiKey);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('creates organization-scoped API key when user has admin/owner role (ensuring only org admins/owners can create org-scoped keys)', async () => {
|
|
469
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
470
|
+
const accountPrincipal: RequestPrincipal = {
|
|
471
|
+
principal_type: trailmixModels.Principal.Account,
|
|
472
|
+
entity: accountEntity,
|
|
473
|
+
};
|
|
474
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
475
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
476
|
+
|
|
477
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
478
|
+
name: faker.word.noun(),
|
|
479
|
+
api_key: faker.string.alphanumeric(32),
|
|
480
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
481
|
+
scope_id: organizationEntity._id,
|
|
482
|
+
};
|
|
483
|
+
const createdApiKey = TestUtils.Entities.createApiKey({
|
|
484
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
485
|
+
scope_id: organizationEntity._id,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
489
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
490
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
491
|
+
const adminRole = TestUtils.Models.createOrganizationRoleModel({
|
|
492
|
+
principal_id: accountEntity._id,
|
|
493
|
+
principal_type: trailmixModels.Principal.Account,
|
|
494
|
+
organization_id: organizationEntity._id,
|
|
495
|
+
role: trailmixModels.RoleValue.Admin,
|
|
496
|
+
});
|
|
497
|
+
authorizationService.resolveOrganizationAuthorization.mockResolvedValue({
|
|
498
|
+
hasAccess: true,
|
|
499
|
+
isGlobalAdmin: false,
|
|
500
|
+
globalRoles: [],
|
|
501
|
+
organizationRoles: [adminRole],
|
|
502
|
+
});
|
|
503
|
+
apiKeyCollection.create.mockResolvedValue(createdApiKey);
|
|
504
|
+
|
|
505
|
+
const result = await service.createApiKey(params, accountPrincipal, auditContext);
|
|
506
|
+
|
|
507
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
508
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
509
|
+
accountEntity._id,
|
|
510
|
+
trailmixModels.Principal.Account
|
|
511
|
+
);
|
|
512
|
+
expect(authorizationService.resolveOrganizationAuthorization).toHaveBeenCalledWith({
|
|
513
|
+
principal: accountPrincipal,
|
|
514
|
+
rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
|
|
515
|
+
principalTypeAllowList: [trailmixModels.Principal.Account],
|
|
516
|
+
organizationId: organizationEntity._id,
|
|
517
|
+
});
|
|
518
|
+
expect(apiKeyCollection.create).toHaveBeenCalledWith(params, auditContext);
|
|
519
|
+
expect(result).toEqual(createdApiKey);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('throws BadRequestException when organizations feature is not enabled (ensuring org-scoped keys require organizations feature)', async () => {
|
|
523
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
524
|
+
const accountPrincipal: RequestPrincipal = {
|
|
525
|
+
principal_type: trailmixModels.Principal.Account,
|
|
526
|
+
entity: accountEntity,
|
|
527
|
+
};
|
|
528
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
529
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
530
|
+
|
|
531
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
532
|
+
name: faker.word.noun(),
|
|
533
|
+
api_key: faker.string.alphanumeric(32),
|
|
534
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
535
|
+
scope_id: organizationEntity._id,
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
539
|
+
featureService.isOrganizationsEnabled.mockReturnValue(false);
|
|
540
|
+
|
|
541
|
+
await expect(
|
|
542
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
543
|
+
).rejects.toThrow(BadRequestException);
|
|
544
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
545
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('throws BadRequestException when scope_id is missing (ensuring organization-scoped keys require scope_id)', async () => {
|
|
549
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
550
|
+
const accountPrincipal: RequestPrincipal = {
|
|
551
|
+
principal_type: trailmixModels.Principal.Account,
|
|
552
|
+
entity: accountEntity,
|
|
553
|
+
};
|
|
554
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
555
|
+
|
|
556
|
+
const params: Partial<Utils.Creatable<trailmixModels.ApiKey.Entity>> = {
|
|
557
|
+
name: faker.word.noun(),
|
|
558
|
+
api_key: faker.string.alphanumeric(32),
|
|
559
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
563
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
564
|
+
|
|
565
|
+
await expect(
|
|
566
|
+
service.createApiKey(params as Utils.Creatable<trailmixModels.ApiKey.Entity>, accountPrincipal, auditContext)
|
|
567
|
+
).rejects.toThrow(BadRequestException);
|
|
568
|
+
|
|
569
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
570
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('throws BadRequestException and logs security audit when user lacks required role (ensuring unauthorized org key creation attempts are blocked and audited)', async () => {
|
|
574
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
575
|
+
const accountPrincipal: RequestPrincipal = {
|
|
576
|
+
principal_type: trailmixModels.Principal.Account,
|
|
577
|
+
entity: accountEntity,
|
|
578
|
+
};
|
|
579
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
580
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
581
|
+
|
|
582
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
583
|
+
name: faker.word.noun(),
|
|
584
|
+
api_key: faker.string.alphanumeric(32),
|
|
585
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
586
|
+
scope_id: organizationEntity._id,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
590
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
591
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
592
|
+
authorizationService.resolveOrganizationAuthorization.mockResolvedValue({
|
|
593
|
+
hasAccess: false,
|
|
594
|
+
isGlobalAdmin: false,
|
|
595
|
+
globalRoles: [],
|
|
596
|
+
organizationRoles: [],
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
await expect(
|
|
600
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
601
|
+
).rejects.toThrow(BadRequestException);
|
|
602
|
+
|
|
603
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
604
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
605
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
606
|
+
principal_id: accountEntity._id,
|
|
607
|
+
principal_type: trailmixModels.Principal.Account,
|
|
608
|
+
message: `Unauthorized attempt to create organization-scoped API key for organization ${organizationEntity._id}`,
|
|
609
|
+
source: ApiKeyService.name,
|
|
610
|
+
});
|
|
611
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('throws ForbiddenException when user has User role but not Admin/Owner (ensuring insufficient permissions throw ForbiddenException)', async () => {
|
|
615
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
616
|
+
const accountPrincipal: RequestPrincipal = {
|
|
617
|
+
principal_type: trailmixModels.Principal.Account,
|
|
618
|
+
entity: accountEntity,
|
|
619
|
+
};
|
|
620
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
621
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
622
|
+
|
|
623
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
624
|
+
name: faker.word.noun(),
|
|
625
|
+
api_key: faker.string.alphanumeric(32),
|
|
626
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
627
|
+
scope_id: organizationEntity._id,
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
631
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
632
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
633
|
+
const userRole = TestUtils.Models.createOrganizationRoleModel({
|
|
634
|
+
principal_id: accountEntity._id,
|
|
635
|
+
principal_type: trailmixModels.Principal.Account,
|
|
636
|
+
organization_id: organizationEntity._id,
|
|
637
|
+
role: trailmixModels.RoleValue.User,
|
|
638
|
+
});
|
|
639
|
+
authorizationService.resolveOrganizationAuthorization.mockResolvedValue({
|
|
640
|
+
hasAccess: false,
|
|
641
|
+
isGlobalAdmin: false,
|
|
642
|
+
globalRoles: [],
|
|
643
|
+
organizationRoles: [userRole],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await expect(
|
|
647
|
+
service.createApiKey(params, accountPrincipal, auditContext)
|
|
648
|
+
).rejects.toThrow(ForbiddenException);
|
|
649
|
+
|
|
650
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
651
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
652
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
653
|
+
principal_id: accountEntity._id,
|
|
654
|
+
principal_type: trailmixModels.Principal.Account,
|
|
655
|
+
message: `Unauthorized attempt to create organization-scoped API key for organization ${organizationEntity._id}`,
|
|
656
|
+
source: ApiKeyService.name,
|
|
657
|
+
});
|
|
658
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('throws InternalServerErrorException when organizations feature is enabled but organizationCollection is not available (unexpected situation)', async () => {
|
|
662
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
663
|
+
const accountPrincipal: RequestPrincipal = {
|
|
664
|
+
principal_type: trailmixModels.Principal.Account,
|
|
665
|
+
entity: accountEntity,
|
|
666
|
+
};
|
|
667
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
668
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
669
|
+
|
|
670
|
+
const params: Utils.Creatable<trailmixModels.ApiKey.Entity> = {
|
|
671
|
+
name: faker.word.noun(),
|
|
672
|
+
api_key: faker.string.alphanumeric(32),
|
|
673
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
674
|
+
scope_id: organizationEntity._id,
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Create a new test module without OrganizationCollection provider
|
|
678
|
+
const testModuleWithoutOrgCollection: TestingModule = await Test.createTestingModule({
|
|
679
|
+
providers: [
|
|
680
|
+
ApiKeyService,
|
|
681
|
+
{
|
|
682
|
+
provide: ApiKeyCollection,
|
|
683
|
+
useValue: apiKeyCollection,
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
provide: AuthorizationService,
|
|
687
|
+
useValue: authorizationService,
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
provide: SecurityAuditCollection,
|
|
691
|
+
useValue: securityAuditCollection,
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
provide: FeatureService,
|
|
695
|
+
useValue: featureService,
|
|
696
|
+
},
|
|
697
|
+
// OrganizationCollection is not provided - simulating undefined
|
|
698
|
+
],
|
|
699
|
+
}).compile();
|
|
700
|
+
|
|
701
|
+
const serviceWithoutOrgCollection = testModuleWithoutOrgCollection.get<ApiKeyService>(ApiKeyService);
|
|
702
|
+
|
|
703
|
+
featureService.getApiKeyScopes.mockReturnValue([trailmixModels.ApiKeyScope.Organization]);
|
|
704
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
705
|
+
|
|
706
|
+
await expect(
|
|
707
|
+
serviceWithoutOrgCollection.createApiKey(params, accountPrincipal, auditContext)
|
|
708
|
+
).rejects.toThrow(InternalServerErrorException);
|
|
709
|
+
|
|
710
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
711
|
+
expect(Logger.prototype.error).toHaveBeenCalledWith('Organization collections and services are not available despite organizations feature being enabled');
|
|
712
|
+
expect(apiKeyCollection.create).not.toHaveBeenCalled();
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('throws InternalServerErrorException for invalid scope type (unexpected edge case)', async () => {
|
|
717
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
718
|
+
const accountPrincipal: RequestPrincipal = {
|
|
719
|
+
principal_type: trailmixModels.Principal.Account,
|
|
720
|
+
entity: accountEntity,
|
|
721
|
+
};
|
|
722
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
723
|
+
|
|
724
|
+
const invalidScopeType = 'invalid' as any;
|
|
725
|
+
|
|
726
|
+
const params = {
|
|
727
|
+
name: faker.word.noun(),
|
|
728
|
+
api_key: faker.string.alphanumeric(32),
|
|
729
|
+
scope_type: invalidScopeType,
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
featureService.getApiKeyScopes.mockReturnValue([invalidScopeType]);
|
|
734
|
+
|
|
735
|
+
await expect(
|
|
736
|
+
service.createApiKey(params as Utils.Creatable<trailmixModels.ApiKey.Entity>, accountPrincipal, auditContext)
|
|
737
|
+
).rejects.toThrow(InternalServerErrorException);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe('getApiKeys', () => {
|
|
742
|
+
it('returns empty results for ApiKey principal with Account scope (ensuring ApiKey principals can query but get filtered results)', async () => {
|
|
743
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
744
|
+
const apiKeyPrincipal: RequestPrincipal = {
|
|
745
|
+
principal_type: trailmixModels.Principal.ApiKey,
|
|
746
|
+
entity: apiKeyEntity,
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const queryParams: GetApiKeysParams = {
|
|
750
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
754
|
+
apiKeyCollection.find.mockResolvedValue([]);
|
|
755
|
+
|
|
756
|
+
const result = await service.getApiKeys(apiKeyPrincipal, queryParams);
|
|
757
|
+
|
|
758
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
759
|
+
apiKeyEntity._id,
|
|
760
|
+
trailmixModels.Principal.ApiKey
|
|
761
|
+
);
|
|
762
|
+
expect(apiKeyCollection.find).toHaveBeenCalledWith({
|
|
763
|
+
"scope.type": trailmixModels.ApiKeyScope.Account,
|
|
764
|
+
"scope.id": apiKeyEntity._id,
|
|
765
|
+
});
|
|
766
|
+
expect(result).toEqual({ items: [], count: 0 });
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe('Global admin', () => {
|
|
770
|
+
it('returns all API keys for global admin without filters (ensuring global admins can see all keys)', async () => {
|
|
771
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
772
|
+
const accountPrincipal: RequestPrincipal = {
|
|
773
|
+
principal_type: trailmixModels.Principal.Account,
|
|
774
|
+
entity: accountEntity,
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const queryParams: GetApiKeysParams = {};
|
|
778
|
+
const mockApiKeys = [TestUtils.Entities.createApiKey()];
|
|
779
|
+
|
|
780
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
781
|
+
apiKeyCollection.find.mockResolvedValue(mockApiKeys);
|
|
782
|
+
|
|
783
|
+
const result = await service.getApiKeys(accountPrincipal, queryParams);
|
|
784
|
+
|
|
785
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
786
|
+
accountEntity._id,
|
|
787
|
+
trailmixModels.Principal.Account
|
|
788
|
+
);
|
|
789
|
+
expect(apiKeyCollection.find).toHaveBeenCalledWith({});
|
|
790
|
+
expect(result).toEqual({
|
|
791
|
+
items: mockApiKeys,
|
|
792
|
+
count: 1,
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('returns filtered API keys for global admin with name filter (ensuring global admins can filter by name)', async () => {
|
|
797
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
798
|
+
const accountPrincipal: RequestPrincipal = {
|
|
799
|
+
principal_type: trailmixModels.Principal.Account,
|
|
800
|
+
entity: accountEntity,
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const searchName = faker.word.noun();
|
|
804
|
+
const queryParams: GetApiKeysParams = {
|
|
805
|
+
name: searchName,
|
|
806
|
+
};
|
|
807
|
+
const mockApiKeys = [TestUtils.Entities.createApiKey({
|
|
808
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
809
|
+
scope_id: accountEntity._id,
|
|
810
|
+
name: searchName,
|
|
811
|
+
})];
|
|
812
|
+
|
|
813
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
814
|
+
apiKeyCollection.find.mockResolvedValue(mockApiKeys);
|
|
815
|
+
|
|
816
|
+
const result = await service.getApiKeys(accountPrincipal, queryParams);
|
|
817
|
+
|
|
818
|
+
expect(apiKeyCollection.find).toHaveBeenCalledWith({
|
|
819
|
+
name: searchName,
|
|
820
|
+
});
|
|
821
|
+
expect(result).toEqual({
|
|
822
|
+
items: mockApiKeys,
|
|
823
|
+
count: 1,
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('returns filtered API keys for global admin with multiple filters (ensuring global admins can combine multiple filters)', async () => {
|
|
828
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
829
|
+
const accountPrincipal: RequestPrincipal = {
|
|
830
|
+
principal_type: trailmixModels.Principal.Account,
|
|
831
|
+
entity: accountEntity,
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const searchName = faker.word.noun();
|
|
835
|
+
const queryParams: GetApiKeysParams = {
|
|
836
|
+
name: searchName,
|
|
837
|
+
disabled: false,
|
|
838
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
839
|
+
scope_id: accountEntity._id,
|
|
840
|
+
};
|
|
841
|
+
const mockApiKeys = [TestUtils.Entities.createApiKey({
|
|
842
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
843
|
+
scope_id: accountEntity._id,
|
|
844
|
+
name: searchName,
|
|
845
|
+
})];
|
|
846
|
+
|
|
847
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(true);
|
|
848
|
+
apiKeyCollection.find.mockResolvedValue(mockApiKeys);
|
|
849
|
+
|
|
850
|
+
const result = await service.getApiKeys(accountPrincipal, queryParams);
|
|
851
|
+
|
|
852
|
+
expect(apiKeyCollection.find).toHaveBeenCalledWith({
|
|
853
|
+
name: searchName,
|
|
854
|
+
'scope.type': trailmixModels.ApiKeyScope.Account,
|
|
855
|
+
'scope.id': accountEntity._id,
|
|
856
|
+
});
|
|
857
|
+
expect(result).toEqual({
|
|
858
|
+
items: mockApiKeys,
|
|
859
|
+
count: 1,
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
describe('Non-global admin', () => {
|
|
865
|
+
beforeEach(() => {
|
|
866
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('throws BadRequestException when scope_type is missing (ensuring non-global admins must specify scope type)', async () => {
|
|
870
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
871
|
+
const accountPrincipal: RequestPrincipal = {
|
|
872
|
+
principal_type: trailmixModels.Principal.Account,
|
|
873
|
+
entity: accountEntity,
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const queryParams: GetApiKeysParams = {};
|
|
877
|
+
|
|
878
|
+
await expect(
|
|
879
|
+
service.getApiKeys(accountPrincipal, queryParams)
|
|
880
|
+
).rejects.toThrow(BadRequestException);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('throws BadRequestException and logs security audit when trying to get global-scoped keys (ensuring non-global admins cannot access global-scoped keys)', async () => {
|
|
884
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
885
|
+
const accountPrincipal: RequestPrincipal = {
|
|
886
|
+
principal_type: trailmixModels.Principal.Account,
|
|
887
|
+
entity: accountEntity,
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const queryParams: GetApiKeysParams = {
|
|
891
|
+
scope_type: trailmixModels.ApiKeyScope.Global,
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
await expect(
|
|
895
|
+
service.getApiKeys(accountPrincipal, queryParams)
|
|
896
|
+
).rejects.toThrow(BadRequestException);
|
|
897
|
+
|
|
898
|
+
expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
|
|
899
|
+
event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
|
|
900
|
+
principal_id: accountEntity._id,
|
|
901
|
+
principal_type: trailmixModels.Principal.Account,
|
|
902
|
+
message: 'Unauthorized attempt to get global-scoped API keys for non-global admins',
|
|
903
|
+
source: ApiKeyService.name,
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('returns account-scoped API keys for the principal (ensuring users can only see their own account-scoped keys)', async () => {
|
|
908
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
909
|
+
const accountPrincipal: RequestPrincipal = {
|
|
910
|
+
principal_type: trailmixModels.Principal.Account,
|
|
911
|
+
entity: accountEntity,
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const searchName = faker.word.noun();
|
|
915
|
+
const queryParams: GetApiKeysParams = {
|
|
916
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
917
|
+
name: searchName,
|
|
918
|
+
};
|
|
919
|
+
const mockApiKeys = [TestUtils.Entities.createApiKey({
|
|
920
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
921
|
+
scope_id: accountEntity._id,
|
|
922
|
+
name: searchName,
|
|
923
|
+
})];
|
|
924
|
+
|
|
925
|
+
apiKeyCollection.find.mockResolvedValue(mockApiKeys);
|
|
926
|
+
|
|
927
|
+
const result = await service.getApiKeys(accountPrincipal, queryParams);
|
|
928
|
+
|
|
929
|
+
expect(apiKeyCollection.find).toHaveBeenCalledWith({
|
|
930
|
+
name: searchName,
|
|
931
|
+
'scope.type': trailmixModels.ApiKeyScope.Account,
|
|
932
|
+
'scope.id': accountEntity._id,
|
|
933
|
+
});
|
|
934
|
+
expect(result).toEqual({
|
|
935
|
+
items: mockApiKeys,
|
|
936
|
+
count: 1,
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('returns organization-scoped API keys when scope_id is provided and user has admin/owner role (ensuring only org admins/owners can query org-scoped keys)', async () => {
|
|
941
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
942
|
+
const accountPrincipal: RequestPrincipal = {
|
|
943
|
+
principal_type: trailmixModels.Principal.Account,
|
|
944
|
+
entity: accountEntity,
|
|
945
|
+
};
|
|
946
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
947
|
+
|
|
948
|
+
const queryParams: GetApiKeysParams = {
|
|
949
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
950
|
+
scope_id: organizationEntity._id,
|
|
951
|
+
disabled: true,
|
|
952
|
+
};
|
|
953
|
+
const mockApiKeys = [TestUtils.Entities.createApiKey({
|
|
954
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
955
|
+
scope_id: organizationEntity._id,
|
|
956
|
+
})];
|
|
957
|
+
const adminRole = TestUtils.Models.createOrganizationRoleModel({
|
|
958
|
+
principal_id: accountEntity._id,
|
|
959
|
+
principal_type: trailmixModels.Principal.Account,
|
|
960
|
+
organization_id: organizationEntity._id,
|
|
961
|
+
role: trailmixModels.RoleValue.Admin,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
965
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
966
|
+
authorizationService.resolveOrganizationAuthorization.mockResolvedValue({
|
|
967
|
+
hasAccess: true,
|
|
968
|
+
isGlobalAdmin: false,
|
|
969
|
+
globalRoles: [],
|
|
970
|
+
organizationRoles: [adminRole],
|
|
971
|
+
});
|
|
972
|
+
apiKeyCollection.find.mockResolvedValue(mockApiKeys);
|
|
973
|
+
|
|
974
|
+
const result = await service.getApiKeys(accountPrincipal, queryParams);
|
|
975
|
+
|
|
976
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
977
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
978
|
+
accountEntity._id,
|
|
979
|
+
trailmixModels.Principal.Account
|
|
980
|
+
);
|
|
981
|
+
expect(authorizationService.resolveOrganizationAuthorization).toHaveBeenCalledWith({
|
|
982
|
+
principal: accountPrincipal,
|
|
983
|
+
rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
|
|
984
|
+
principalTypeAllowList: [trailmixModels.Principal.Account],
|
|
985
|
+
organizationId: organizationEntity._id,
|
|
986
|
+
});
|
|
987
|
+
expect(apiKeyCollection.find).toHaveBeenCalledWith({
|
|
988
|
+
disabled: true,
|
|
989
|
+
'scope.type': trailmixModels.ApiKeyScope.Organization,
|
|
990
|
+
'scope.id': organizationEntity._id,
|
|
991
|
+
});
|
|
992
|
+
expect(result).toEqual({
|
|
993
|
+
items: mockApiKeys,
|
|
994
|
+
count: 1,
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('throws BadRequestException when organizations feature is not enabled for organization scope (ensuring org-scoped queries require organizations feature)', async () => {
|
|
999
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1000
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1001
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1002
|
+
entity: accountEntity,
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const queryParams: GetApiKeysParams = {
|
|
1006
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
featureService.isOrganizationsEnabled.mockReturnValue(false);
|
|
1010
|
+
|
|
1011
|
+
await expect(
|
|
1012
|
+
service.getApiKeys(accountPrincipal, queryParams)
|
|
1013
|
+
).rejects.toThrow(BadRequestException);
|
|
1014
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it('throws ForbiddenException when user does not have admin/owner role for organization scope (ensuring only org admins/owners can query org-scoped keys)', async () => {
|
|
1018
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1019
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1020
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1021
|
+
entity: accountEntity,
|
|
1022
|
+
};
|
|
1023
|
+
const organizationEntity = TestUtils.Entities.createOrganization();
|
|
1024
|
+
|
|
1025
|
+
const queryParams: GetApiKeysParams = {
|
|
1026
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
1027
|
+
scope_id: organizationEntity._id,
|
|
1028
|
+
};
|
|
1029
|
+
const userRole = TestUtils.Models.createOrganizationRoleModel({
|
|
1030
|
+
principal_id: accountEntity._id,
|
|
1031
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1032
|
+
organization_id: organizationEntity._id,
|
|
1033
|
+
role: trailmixModels.RoleValue.User,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
1037
|
+
authorizationService.isGlobalAdmin.mockResolvedValue(false);
|
|
1038
|
+
authorizationService.resolveOrganizationAuthorization.mockResolvedValue({
|
|
1039
|
+
hasAccess: false,
|
|
1040
|
+
isGlobalAdmin: false,
|
|
1041
|
+
globalRoles: [],
|
|
1042
|
+
organizationRoles: [userRole],
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
await expect(
|
|
1046
|
+
service.getApiKeys(accountPrincipal, queryParams)
|
|
1047
|
+
).rejects.toThrow(ForbiddenException);
|
|
1048
|
+
|
|
1049
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
1050
|
+
expect(authorizationService.isGlobalAdmin).toHaveBeenCalledWith(
|
|
1051
|
+
accountEntity._id,
|
|
1052
|
+
trailmixModels.Principal.Account
|
|
1053
|
+
);
|
|
1054
|
+
expect(authorizationService.resolveOrganizationAuthorization).toHaveBeenCalledWith({
|
|
1055
|
+
principal: accountPrincipal,
|
|
1056
|
+
rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
|
|
1057
|
+
principalTypeAllowList: [trailmixModels.Principal.Account],
|
|
1058
|
+
organizationId: organizationEntity._id,
|
|
1059
|
+
});
|
|
1060
|
+
expect(apiKeyCollection.find).not.toHaveBeenCalled();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('throws BadRequestException when scope_id is missing for organization scope (ensuring org-scoped queries require scope_id)', async () => {
|
|
1064
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1065
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1066
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1067
|
+
entity: accountEntity,
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
const queryParams: GetApiKeysParams = {
|
|
1071
|
+
scope_type: trailmixModels.ApiKeyScope.Organization,
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
featureService.isOrganizationsEnabled.mockReturnValue(true);
|
|
1075
|
+
|
|
1076
|
+
await expect(
|
|
1077
|
+
service.getApiKeys(accountPrincipal, queryParams)
|
|
1078
|
+
).rejects.toThrow(BadRequestException);
|
|
1079
|
+
expect(featureService.isOrganizationsEnabled).toHaveBeenCalled();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it('throws InternalServerErrorException for invalid scope type (unexpected edge case)', async () => {
|
|
1083
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1084
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1085
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1086
|
+
entity: accountEntity,
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
const queryParams = {
|
|
1090
|
+
scope_type: 'invalid' as any,
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
await expect(
|
|
1094
|
+
service.getApiKeys(accountPrincipal, queryParams)
|
|
1095
|
+
).rejects.toThrow(InternalServerErrorException);
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
describe('getApiKey', () => {
|
|
1101
|
+
it('returns API key when principal has access (ensuring authorized principals can retrieve API keys)', async () => {
|
|
1102
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1103
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1104
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1105
|
+
entity: accountEntity,
|
|
1106
|
+
};
|
|
1107
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
1108
|
+
|
|
1109
|
+
authorizationService.authorizeApiKeyAccessForPrincipal.mockResolvedValue(true);
|
|
1110
|
+
|
|
1111
|
+
const result = await service.getApiKey(apiKeyEntity, accountPrincipal);
|
|
1112
|
+
|
|
1113
|
+
expect(authorizationService.authorizeApiKeyAccessForPrincipal).toHaveBeenCalledWith(
|
|
1114
|
+
accountPrincipal,
|
|
1115
|
+
apiKeyEntity.scope_type,
|
|
1116
|
+
apiKeyEntity.scope_id
|
|
1117
|
+
);
|
|
1118
|
+
expect(result).toEqual(apiKeyEntity);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('throws NotFoundException when ApiKey principal does not have access (ensuring unauthorized access attempts return not found)', async () => {
|
|
1122
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
1123
|
+
const apiKeyPrincipal: RequestPrincipal = {
|
|
1124
|
+
principal_type: trailmixModels.Principal.ApiKey,
|
|
1125
|
+
entity: apiKeyEntity,
|
|
1126
|
+
};
|
|
1127
|
+
const testApiKey = TestUtils.Entities.createApiKey({
|
|
1128
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
1129
|
+
scope_id: new ObjectId(), // Different ID, so access will be denied
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
authorizationService.authorizeApiKeyAccessForPrincipal.mockResolvedValue(false);
|
|
1133
|
+
|
|
1134
|
+
await expect(
|
|
1135
|
+
service.getApiKey(testApiKey, apiKeyPrincipal)
|
|
1136
|
+
).rejects.toThrow(NotFoundException);
|
|
1137
|
+
await expect(
|
|
1138
|
+
service.getApiKey(testApiKey, apiKeyPrincipal)
|
|
1139
|
+
).rejects.toThrow('API key not found');
|
|
1140
|
+
expect(authorizationService.authorizeApiKeyAccessForPrincipal).toHaveBeenCalledWith(
|
|
1141
|
+
apiKeyPrincipal,
|
|
1142
|
+
testApiKey.scope_type,
|
|
1143
|
+
testApiKey.scope_id
|
|
1144
|
+
);
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it('throws NotFoundException when Account principal does not have access (ensuring unauthorized access attempts return not found)', async () => {
|
|
1148
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1149
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1150
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1151
|
+
entity: accountEntity,
|
|
1152
|
+
};
|
|
1153
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
1154
|
+
|
|
1155
|
+
authorizationService.authorizeApiKeyAccessForPrincipal.mockResolvedValue(false);
|
|
1156
|
+
|
|
1157
|
+
await expect(
|
|
1158
|
+
service.getApiKey(apiKeyEntity, accountPrincipal)
|
|
1159
|
+
).rejects.toThrow(NotFoundException);
|
|
1160
|
+
|
|
1161
|
+
expect(authorizationService.authorizeApiKeyAccessForPrincipal).toHaveBeenCalledWith(
|
|
1162
|
+
accountPrincipal,
|
|
1163
|
+
apiKeyEntity.scope_type,
|
|
1164
|
+
apiKeyEntity.scope_id
|
|
1165
|
+
);
|
|
1166
|
+
});
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
describe('deleteApiKey', () => {
|
|
1170
|
+
it('deletes API key when principal has access (ensuring authorized principals can delete API keys)', async () => {
|
|
1171
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1172
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1173
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1174
|
+
entity: accountEntity,
|
|
1175
|
+
};
|
|
1176
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
1177
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
1178
|
+
|
|
1179
|
+
const deleteResult = { acknowledged: true, deletedCount: 1 };
|
|
1180
|
+
authorizationService.authorizeApiKeyAccessForPrincipal.mockResolvedValue(true);
|
|
1181
|
+
apiKeyCollection.deleteOne.mockResolvedValue(deleteResult as any);
|
|
1182
|
+
|
|
1183
|
+
await service.deleteApiKey(apiKeyEntity, accountPrincipal, auditContext);
|
|
1184
|
+
|
|
1185
|
+
expect(authorizationService.authorizeApiKeyAccessForPrincipal).toHaveBeenCalledWith(
|
|
1186
|
+
accountPrincipal,
|
|
1187
|
+
apiKeyEntity.scope_type,
|
|
1188
|
+
apiKeyEntity.scope_id
|
|
1189
|
+
);
|
|
1190
|
+
expect(apiKeyCollection.deleteOne).toHaveBeenCalledWith(apiKeyEntity._id, auditContext);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it('throws NotFoundException when ApiKey principal does not have access (ensuring unauthorized access attempts return not found)', async () => {
|
|
1194
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
1195
|
+
const apiKeyPrincipal: RequestPrincipal = {
|
|
1196
|
+
principal_type: trailmixModels.Principal.ApiKey,
|
|
1197
|
+
entity: apiKeyEntity,
|
|
1198
|
+
};
|
|
1199
|
+
const auditContext = createAuditContextForPrincipal(apiKeyPrincipal);
|
|
1200
|
+
const testApiKey = TestUtils.Entities.createApiKey({
|
|
1201
|
+
scope_type: trailmixModels.ApiKeyScope.Account,
|
|
1202
|
+
scope_id: new ObjectId(), // Different ID, so access will be denied
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
authorizationService.authorizeApiKeyAccessForPrincipal.mockResolvedValue(false);
|
|
1206
|
+
|
|
1207
|
+
await expect(
|
|
1208
|
+
service.deleteApiKey(testApiKey, apiKeyPrincipal, auditContext)
|
|
1209
|
+
).rejects.toThrow(NotFoundException);
|
|
1210
|
+
await expect(
|
|
1211
|
+
service.deleteApiKey(testApiKey, apiKeyPrincipal, auditContext)
|
|
1212
|
+
).rejects.toThrow('API key not found');
|
|
1213
|
+
expect(authorizationService.authorizeApiKeyAccessForPrincipal).toHaveBeenCalledWith(
|
|
1214
|
+
apiKeyPrincipal,
|
|
1215
|
+
testApiKey.scope_type,
|
|
1216
|
+
testApiKey.scope_id
|
|
1217
|
+
);
|
|
1218
|
+
expect(apiKeyCollection.deleteOne).not.toHaveBeenCalled();
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it('throws NotFoundException when Account principal does not have access (ensuring unauthorized deletion attempts return not found)', async () => {
|
|
1222
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
1223
|
+
const accountPrincipal: RequestPrincipal = {
|
|
1224
|
+
principal_type: trailmixModels.Principal.Account,
|
|
1225
|
+
entity: accountEntity,
|
|
1226
|
+
};
|
|
1227
|
+
const auditContext = createAuditContextForPrincipal(accountPrincipal);
|
|
1228
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
1229
|
+
|
|
1230
|
+
authorizationService.authorizeApiKeyAccessForPrincipal.mockResolvedValue(false);
|
|
1231
|
+
|
|
1232
|
+
await expect(
|
|
1233
|
+
service.deleteApiKey(apiKeyEntity, accountPrincipal, auditContext)
|
|
1234
|
+
).rejects.toThrow(NotFoundException);
|
|
1235
|
+
|
|
1236
|
+
expect(authorizationService.authorizeApiKeyAccessForPrincipal).toHaveBeenCalledWith(
|
|
1237
|
+
accountPrincipal,
|
|
1238
|
+
apiKeyEntity.scope_type,
|
|
1239
|
+
apiKeyEntity.scope_id
|
|
1240
|
+
);
|
|
1241
|
+
expect(apiKeyCollection.deleteOne).not.toHaveBeenCalled();
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
});
|