@friggframework/core 2.0.0-next.41 → 2.0.0-next.42
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/CLAUDE.md +693 -0
- package/README.md +931 -50
- package/application/commands/README.md +421 -0
- package/application/commands/credential-commands.js +224 -0
- package/application/commands/entity-commands.js +315 -0
- package/application/commands/integration-commands.js +160 -0
- package/application/commands/integration-commands.test.js +123 -0
- package/application/commands/user-commands.js +213 -0
- package/application/index.js +69 -0
- package/core/CLAUDE.md +690 -0
- package/core/create-handler.js +0 -6
- package/credential/repositories/credential-repository-factory.js +47 -0
- package/credential/repositories/credential-repository-interface.js +98 -0
- package/credential/repositories/credential-repository-mongo.js +301 -0
- package/credential/repositories/credential-repository-postgres.js +307 -0
- package/credential/repositories/credential-repository.js +307 -0
- package/credential/use-cases/get-credential-for-user.js +21 -0
- package/credential/use-cases/update-authentication-status.js +15 -0
- package/database/config.js +117 -0
- package/database/encryption/README.md +683 -0
- package/database/encryption/encryption-integration.test.js +553 -0
- package/database/encryption/encryption-schema-registry.js +141 -0
- package/database/encryption/encryption-schema-registry.test.js +392 -0
- package/database/encryption/field-encryption-service.js +226 -0
- package/database/encryption/field-encryption-service.test.js +525 -0
- package/database/encryption/logger.js +79 -0
- package/database/encryption/mongo-decryption-fix-verification.test.js +348 -0
- package/database/encryption/postgres-decryption-fix-verification.test.js +371 -0
- package/database/encryption/postgres-relation-decryption.test.js +245 -0
- package/database/encryption/prisma-encryption-extension.js +222 -0
- package/database/encryption/prisma-encryption-extension.test.js +439 -0
- package/database/index.js +25 -12
- package/database/models/readme.md +1 -0
- package/database/prisma.js +162 -0
- package/database/repositories/health-check-repository-factory.js +38 -0
- package/database/repositories/health-check-repository-interface.js +86 -0
- package/database/repositories/health-check-repository-mongodb.js +72 -0
- package/database/repositories/health-check-repository-postgres.js +75 -0
- package/database/repositories/health-check-repository.js +108 -0
- package/database/use-cases/check-database-health-use-case.js +34 -0
- package/database/use-cases/check-encryption-health-use-case.js +82 -0
- package/database/use-cases/test-encryption-use-case.js +252 -0
- package/encrypt/Cryptor.js +20 -152
- package/encrypt/index.js +1 -2
- package/encrypt/test-encrypt.js +0 -2
- package/handlers/app-definition-loader.js +38 -0
- package/handlers/app-handler-helpers.js +0 -3
- package/handlers/auth-flow.integration.test.js +147 -0
- package/handlers/backend-utils.js +25 -45
- package/handlers/integration-event-dispatcher.js +54 -0
- package/handlers/integration-event-dispatcher.test.js +141 -0
- package/handlers/routers/HEALTHCHECK.md +103 -1
- package/handlers/routers/auth.js +3 -14
- package/handlers/routers/health.js +63 -424
- package/handlers/routers/health.test.js +7 -0
- package/handlers/routers/integration-defined-routers.js +8 -5
- package/handlers/routers/user.js +25 -5
- package/handlers/routers/websocket.js +5 -3
- package/handlers/use-cases/check-external-apis-health-use-case.js +81 -0
- package/handlers/use-cases/check-integrations-health-use-case.js +32 -0
- package/handlers/workers/integration-defined-workers.js +6 -3
- package/index.js +45 -22
- package/integrations/index.js +12 -10
- package/integrations/integration-base.js +224 -53
- package/integrations/integration-router.js +386 -178
- package/integrations/options.js +1 -1
- package/integrations/repositories/integration-mapping-repository-factory.js +50 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +106 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +161 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +227 -0
- package/integrations/repositories/integration-mapping-repository.js +156 -0
- package/integrations/repositories/integration-repository-factory.js +44 -0
- package/integrations/repositories/integration-repository-interface.js +115 -0
- package/integrations/repositories/integration-repository-mongo.js +271 -0
- package/integrations/repositories/integration-repository-postgres.js +319 -0
- package/integrations/tests/doubles/dummy-integration-class.js +90 -0
- package/integrations/tests/doubles/test-integration-repository.js +99 -0
- package/integrations/tests/use-cases/create-integration.test.js +131 -0
- package/integrations/tests/use-cases/delete-integration-for-user.test.js +150 -0
- package/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js +92 -0
- package/integrations/tests/use-cases/get-integration-for-user.test.js +150 -0
- package/integrations/tests/use-cases/get-integration-instance.test.js +176 -0
- package/integrations/tests/use-cases/get-integrations-for-user.test.js +176 -0
- package/integrations/tests/use-cases/get-possible-integrations.test.js +188 -0
- package/integrations/tests/use-cases/update-integration-messages.test.js +142 -0
- package/integrations/tests/use-cases/update-integration-status.test.js +103 -0
- package/integrations/tests/use-cases/update-integration.test.js +141 -0
- package/integrations/use-cases/create-integration.js +83 -0
- package/integrations/use-cases/delete-integration-for-user.js +73 -0
- package/integrations/use-cases/find-integration-context-by-external-entity-id.js +72 -0
- package/integrations/use-cases/get-integration-for-user.js +78 -0
- package/integrations/use-cases/get-integration-instance-by-definition.js +67 -0
- package/integrations/use-cases/get-integration-instance.js +83 -0
- package/integrations/use-cases/get-integrations-for-user.js +87 -0
- package/integrations/use-cases/get-possible-integrations.js +27 -0
- package/integrations/use-cases/index.js +11 -0
- package/integrations/use-cases/load-integration-context-full.test.js +329 -0
- package/integrations/use-cases/load-integration-context.js +71 -0
- package/integrations/use-cases/load-integration-context.test.js +114 -0
- package/integrations/use-cases/update-integration-messages.js +44 -0
- package/integrations/use-cases/update-integration-status.js +32 -0
- package/integrations/use-cases/update-integration.js +93 -0
- package/integrations/utils/map-integration-dto.js +36 -0
- package/jest-global-setup-noop.js +3 -0
- package/jest-global-teardown-noop.js +3 -0
- package/{module-plugin → modules}/entity.js +1 -0
- package/{module-plugin → modules}/index.js +0 -8
- package/modules/module-factory.js +56 -0
- package/modules/module-hydration.test.js +205 -0
- package/modules/module.js +221 -0
- package/modules/repositories/module-repository-factory.js +33 -0
- package/modules/repositories/module-repository-interface.js +129 -0
- package/modules/repositories/module-repository-mongo.js +386 -0
- package/modules/repositories/module-repository-postgres.js +437 -0
- package/modules/repositories/module-repository.js +327 -0
- package/{module-plugin → modules}/test/mock-api/api.js +8 -3
- package/{module-plugin → modules}/test/mock-api/definition.js +12 -8
- package/modules/tests/doubles/test-module-factory.js +16 -0
- package/modules/tests/doubles/test-module-repository.js +39 -0
- package/modules/use-cases/get-entities-for-user.js +32 -0
- package/modules/use-cases/get-entity-options-by-id.js +59 -0
- package/modules/use-cases/get-entity-options-by-type.js +34 -0
- package/modules/use-cases/get-module-instance-from-type.js +31 -0
- package/modules/use-cases/get-module.js +56 -0
- package/modules/use-cases/process-authorization-callback.js +121 -0
- package/modules/use-cases/refresh-entity-options.js +59 -0
- package/modules/use-cases/test-module-auth.js +55 -0
- package/modules/utils/map-module-dto.js +18 -0
- package/package.json +14 -6
- package/prisma-mongodb/schema.prisma +321 -0
- package/prisma-postgresql/migrations/20250930193005_init/migration.sql +315 -0
- package/prisma-postgresql/migrations/20251006135218_init/migration.sql +9 -0
- package/prisma-postgresql/migrations/migration_lock.toml +3 -0
- package/prisma-postgresql/schema.prisma +303 -0
- package/syncs/manager.js +468 -443
- package/syncs/repositories/sync-repository-factory.js +38 -0
- package/syncs/repositories/sync-repository-interface.js +109 -0
- package/syncs/repositories/sync-repository-mongo.js +239 -0
- package/syncs/repositories/sync-repository-postgres.js +319 -0
- package/syncs/sync.js +0 -1
- package/token/repositories/token-repository-factory.js +33 -0
- package/token/repositories/token-repository-interface.js +131 -0
- package/token/repositories/token-repository-mongo.js +212 -0
- package/token/repositories/token-repository-postgres.js +257 -0
- package/token/repositories/token-repository.js +219 -0
- package/types/integrations/index.d.ts +2 -6
- package/types/module-plugin/index.d.ts +5 -57
- package/types/syncs/index.d.ts +0 -2
- package/user/repositories/user-repository-factory.js +46 -0
- package/user/repositories/user-repository-interface.js +198 -0
- package/user/repositories/user-repository-mongo.js +250 -0
- package/user/repositories/user-repository-postgres.js +311 -0
- package/user/tests/doubles/test-user-repository.js +72 -0
- package/user/tests/use-cases/create-individual-user.test.js +24 -0
- package/user/tests/use-cases/create-organization-user.test.js +28 -0
- package/user/tests/use-cases/create-token-for-user-id.test.js +19 -0
- package/user/tests/use-cases/get-user-from-bearer-token.test.js +64 -0
- package/user/tests/use-cases/login-user.test.js +140 -0
- package/user/use-cases/create-individual-user.js +61 -0
- package/user/use-cases/create-organization-user.js +47 -0
- package/user/use-cases/create-token-for-user-id.js +30 -0
- package/user/use-cases/get-user-from-bearer-token.js +77 -0
- package/user/use-cases/login-user.js +122 -0
- package/user/user.js +77 -0
- package/websocket/repositories/websocket-connection-repository-factory.js +37 -0
- package/websocket/repositories/websocket-connection-repository-interface.js +106 -0
- package/websocket/repositories/websocket-connection-repository-mongo.js +155 -0
- package/websocket/repositories/websocket-connection-repository-postgres.js +195 -0
- package/websocket/repositories/websocket-connection-repository.js +160 -0
- package/database/models/State.js +0 -9
- package/database/models/Token.js +0 -70
- package/database/mongo.js +0 -171
- package/encrypt/Cryptor.test.js +0 -32
- package/encrypt/encrypt.js +0 -104
- package/encrypt/encrypt.test.js +0 -1069
- package/handlers/routers/middleware/loadUser.js +0 -15
- package/handlers/routers/middleware/requireLoggedInUser.js +0 -12
- package/integrations/create-frigg-backend.js +0 -31
- package/integrations/integration-factory.js +0 -251
- package/integrations/integration-mapping.js +0 -43
- package/integrations/integration-model.js +0 -46
- package/integrations/integration-user.js +0 -144
- package/integrations/test/integration-base.test.js +0 -144
- package/module-plugin/auther.js +0 -393
- package/module-plugin/credential.js +0 -22
- package/module-plugin/entity-manager.js +0 -70
- package/module-plugin/manager.js +0 -169
- package/module-plugin/module-factory.js +0 -61
- package/module-plugin/test/auther.test.js +0 -97
- /package/{module-plugin → modules}/ModuleConstants.js +0 -0
- /package/{module-plugin → modules}/requester/api-key.js +0 -0
- /package/{module-plugin → modules}/requester/basic.js +0 -0
- /package/{module-plugin → modules}/requester/oauth-2.js +0 -0
- /package/{module-plugin → modules}/requester/requester.js +0 -0
- /package/{module-plugin → modules}/requester/requester.test.js +0 -0
- /package/{module-plugin → modules}/test/mock-api/mocks/hubspot.js +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
const { FieldEncryptionService } = require('./field-encryption-service');
|
|
2
|
+
|
|
3
|
+
describe('FieldEncryptionService', () => {
|
|
4
|
+
let mockCryptor;
|
|
5
|
+
let mockSchema;
|
|
6
|
+
let service;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Mock Cryptor
|
|
10
|
+
mockCryptor = {
|
|
11
|
+
encrypt: jest
|
|
12
|
+
.fn()
|
|
13
|
+
.mockImplementation(
|
|
14
|
+
(value) => `encrypted:${value}:keydata:enckey`
|
|
15
|
+
),
|
|
16
|
+
decrypt: jest
|
|
17
|
+
.fn()
|
|
18
|
+
.mockImplementation((value) => {
|
|
19
|
+
// Handle multiple encrypted formats
|
|
20
|
+
// Format 1: "encrypted:ORIGINAL:keydata:enckey"
|
|
21
|
+
// Format 2: "keyId:ORIGINAL:iv:enckey"
|
|
22
|
+
|
|
23
|
+
// Try format 1 (from our new tests)
|
|
24
|
+
const prefix1 = 'encrypted:';
|
|
25
|
+
const suffix1 = ':keydata:enckey';
|
|
26
|
+
if (value.startsWith(prefix1) && value.endsWith(suffix1)) {
|
|
27
|
+
return value.slice(prefix1.length, -suffix1.length);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Try format 2 (from existing tests)
|
|
31
|
+
const prefix2 = 'keyId:';
|
|
32
|
+
const suffix2 = ':iv:enckey';
|
|
33
|
+
if (value.startsWith(prefix2) && value.endsWith(suffix2)) {
|
|
34
|
+
return value.slice(prefix2.length, -suffix2.length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return value; // Fallback for non-standard format
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Mock Schema Registry
|
|
42
|
+
mockSchema = {
|
|
43
|
+
getEncryptedFields: jest.fn().mockImplementation((modelName) => {
|
|
44
|
+
const schemas = {
|
|
45
|
+
Credential: ['data.access_token', 'data.refresh_token'],
|
|
46
|
+
User: ['hashword'],
|
|
47
|
+
IntegrationMapping: ['mapping'],
|
|
48
|
+
EmptyModel: [],
|
|
49
|
+
};
|
|
50
|
+
return schemas[modelName] || [];
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
service = new FieldEncryptionService({
|
|
55
|
+
cryptor: mockCryptor,
|
|
56
|
+
schema: mockSchema,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('constructor', () => {
|
|
61
|
+
it('should throw if cryptor not provided', () => {
|
|
62
|
+
expect(() => {
|
|
63
|
+
new FieldEncryptionService({ schema: mockSchema });
|
|
64
|
+
}).toThrow('Cryptor instance required');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should throw if schema not provided', () => {
|
|
68
|
+
expect(() => {
|
|
69
|
+
new FieldEncryptionService({ cryptor: mockCryptor });
|
|
70
|
+
}).toThrow('Schema with getEncryptedFields method required');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should throw if schema missing getEncryptedFields', () => {
|
|
74
|
+
expect(() => {
|
|
75
|
+
new FieldEncryptionService({
|
|
76
|
+
cryptor: mockCryptor,
|
|
77
|
+
schema: {},
|
|
78
|
+
});
|
|
79
|
+
}).toThrow('Schema with getEncryptedFields method required');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should create instance with valid params', () => {
|
|
83
|
+
expect(service).toBeInstanceOf(FieldEncryptionService);
|
|
84
|
+
expect(service.cryptor).toBe(mockCryptor);
|
|
85
|
+
expect(service.schema).toBe(mockSchema);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('encryptFields', () => {
|
|
90
|
+
it('should encrypt nested JSON fields', async () => {
|
|
91
|
+
const document = {
|
|
92
|
+
id: '123',
|
|
93
|
+
data: {
|
|
94
|
+
access_token: 'secret123',
|
|
95
|
+
refresh_token: 'refresh456',
|
|
96
|
+
other: 'public',
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const result = await service.encryptFields('Credential', document);
|
|
101
|
+
|
|
102
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123');
|
|
103
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('refresh456');
|
|
104
|
+
expect(result.data.access_token).toBe(
|
|
105
|
+
'encrypted:secret123:keydata:enckey'
|
|
106
|
+
);
|
|
107
|
+
expect(result.data.refresh_token).toBe(
|
|
108
|
+
'encrypted:refresh456:keydata:enckey'
|
|
109
|
+
);
|
|
110
|
+
expect(result.data.other).toBe('public'); // Not encrypted
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should encrypt top-level fields', async () => {
|
|
114
|
+
const document = {
|
|
115
|
+
id: '123',
|
|
116
|
+
hashword: 'password_hash',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const result = await service.encryptFields('User', document);
|
|
120
|
+
|
|
121
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('password_hash');
|
|
122
|
+
expect(result.hashword).toBe(
|
|
123
|
+
'encrypted:password_hash:keydata:enckey'
|
|
124
|
+
);
|
|
125
|
+
expect(result.id).toBe('123'); // Not encrypted
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle models without encrypted fields', async () => {
|
|
129
|
+
const document = { id: '123', state: 'some_state' };
|
|
130
|
+
|
|
131
|
+
const result = await service.encryptFields('EmptyModel', document);
|
|
132
|
+
|
|
133
|
+
expect(mockCryptor.encrypt).not.toHaveBeenCalled();
|
|
134
|
+
expect(result).toEqual(document);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should skip null values', async () => {
|
|
138
|
+
const document = {
|
|
139
|
+
data: {
|
|
140
|
+
access_token: null,
|
|
141
|
+
refresh_token: 'valid',
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await service.encryptFields('Credential', document);
|
|
146
|
+
|
|
147
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('valid');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should skip undefined values', async () => {
|
|
152
|
+
const document = {
|
|
153
|
+
data: {
|
|
154
|
+
access_token: undefined,
|
|
155
|
+
refresh_token: 'valid',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await service.encryptFields('Credential', document);
|
|
160
|
+
|
|
161
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('valid');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should skip empty strings', async () => {
|
|
166
|
+
const document = {
|
|
167
|
+
data: {
|
|
168
|
+
access_token: '',
|
|
169
|
+
refresh_token: 'valid',
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
await service.encryptFields('Credential', document);
|
|
174
|
+
|
|
175
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('valid');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should skip already encrypted values', async () => {
|
|
180
|
+
const document = {
|
|
181
|
+
data: {
|
|
182
|
+
access_token: 'already:encrypted:data:key',
|
|
183
|
+
refresh_token: 'plain',
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await service.encryptFields('Credential', document);
|
|
188
|
+
|
|
189
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith('plain');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should not mutate original document', async () => {
|
|
194
|
+
const document = {
|
|
195
|
+
data: { access_token: 'secret' },
|
|
196
|
+
};
|
|
197
|
+
const original = JSON.parse(JSON.stringify(document));
|
|
198
|
+
|
|
199
|
+
await service.encryptFields('Credential', document);
|
|
200
|
+
|
|
201
|
+
expect(document).toEqual(original);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should properly encrypt object/JSON values (IntegrationMapping.mapping)', async () => {
|
|
205
|
+
// This test demonstrates the bug: objects are converted to "[object Object]"
|
|
206
|
+
// Expected behavior: object should be JSON.stringify'd before encryption
|
|
207
|
+
const mappingObject = {
|
|
208
|
+
action: 'upload',
|
|
209
|
+
formData: {
|
|
210
|
+
container: 'project_123',
|
|
211
|
+
folderId: '456',
|
|
212
|
+
attachments: ['att-1', 'att-2'],
|
|
213
|
+
},
|
|
214
|
+
taskId: 'task-789',
|
|
215
|
+
status: 'pending',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const document = {
|
|
219
|
+
id: 1,
|
|
220
|
+
integrationId: 1,
|
|
221
|
+
sourceId: 'task-789',
|
|
222
|
+
mapping: mappingObject,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const encrypted = await service.encryptFields('IntegrationMapping', document);
|
|
226
|
+
|
|
227
|
+
// The cryptor should receive JSON string, not "[object Object]"
|
|
228
|
+
expect(mockCryptor.encrypt).toHaveBeenCalledWith(
|
|
229
|
+
JSON.stringify(mappingObject)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// The encrypted value should be the JSON string encrypted
|
|
233
|
+
expect(encrypted.mapping).toBe(
|
|
234
|
+
`encrypted:${JSON.stringify(mappingObject)}:keydata:enckey`
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Now decrypt and verify object is restored
|
|
238
|
+
const decrypted = await service.decryptFields('IntegrationMapping', encrypted);
|
|
239
|
+
|
|
240
|
+
// After decryption, the object should be fully restored
|
|
241
|
+
expect(decrypted.mapping).toEqual(mappingObject);
|
|
242
|
+
expect(decrypted.mapping.action).toBe('upload');
|
|
243
|
+
expect(decrypted.mapping.formData.attachments).toEqual(['att-1', 'att-2']);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should throw on encryption errors', async () => {
|
|
247
|
+
mockCryptor.encrypt.mockRejectedValueOnce(
|
|
248
|
+
new Error('Encryption failed')
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const document = {
|
|
252
|
+
data: {
|
|
253
|
+
access_token: 'secret',
|
|
254
|
+
refresh_token: 'valid',
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
await expect(
|
|
259
|
+
service.encryptFields('Credential', document)
|
|
260
|
+
).rejects.toThrow('Encryption failed');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should return non-object values as-is', async () => {
|
|
264
|
+
expect(await service.encryptFields('Credential', null)).toBeNull();
|
|
265
|
+
expect(
|
|
266
|
+
await service.encryptFields('Credential', undefined)
|
|
267
|
+
).toBeUndefined();
|
|
268
|
+
expect(await service.encryptFields('Credential', 'string')).toBe(
|
|
269
|
+
'string'
|
|
270
|
+
);
|
|
271
|
+
expect(await service.encryptFields('Credential', 123)).toBe(123);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('decryptFields', () => {
|
|
276
|
+
it('should decrypt nested JSON fields', async () => {
|
|
277
|
+
const document = {
|
|
278
|
+
id: '123',
|
|
279
|
+
data: {
|
|
280
|
+
access_token: 'keyId:secret123:iv:enckey',
|
|
281
|
+
refresh_token: 'keyId:refresh456:iv:enckey',
|
|
282
|
+
other: 'public',
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const result = await service.decryptFields('Credential', document);
|
|
287
|
+
|
|
288
|
+
expect(mockCryptor.decrypt).toHaveBeenCalledWith(
|
|
289
|
+
'keyId:secret123:iv:enckey'
|
|
290
|
+
);
|
|
291
|
+
expect(mockCryptor.decrypt).toHaveBeenCalledWith(
|
|
292
|
+
'keyId:refresh456:iv:enckey'
|
|
293
|
+
);
|
|
294
|
+
expect(result.data.access_token).toBe('secret123');
|
|
295
|
+
expect(result.data.refresh_token).toBe('refresh456');
|
|
296
|
+
expect(result.data.other).toBe('public'); // Not decrypted
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should decrypt top-level fields', async () => {
|
|
300
|
+
const document = {
|
|
301
|
+
id: '123',
|
|
302
|
+
hashword: 'keyId:password_hash:iv:enckey',
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const result = await service.decryptFields('User', document);
|
|
306
|
+
|
|
307
|
+
expect(mockCryptor.decrypt).toHaveBeenCalledWith(
|
|
308
|
+
'keyId:password_hash:iv:enckey'
|
|
309
|
+
);
|
|
310
|
+
expect(result.hashword).toBe('password_hash');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should skip non-encrypted values', async () => {
|
|
314
|
+
const document = {
|
|
315
|
+
data: {
|
|
316
|
+
access_token: 'plaintext', // Not encrypted format
|
|
317
|
+
refresh_token: 'keyId:encrypted:iv:enckey',
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
await service.decryptFields('Credential', document);
|
|
322
|
+
|
|
323
|
+
expect(mockCryptor.decrypt).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(mockCryptor.decrypt).toHaveBeenCalledWith(
|
|
325
|
+
'keyId:encrypted:iv:enckey'
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should not mutate original document', async () => {
|
|
330
|
+
const document = {
|
|
331
|
+
data: { access_token: 'keyId:secret:iv:enckey' },
|
|
332
|
+
};
|
|
333
|
+
const original = JSON.parse(JSON.stringify(document));
|
|
334
|
+
|
|
335
|
+
await service.decryptFields('Credential', document);
|
|
336
|
+
|
|
337
|
+
expect(document).toEqual(original);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should throw on decryption errors', async () => {
|
|
341
|
+
mockCryptor.decrypt.mockRejectedValueOnce(
|
|
342
|
+
new Error('Decryption failed')
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const document = {
|
|
346
|
+
data: {
|
|
347
|
+
access_token: 'keyId:secret:iv:enckey',
|
|
348
|
+
refresh_token: 'keyId:valid:iv:enckey',
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
await expect(
|
|
353
|
+
service.decryptFields('Credential', document)
|
|
354
|
+
).rejects.toThrow('Decryption failed');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('encryptFieldsInBulk', () => {
|
|
359
|
+
it('should encrypt multiple documents', async () => {
|
|
360
|
+
const documents = [
|
|
361
|
+
{ data: { access_token: 'secret1' } },
|
|
362
|
+
{ data: { access_token: 'secret2' } },
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
const result = await service.encryptFieldsInBulk(
|
|
366
|
+
'Credential',
|
|
367
|
+
documents
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
expect(result).toHaveLength(2);
|
|
371
|
+
expect(result[0].data.access_token).toBe(
|
|
372
|
+
'encrypted:secret1:keydata:enckey'
|
|
373
|
+
);
|
|
374
|
+
expect(result[1].data.access_token).toBe(
|
|
375
|
+
'encrypted:secret2:keydata:enckey'
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('should handle empty array', async () => {
|
|
380
|
+
const result = await service.encryptFieldsInBulk('Credential', []);
|
|
381
|
+
expect(result).toEqual([]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should return non-array values as-is', async () => {
|
|
385
|
+
expect(
|
|
386
|
+
await service.encryptFieldsInBulk('Credential', null)
|
|
387
|
+
).toBeNull();
|
|
388
|
+
expect(
|
|
389
|
+
await service.encryptFieldsInBulk('Credential', { data: {} })
|
|
390
|
+
).toEqual({ data: {} });
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('decryptFieldsInBulk', () => {
|
|
395
|
+
it('should decrypt multiple documents', async () => {
|
|
396
|
+
const documents = [
|
|
397
|
+
{ data: { access_token: 'keyId:secret1:iv:enckey' } },
|
|
398
|
+
{ data: { access_token: 'keyId:secret2:iv:enckey' } },
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
const result = await service.decryptFieldsInBulk(
|
|
402
|
+
'Credential',
|
|
403
|
+
documents
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
expect(result).toHaveLength(2);
|
|
407
|
+
expect(result[0].data.access_token).toBe('secret1');
|
|
408
|
+
expect(result[1].data.access_token).toBe('secret2');
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('_isEncrypted', () => {
|
|
413
|
+
it('should detect encrypted format', () => {
|
|
414
|
+
expect(service._isEncrypted('keyId:data:iv:enckey')).toBe(true);
|
|
415
|
+
expect(
|
|
416
|
+
service._isEncrypted('keyId:longer:data:with:colons:enckey')
|
|
417
|
+
).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should reject non-encrypted formats', () => {
|
|
421
|
+
expect(service._isEncrypted('plaintext')).toBe(false);
|
|
422
|
+
expect(service._isEncrypted('one:two:three')).toBe(false);
|
|
423
|
+
expect(service._isEncrypted('one:two')).toBe(false);
|
|
424
|
+
expect(service._isEncrypted(null)).toBe(false);
|
|
425
|
+
expect(service._isEncrypted(undefined)).toBe(false);
|
|
426
|
+
expect(service._isEncrypted(123)).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('_getNestedValue', () => {
|
|
431
|
+
it('should get top-level value', () => {
|
|
432
|
+
const obj = { name: 'test' };
|
|
433
|
+
expect(service._getNestedValue(obj, 'name')).toBe('test');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('should get nested value', () => {
|
|
437
|
+
const obj = { data: { token: 'abc' } };
|
|
438
|
+
expect(service._getNestedValue(obj, 'data.token')).toBe('abc');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should get deeply nested value', () => {
|
|
442
|
+
const obj = { level1: { level2: { level3: 'deep' } } };
|
|
443
|
+
expect(service._getNestedValue(obj, 'level1.level2.level3')).toBe(
|
|
444
|
+
'deep'
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should return undefined for missing path', () => {
|
|
449
|
+
const obj = { data: { token: 'abc' } };
|
|
450
|
+
expect(service._getNestedValue(obj, 'data.missing')).toBeUndefined();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should handle null/undefined gracefully', () => {
|
|
454
|
+
expect(service._getNestedValue(null, 'path')).toBeUndefined();
|
|
455
|
+
expect(service._getNestedValue({}, null)).toBeUndefined();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('_setNestedValue', () => {
|
|
460
|
+
it('should set top-level value', () => {
|
|
461
|
+
const obj = {};
|
|
462
|
+
service._setNestedValue(obj, 'name', 'test');
|
|
463
|
+
expect(obj.name).toBe('test');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should set nested value', () => {
|
|
467
|
+
const obj = {};
|
|
468
|
+
service._setNestedValue(obj, 'data.token', 'abc');
|
|
469
|
+
expect(obj.data.token).toBe('abc');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should set deeply nested value', () => {
|
|
473
|
+
const obj = {};
|
|
474
|
+
service._setNestedValue(obj, 'level1.level2.level3', 'deep');
|
|
475
|
+
expect(obj.level1.level2.level3).toBe('deep');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should create intermediate objects', () => {
|
|
479
|
+
const obj = { data: {} };
|
|
480
|
+
service._setNestedValue(obj, 'data.nested.value', 'test');
|
|
481
|
+
expect(obj.data.nested.value).toBe('test');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('should handle null/undefined gracefully', () => {
|
|
485
|
+
service._setNestedValue(null, 'path', 'value'); // Should not throw
|
|
486
|
+
service._setNestedValue({}, null, 'value'); // Should not throw
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('_deepClone', () => {
|
|
491
|
+
it('should clone objects', () => {
|
|
492
|
+
const obj = { a: 1, b: { c: 2 } };
|
|
493
|
+
const clone = service._deepClone(obj);
|
|
494
|
+
|
|
495
|
+
expect(clone).toEqual(obj);
|
|
496
|
+
expect(clone).not.toBe(obj);
|
|
497
|
+
expect(clone.b).not.toBe(obj.b);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('should clone arrays', () => {
|
|
501
|
+
const arr = [1, 2, { a: 3 }];
|
|
502
|
+
const clone = service._deepClone(arr);
|
|
503
|
+
|
|
504
|
+
expect(clone).toEqual(arr);
|
|
505
|
+
expect(clone).not.toBe(arr);
|
|
506
|
+
expect(clone[2]).not.toBe(arr[2]);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('should clone dates', () => {
|
|
510
|
+
const date = new Date('2024-01-01');
|
|
511
|
+
const clone = service._deepClone(date);
|
|
512
|
+
|
|
513
|
+
expect(clone).toEqual(date);
|
|
514
|
+
expect(clone).not.toBe(date);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should handle primitives', () => {
|
|
518
|
+
expect(service._deepClone(null)).toBeNull();
|
|
519
|
+
expect(service._deepClone(undefined)).toBeUndefined();
|
|
520
|
+
expect(service._deepClone(123)).toBe(123);
|
|
521
|
+
expect(service._deepClone('string')).toBe('string');
|
|
522
|
+
expect(service._deepClone(true)).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption Logger
|
|
3
|
+
*
|
|
4
|
+
* Centralized logging for encryption operations.
|
|
5
|
+
* Prevents sensitive data leakage in production logs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const LOG_LEVELS = {
|
|
9
|
+
DEBUG: 0,
|
|
10
|
+
INFO: 1,
|
|
11
|
+
WARN: 2,
|
|
12
|
+
ERROR: 3,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
class EncryptionLogger {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.minLevel = this._getMinLevel();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_getMinLevel() {
|
|
21
|
+
const level = process.env.FRIGG_LOG_LEVEL || 'INFO';
|
|
22
|
+
return LOG_LEVELS[level.toUpperCase()] ?? LOG_LEVELS.INFO;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_shouldLog(level) {
|
|
26
|
+
return LOG_LEVELS[level] >= this.minLevel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_sanitize(message) {
|
|
30
|
+
// Remove potential key material or encrypted data from logs
|
|
31
|
+
if (typeof message === 'string') {
|
|
32
|
+
// Truncate long base64 strings that might be keys or encrypted data
|
|
33
|
+
return message.replace(/([A-Za-z0-9+/=]{50,})/g, (match) =>
|
|
34
|
+
`${match.substring(0, 10)}...[${match.length} chars]`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return message;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
debug(message, ...args) {
|
|
41
|
+
if (this._shouldLog('DEBUG')) {
|
|
42
|
+
console.log(`[Frigg Debug]`, this._sanitize(message), ...args);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
info(message, ...args) {
|
|
47
|
+
if (this._shouldLog('INFO')) {
|
|
48
|
+
console.log(`[Frigg]`, this._sanitize(message), ...args);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
warn(message, ...args) {
|
|
53
|
+
if (this._shouldLog('WARN')) {
|
|
54
|
+
console.warn(`[Frigg]`, this._sanitize(message), ...args);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
error(message, error) {
|
|
59
|
+
if (this._shouldLog('ERROR')) {
|
|
60
|
+
const sanitizedMessage = this._sanitize(message);
|
|
61
|
+
|
|
62
|
+
// In production, don't log stack traces with sensitive paths
|
|
63
|
+
const isProduction = process.env.STAGE === 'production';
|
|
64
|
+
|
|
65
|
+
if (error && !isProduction) {
|
|
66
|
+
console.error(`[Frigg]`, sanitizedMessage, error);
|
|
67
|
+
} else if (error) {
|
|
68
|
+
console.error(`[Frigg]`, sanitizedMessage, error.message);
|
|
69
|
+
} else {
|
|
70
|
+
console.error(`[Frigg]`, sanitizedMessage);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Singleton instance
|
|
77
|
+
const logger = new EncryptionLogger();
|
|
78
|
+
|
|
79
|
+
module.exports = { logger };
|