@tumbaland/backend-core 1.43.0 → 1.45.0
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/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
package/src/audit/service.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { AuditEvent, type IAuditEvent, type ToolCall } from './AuditEvent';
|
|
2
|
-
import type { ActorKind } from './actor';
|
|
3
|
-
|
|
4
|
-
export interface AuditQuery {
|
|
5
|
-
userId: string;
|
|
6
|
-
/** narrow to one actor — a key's id or an OAuth client id */
|
|
7
|
-
credentialId?: string;
|
|
8
|
-
actorKind?: ActorKind;
|
|
9
|
-
action?: IAuditEvent['action'];
|
|
10
|
-
resource?: string;
|
|
11
|
-
/** narrow to one area — 'relationship-service', 'album-service' */
|
|
12
|
-
service?: string;
|
|
13
|
-
from?: Date;
|
|
14
|
-
to?: Date;
|
|
15
|
-
limit?: number;
|
|
16
|
-
page?: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface AuditEntry {
|
|
20
|
-
id: string;
|
|
21
|
-
at: string;
|
|
22
|
-
service: string;
|
|
23
|
-
action: IAuditEvent['action'];
|
|
24
|
-
resource: string;
|
|
25
|
-
resourceId?: string;
|
|
26
|
-
actorKind: ActorKind;
|
|
27
|
-
actorLabel: string;
|
|
28
|
-
actorCredentialId?: string;
|
|
29
|
-
tenant: string;
|
|
30
|
-
changes?: Record<string, { from: unknown; to: unknown }>;
|
|
31
|
-
snapshot?: Record<string, unknown>;
|
|
32
|
-
count?: number;
|
|
33
|
-
/** present on 'call': which tool ran, how long it took, and what it cost */
|
|
34
|
-
tool?: ToolCall;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const toEntry = (event: IAuditEvent): AuditEntry => ({
|
|
38
|
-
id: String(event._id),
|
|
39
|
-
at: event.at.toISOString(),
|
|
40
|
-
service: event.service,
|
|
41
|
-
action: event.action,
|
|
42
|
-
resource: event.resource,
|
|
43
|
-
resourceId: event.resourceId,
|
|
44
|
-
actorKind: event.actorKind,
|
|
45
|
-
actorLabel: event.actorLabel,
|
|
46
|
-
actorCredentialId: event.actorCredentialId,
|
|
47
|
-
tenant: event.tenant,
|
|
48
|
-
changes: event.changes,
|
|
49
|
-
snapshot: event.snapshot,
|
|
50
|
-
count: event.count,
|
|
51
|
-
tool: event.tool
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* One account's trail, newest first.
|
|
56
|
-
*
|
|
57
|
-
* Always scoped to `userId` by the query rather than filtered afterwards, so
|
|
58
|
-
* there is no path where a wider read is one forgotten condition away. The trail
|
|
59
|
-
* carries old values of journal entries, which makes it as sensitive as the
|
|
60
|
-
* journal itself.
|
|
61
|
-
*/
|
|
62
|
-
export const listAuditEvents = async (
|
|
63
|
-
query: AuditQuery
|
|
64
|
-
): Promise<{ events: AuditEntry[]; total: number; page: number; totalPages: number }> => {
|
|
65
|
-
const limit = Math.min(query.limit ?? 50, 200);
|
|
66
|
-
const page = Math.max(query.page ?? 1, 1);
|
|
67
|
-
|
|
68
|
-
const filter: Record<string, unknown> = { userId: query.userId };
|
|
69
|
-
if (query.credentialId) filter.actorCredentialId = query.credentialId;
|
|
70
|
-
if (query.actorKind) filter.actorKind = query.actorKind;
|
|
71
|
-
if (query.action) filter.action = query.action;
|
|
72
|
-
if (query.resource) filter.resource = query.resource;
|
|
73
|
-
if (query.service) filter.service = query.service;
|
|
74
|
-
if (query.from || query.to) {
|
|
75
|
-
const at: Record<string, Date> = {};
|
|
76
|
-
if (query.from) at.$gte = query.from;
|
|
77
|
-
if (query.to) at.$lte = query.to;
|
|
78
|
-
filter.at = at;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const [events, total] = await Promise.all([
|
|
82
|
-
AuditEvent.find(filter)
|
|
83
|
-
.sort({ at: -1 })
|
|
84
|
-
.skip((page - 1) * limit)
|
|
85
|
-
.limit(limit),
|
|
86
|
-
AuditEvent.countDocuments(filter)
|
|
87
|
-
]);
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
events: events.map(toEntry),
|
|
91
|
-
total,
|
|
92
|
-
page,
|
|
93
|
-
totalPages: Math.ceil(total / limit)
|
|
94
|
-
};
|
|
95
|
-
};
|
package/src/auth/session.test.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
const connection = { readyState: 1, collection: jest.fn() };
|
|
2
|
-
|
|
3
|
-
jest.mock('mongoose', () => {
|
|
4
|
-
const actual = jest.requireActual('mongoose');
|
|
5
|
-
return { __esModule: true, default: { connection, Types: actual.Types } };
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
import mongoose from 'mongoose';
|
|
9
|
-
import {
|
|
10
|
-
USER_SESSIONS_COLLECTION,
|
|
11
|
-
isCurrentSession,
|
|
12
|
-
isLegacySessionlessToken,
|
|
13
|
-
overrideSessionCheckForTests
|
|
14
|
-
} from './session';
|
|
15
|
-
|
|
16
|
-
const USER_ID = 'b'.repeat(24);
|
|
17
|
-
const SESSION_ID = 'a'.repeat(24);
|
|
18
|
-
const now = Math.floor(Date.now() / 1000);
|
|
19
|
-
|
|
20
|
-
describe('isLegacySessionlessToken', () => {
|
|
21
|
-
it('accepts a pre-sessions token that lived 24 hours at most', () => {
|
|
22
|
-
expect(isLegacySessionlessToken({ iat: now, exp: now + 24 * 60 * 60 })).toBe(true);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('refuses a session-less token that lives longer, or has no expiry', () => {
|
|
26
|
-
expect(isLegacySessionlessToken({ iat: now, exp: now + 24 * 60 * 60 + 1 })).toBe(false);
|
|
27
|
-
expect(isLegacySessionlessToken({ iat: now })).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('is never true for a token that names a session', () => {
|
|
31
|
-
expect(isLegacySessionlessToken({ sid: SESSION_ID, iat: now, exp: now + 60 })).toBe(false);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('isCurrentSession', () => {
|
|
36
|
-
const findOne = jest.fn();
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
overrideSessionCheckForTests();
|
|
40
|
-
findOne.mockReset();
|
|
41
|
-
connection.readyState = 1;
|
|
42
|
-
connection.collection.mockReset().mockReturnValue({ findOne });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('accepts a live session that belongs to the token user', async () => {
|
|
46
|
-
findOne.mockResolvedValue({ _id: SESSION_ID });
|
|
47
|
-
|
|
48
|
-
await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(true);
|
|
49
|
-
|
|
50
|
-
expect(mongoose.connection.collection).toHaveBeenCalledWith(USER_SESSIONS_COLLECTION);
|
|
51
|
-
const [query, options] = findOne.mock.calls[0];
|
|
52
|
-
expect(query._id.toString()).toBe(SESSION_ID);
|
|
53
|
-
expect(query.userId.toString()).toBe(USER_ID);
|
|
54
|
-
expect(query.expiresAt.$gt).toBeInstanceOf(Date);
|
|
55
|
-
expect(options).toMatchObject({ readPreference: 'primary' });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('refuses a revoked or expired session', async () => {
|
|
59
|
-
findOne.mockResolvedValue(null);
|
|
60
|
-
|
|
61
|
-
await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(false);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('refuses while the database is unavailable rather than letting the token through', async () => {
|
|
65
|
-
connection.readyState = 0;
|
|
66
|
-
|
|
67
|
-
await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(false);
|
|
68
|
-
expect(findOne).not.toHaveBeenCalled();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it.each([
|
|
72
|
-
[{ id: USER_ID, sid: 'not-an-id' }],
|
|
73
|
-
[{ id: 'not-an-id', sid: SESSION_ID }],
|
|
74
|
-
[{ id: USER_ID, sid: 42 }],
|
|
75
|
-
[null]
|
|
76
|
-
])('refuses malformed claims %j without a lookup', async (claims) => {
|
|
77
|
-
await expect(isCurrentSession(claims)).resolves.toBe(false);
|
|
78
|
-
expect(findOne).not.toHaveBeenCalled();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('accepts a short-lived pre-sessions token without a lookup', async () => {
|
|
82
|
-
await expect(isCurrentSession({ id: USER_ID, iat: now, exp: now + 60 })).resolves.toBe(true);
|
|
83
|
-
expect(findOne).not.toHaveBeenCalled();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('refuses a long-lived token with no session', async () => {
|
|
87
|
-
await expect(isCurrentSession({ id: USER_ID, iat: now, exp: now + 90 * 24 * 60 * 60 })).resolves.toBe(false);
|
|
88
|
-
});
|
|
89
|
-
});
|
package/src/auth/session.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import mongoose from 'mongoose';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* One document per signed-in device, owned and written by auth-service; every
|
|
5
|
-
* other service only reads it here. A session token names its document in the
|
|
6
|
-
* `sid` claim, and the token is only as good as that document: deleting it
|
|
7
|
-
* (sign-out, "sign out this device") ends the session on its next request, in
|
|
8
|
-
* every service, and its `expiresAt` (slid forward by auth-service while the
|
|
9
|
-
* device is in use, never past a hard cap from sign-in) is what idle expiry
|
|
10
|
-
* actually means.
|
|
11
|
-
*/
|
|
12
|
-
export const USER_SESSIONS_COLLECTION = 'user_sessions';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Tokens minted before sessions existed carry no `sid` and lived 24 hours.
|
|
16
|
-
* auth-service upgrades one to a session the next time `/auth/verify` sees it,
|
|
17
|
-
* so this window closes by itself a day after the sessions release; the branch
|
|
18
|
-
* accepting them can be deleted after that.
|
|
19
|
-
*/
|
|
20
|
-
const LEGACY_TOKEN_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
21
|
-
|
|
22
|
-
const OBJECT_ID = /^[a-f\d]{24}$/i;
|
|
23
|
-
|
|
24
|
-
/** True for a pre-sessions token: no `sid`, and the old 24-hour lifetime at most. */
|
|
25
|
-
export function isLegacySessionlessToken(claims: unknown): boolean {
|
|
26
|
-
if (!claims || typeof claims !== 'object') return false;
|
|
27
|
-
const { sid, iat, exp } = claims as { sid?: unknown; iat?: unknown; exp?: unknown };
|
|
28
|
-
return (
|
|
29
|
-
sid === undefined &&
|
|
30
|
-
Number.isSafeInteger(iat) &&
|
|
31
|
-
Number.isSafeInteger(exp) &&
|
|
32
|
-
(exp as number) - (iat as number) <= LEGACY_TOKEN_MAX_LIFETIME_SECONDS
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
type SessionCheck = (claims: unknown) => Promise<boolean>;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Whether a verified session token still stands: for a token with a `sid`,
|
|
40
|
-
* its session belongs to the token's user and has neither been revoked nor
|
|
41
|
-
* expired. Read on the primary, uncached, so a revocation holds from the next
|
|
42
|
-
* request.
|
|
43
|
-
*/
|
|
44
|
-
const checkSessionInDatabase: SessionCheck = async (claims) => {
|
|
45
|
-
if (!claims || typeof claims !== 'object') return false;
|
|
46
|
-
const { id, sid } = claims as { id?: unknown; sid?: unknown };
|
|
47
|
-
if (sid === undefined) return isLegacySessionlessToken(claims);
|
|
48
|
-
if (typeof sid !== 'string' || !OBJECT_ID.test(sid)) return false;
|
|
49
|
-
if (typeof id !== 'string' || !OBJECT_ID.test(id)) return false;
|
|
50
|
-
// Refuse rather than queue an authentication query while the database is down.
|
|
51
|
-
if (mongoose.connection.readyState !== 1) return false;
|
|
52
|
-
|
|
53
|
-
const session = await mongoose.connection.collection(USER_SESSIONS_COLLECTION).findOne(
|
|
54
|
-
{
|
|
55
|
-
_id: new mongoose.Types.ObjectId(sid),
|
|
56
|
-
userId: new mongoose.Types.ObjectId(id),
|
|
57
|
-
expiresAt: { $gt: new Date() }
|
|
58
|
-
},
|
|
59
|
-
{ projection: { _id: 1 }, readPreference: 'primary', maxTimeMS: 3000 }
|
|
60
|
-
);
|
|
61
|
-
return session !== null;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
let sessionCheck: SessionCheck = checkSessionInDatabase;
|
|
65
|
-
|
|
66
|
-
export const isCurrentSession = (claims: unknown): Promise<boolean> => sessionCheck(claims);
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Replaces the check, for tests only; pass nothing to restore the real one.
|
|
70
|
-
* Not exported from the package index: service suites reach it through the
|
|
71
|
-
* `@tumbaland/backend-core/testing` entry point (see testing.js).
|
|
72
|
-
*/
|
|
73
|
-
export function overrideSessionCheckForTests(check?: SessionCheck): void {
|
|
74
|
-
sessionCheck = check ?? checkSessionInDatabase;
|
|
75
|
-
}
|
package/src/config/env.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { requireEnv } from './env';
|
|
2
|
-
|
|
3
|
-
describe('requireEnv', () => {
|
|
4
|
-
const ORIGINAL_ENV = process.env;
|
|
5
|
-
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
process.env = { ...ORIGINAL_ENV };
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
afterAll(() => {
|
|
11
|
-
process.env = ORIGINAL_ENV;
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('returns the value when the variable is set', () => {
|
|
15
|
-
process.env.MY_VAR = 'hello';
|
|
16
|
-
expect(requireEnv('MY_VAR')).toBe('hello');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('throws when the variable is missing', () => {
|
|
20
|
-
delete process.env.MY_VAR;
|
|
21
|
-
expect(() => requireEnv('MY_VAR')).toThrow('Missing required environment variable: MY_VAR');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('throws when the variable is an empty string', () => {
|
|
25
|
-
process.env.MY_VAR = '';
|
|
26
|
-
expect(() => requireEnv('MY_VAR')).toThrow('Missing required environment variable: MY_VAR');
|
|
27
|
-
});
|
|
28
|
-
});
|
package/src/config/env.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Returns the value of a required environment variable.
|
|
3
|
-
* Throws if the variable is missing or empty — call at service startup
|
|
4
|
-
* (after dotenv.config()) so misconfigured services fail fast instead of
|
|
5
|
-
* running with insecure defaults.
|
|
6
|
-
*/
|
|
7
|
-
export function requireEnv(name: string): string {
|
|
8
|
-
const value = process.env[name];
|
|
9
|
-
if (!value) {
|
|
10
|
-
throw new Error(`Missing required environment variable: ${name}`);
|
|
11
|
-
}
|
|
12
|
-
return value;
|
|
13
|
-
}
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
jest.mock('../logging/logger', () => ({
|
|
2
|
-
__esModule: true,
|
|
3
|
-
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
-
}));
|
|
5
|
-
|
|
6
|
-
const connectionHandlers: Record<string, (...args: any[]) => void> = {};
|
|
7
|
-
|
|
8
|
-
jest.mock('mongoose', () => ({
|
|
9
|
-
__esModule: true,
|
|
10
|
-
default: {
|
|
11
|
-
connect: jest.fn(),
|
|
12
|
-
connection: {
|
|
13
|
-
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
|
|
14
|
-
connectionHandlers[event] = handler;
|
|
15
|
-
}),
|
|
16
|
-
close: jest.fn()
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
import mongoose from 'mongoose';
|
|
22
|
-
import logger from '../logging/logger';
|
|
23
|
-
import { disconnectDB } from './connection';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* `connection.ts` keeps module-level `connected`/`pending` flags, so each
|
|
27
|
-
* connectDB scenario needs its own fresh copy of the module —
|
|
28
|
-
* isolateModulesAsync sandboxes that without leaking into other tests in this
|
|
29
|
-
* file the way a blanket jest.resetModules() would.
|
|
30
|
-
*
|
|
31
|
-
* Note that this does NOT re-create the mongoose/logger mocks: a jest.mock
|
|
32
|
-
* factory result is shared across isolated registries, so the objects returned
|
|
33
|
-
* here are the same instances every time. Resetting their implementations is
|
|
34
|
-
* beforeEach's job — see the comment there.
|
|
35
|
-
*/
|
|
36
|
-
async function freshConnectDB() {
|
|
37
|
-
let connectDBFn!: typeof import('./connection').connectDB;
|
|
38
|
-
let mongooseMock!: typeof mongoose;
|
|
39
|
-
let loggerMock!: typeof logger;
|
|
40
|
-
|
|
41
|
-
await jest.isolateModulesAsync(async () => {
|
|
42
|
-
mongooseMock = (await import('mongoose')).default as unknown as typeof mongoose;
|
|
43
|
-
loggerMock = (await import('../logging/logger')).default as unknown as typeof logger;
|
|
44
|
-
connectDBFn = (await import('./connection')).connectDB;
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
return { connectDB: connectDBFn, mongoose: mongooseMock, logger: loggerMock };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
describe('connectDB', () => {
|
|
51
|
-
const ORIGINAL_ENV = process.env;
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
process.env = { ...ORIGINAL_ENV };
|
|
55
|
-
delete process.env.MONGODB_URI;
|
|
56
|
-
for (const key of Object.keys(connectionHandlers)) delete connectionHandlers[key];
|
|
57
|
-
|
|
58
|
-
// `clearMocks: true` only clears recorded calls, not implementations, so a
|
|
59
|
-
// mockRejectedValue set by one test stays installed for every test after
|
|
60
|
-
// it. Reset connect explicitly — but not connection.on, whose factory
|
|
61
|
-
// implementation is what records into connectionHandlers.
|
|
62
|
-
(mongoose.connect as jest.Mock).mockReset();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
afterAll(() => {
|
|
66
|
-
process.env = ORIGINAL_ENV;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('throws when no MongoDB URI is available', async () => {
|
|
70
|
-
const { connectDB } = await freshConnectDB();
|
|
71
|
-
await expect(connectDB('my-service')).rejects.toThrow('MONGODB_URI environment variable is not set');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('connects using the explicit uri argument over the env var', async () => {
|
|
75
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
76
|
-
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
77
|
-
|
|
78
|
-
await connectDB('my-service', 'mongodb://explicit-uri');
|
|
79
|
-
|
|
80
|
-
expect(mongoose.connect).toHaveBeenCalledWith('mongodb://explicit-uri');
|
|
81
|
-
expect(logger.info).toHaveBeenCalledWith('MongoDB connected', { service: 'my-service' });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('falls back to MONGODB_URI when no explicit uri is given', async () => {
|
|
85
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
86
|
-
const { connectDB, mongoose } = await freshConnectDB();
|
|
87
|
-
|
|
88
|
-
await connectDB('my-service');
|
|
89
|
-
|
|
90
|
-
expect(mongoose.connect).toHaveBeenCalledWith('mongodb://env-uri');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('is a no-op on a second call once already connected', async () => {
|
|
94
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
95
|
-
const { connectDB, mongoose } = await freshConnectDB();
|
|
96
|
-
|
|
97
|
-
await connectDB('my-service');
|
|
98
|
-
await connectDB('my-service');
|
|
99
|
-
|
|
100
|
-
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('logs and rethrows when mongoose.connect fails', async () => {
|
|
104
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
105
|
-
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
106
|
-
(mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
107
|
-
|
|
108
|
-
await expect(connectDB('my-service')).rejects.toThrow('ECONNREFUSED');
|
|
109
|
-
expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB', {
|
|
110
|
-
service: 'my-service',
|
|
111
|
-
error: 'ECONNREFUSED'
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The driver retries internally for serverSelectionTimeoutMS; anything that
|
|
117
|
-
* reaches us is a real fault, so one failure is one rejection. Callers exit
|
|
118
|
-
* non-zero and the container restart policy takes it from there.
|
|
119
|
-
*/
|
|
120
|
-
it('does not retry a failed connection', async () => {
|
|
121
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
122
|
-
const { connectDB, mongoose } = await freshConnectDB();
|
|
123
|
-
(mongoose.connect as jest.Mock).mockRejectedValue(new Error('auth failed'));
|
|
124
|
-
|
|
125
|
-
await expect(connectDB('my-service')).rejects.toThrow('auth failed');
|
|
126
|
-
|
|
127
|
-
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('clears the in-flight promise so a later call can retry', async () => {
|
|
131
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
132
|
-
const { connectDB, mongoose } = await freshConnectDB();
|
|
133
|
-
(mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('not ready'));
|
|
134
|
-
|
|
135
|
-
await expect(connectDB('my-service')).rejects.toThrow('not ready');
|
|
136
|
-
await connectDB('my-service');
|
|
137
|
-
|
|
138
|
-
expect(mongoose.connect).toHaveBeenCalledTimes(2);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('lets concurrent callers share one connection attempt', async () => {
|
|
142
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
143
|
-
const { connectDB, mongoose } = await freshConnectDB();
|
|
144
|
-
|
|
145
|
-
await Promise.all([connectDB('my-service'), connectDB('my-service')]);
|
|
146
|
-
|
|
147
|
-
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('registers error/disconnected/reconnected handlers that log through the shared logger', async () => {
|
|
151
|
-
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
152
|
-
const { connectDB, logger } = await freshConnectDB();
|
|
153
|
-
|
|
154
|
-
await connectDB('my-service');
|
|
155
|
-
|
|
156
|
-
connectionHandlers.error(new Error('boom'));
|
|
157
|
-
expect(logger.error).toHaveBeenCalledWith('MongoDB connection error', { service: 'my-service', error: 'boom' });
|
|
158
|
-
|
|
159
|
-
connectionHandlers.disconnected();
|
|
160
|
-
expect(logger.warn).toHaveBeenCalledWith('MongoDB disconnected', { service: 'my-service' });
|
|
161
|
-
|
|
162
|
-
connectionHandlers.reconnected();
|
|
163
|
-
expect(logger.info).toHaveBeenCalledWith('MongoDB reconnected', { service: 'my-service' });
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
describe('disconnectDB', () => {
|
|
168
|
-
beforeEach(() => {
|
|
169
|
-
jest.clearAllMocks();
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('closes the connection and logs success', async () => {
|
|
173
|
-
await disconnectDB('my-service');
|
|
174
|
-
|
|
175
|
-
expect(mongoose.connection.close).toHaveBeenCalled();
|
|
176
|
-
expect(logger.info).toHaveBeenCalledWith('MongoDB connection closed', { service: 'my-service' });
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('logs and rethrows when closing fails', async () => {
|
|
180
|
-
(mongoose.connection.close as jest.Mock).mockRejectedValueOnce(new Error('close failed'));
|
|
181
|
-
|
|
182
|
-
await expect(disconnectDB('my-service')).rejects.toThrow('close failed');
|
|
183
|
-
expect(logger.error).toHaveBeenCalledWith('Error closing MongoDB connection', {
|
|
184
|
-
service: 'my-service',
|
|
185
|
-
error: 'close failed'
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import mongoose from 'mongoose';
|
|
2
|
-
import logger from '../logging/logger';
|
|
3
|
-
|
|
4
|
-
let connected = false;
|
|
5
|
-
/**
|
|
6
|
-
* Set while a connection attempt is in flight so concurrent callers await that
|
|
7
|
-
* attempt instead of opening a second one against the same mongoose singleton.
|
|
8
|
-
*/
|
|
9
|
-
let pending: Promise<void> | null = null;
|
|
10
|
-
let listenersRegistered = false;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Connects once and throws on failure. There is deliberately no retry loop here.
|
|
14
|
-
*
|
|
15
|
-
* MongoDB is Atlas (`mongodb+srv://`), not a sidecar container, so there is no
|
|
16
|
-
* boot race to ride out — and the driver already retries for us: `connect()`
|
|
17
|
-
* keeps doing server selection for `serverSelectionTimeoutMS` (30s by default)
|
|
18
|
-
* before it rejects, so a transient DNS or network blip is absorbed there.
|
|
19
|
-
* Anything that survives that is a real fault — bad credentials, an IP missing
|
|
20
|
-
* from the Atlas allowlist, a dead cluster — and no amount of retrying fixes it.
|
|
21
|
-
*
|
|
22
|
-
* Callers exit non-zero when this throws, which hands the problem to the
|
|
23
|
-
* container restart policy (`restart: unless-stopped`) that already supervises
|
|
24
|
-
* every service. One supervisor, and a service that can't reach its database
|
|
25
|
-
* shows up as a restarting container instead of a healthy one serving errors.
|
|
26
|
-
*/
|
|
27
|
-
export const connectDB = async (serviceName?: string, uri?: string): Promise<void> => {
|
|
28
|
-
if (connected) return;
|
|
29
|
-
if (pending) return pending;
|
|
30
|
-
|
|
31
|
-
const mongoUri = uri || process.env.MONGODB_URI;
|
|
32
|
-
if (!mongoUri) {
|
|
33
|
-
throw new Error('MONGODB_URI environment variable is not set');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
pending = connectOnce(mongoUri, serviceName).finally(() => {
|
|
37
|
-
pending = null;
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
return pending;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
async function connectOnce(mongoUri: string, serviceName: string | undefined): Promise<void> {
|
|
44
|
-
try {
|
|
45
|
-
await mongoose.connect(mongoUri);
|
|
46
|
-
connected = true;
|
|
47
|
-
|
|
48
|
-
registerConnectionListeners(serviceName);
|
|
49
|
-
logger.info('MongoDB connected', { service: serviceName });
|
|
50
|
-
} catch (error) {
|
|
51
|
-
logger.error('Failed to connect to MongoDB', {
|
|
52
|
-
service: serviceName,
|
|
53
|
-
error: (error as Error).message
|
|
54
|
-
});
|
|
55
|
-
throw error;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Mongoose reconnects on its own once an initial connection has succeeded, so
|
|
61
|
-
* these only report state — but without them a drop is silent and the only
|
|
62
|
-
* symptom is the health endpoint flipping to "disconnected".
|
|
63
|
-
*/
|
|
64
|
-
function registerConnectionListeners(serviceName?: string): void {
|
|
65
|
-
if (listenersRegistered) return;
|
|
66
|
-
listenersRegistered = true;
|
|
67
|
-
|
|
68
|
-
mongoose.connection.on('error', (err: Error) => {
|
|
69
|
-
logger.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
mongoose.connection.on('disconnected', () => {
|
|
73
|
-
logger.warn('MongoDB disconnected', { service: serviceName });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
mongoose.connection.on('reconnected', () => {
|
|
77
|
-
logger.info('MongoDB reconnected', { service: serviceName });
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export const disconnectDB = async (serviceName?: string): Promise<void> => {
|
|
82
|
-
try {
|
|
83
|
-
await mongoose.connection.close();
|
|
84
|
-
connected = false;
|
|
85
|
-
logger.info('MongoDB connection closed', { service: serviceName });
|
|
86
|
-
} catch (error) {
|
|
87
|
-
logger.error('Error closing MongoDB connection', { service: serviceName, error: (error as Error).message });
|
|
88
|
-
throw error;
|
|
89
|
-
}
|
|
90
|
-
};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { Schema, model, models, Document, Model } from 'mongoose';
|
|
2
|
-
import { MeterKey, MeterPeriod, meterPeriod } from './definitions';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* One counter per (subject, meter, period). Lives in the shared database, so
|
|
6
|
-
* album-service can write `storageBytes` while payment-service reads it back
|
|
7
|
-
* for the pricing page without another round trip.
|
|
8
|
-
*
|
|
9
|
-
* `subjectId` is whoever the usage is billed to — an email for personal
|
|
10
|
-
* meters, the group owner's email for pooled ones. It is deliberately not a
|
|
11
|
-
* user ObjectId: every service already has the email on the JWT, and none of
|
|
12
|
-
* them share a users collection.
|
|
13
|
-
*/
|
|
14
|
-
export interface IUsageMeter extends Document {
|
|
15
|
-
subjectId: string;
|
|
16
|
-
meter: MeterKey;
|
|
17
|
-
/** `ALL` for cumulative meters, `YYYY-MM` for monthly ones. */
|
|
18
|
-
period: string;
|
|
19
|
-
count: number;
|
|
20
|
-
createdAt: Date;
|
|
21
|
-
updatedAt: Date;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const UsageMeterSchema = new Schema<IUsageMeter>(
|
|
25
|
-
{
|
|
26
|
-
subjectId: { type: String, required: true, lowercase: true, trim: true },
|
|
27
|
-
meter: { type: String, required: true },
|
|
28
|
-
period: { type: String, required: true, default: 'ALL' },
|
|
29
|
-
count: { type: Number, required: true, default: 0 }
|
|
30
|
-
},
|
|
31
|
-
{ timestamps: true }
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
UsageMeterSchema.index({ subjectId: 1, meter: 1, period: 1 }, { unique: true });
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Registered through `models` first: several services import this module into
|
|
38
|
-
* the same mongoose singleton during tests, and a second `model()` call with
|
|
39
|
-
* the same name throws `OverwriteModelError`.
|
|
40
|
-
*/
|
|
41
|
-
export const UsageMeter: Model<IUsageMeter> =
|
|
42
|
-
(models.UsageMeter as Model<IUsageMeter>) || model<IUsageMeter>('UsageMeter', UsageMeterSchema);
|
|
43
|
-
|
|
44
|
-
/** The period bucket a meter counts into right now. Monthly meters roll over at 00:00 UTC on the 1st. */
|
|
45
|
-
export function currentPeriod(meter: MeterKey, now: Date = new Date()): string {
|
|
46
|
-
const period: MeterPeriod = meterPeriod(meter);
|
|
47
|
-
if (period === 'ALL') return 'ALL';
|
|
48
|
-
return now.toISOString().slice(0, 7); // YYYY-MM
|
|
49
|
-
}
|