@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.
Files changed (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. package/tsconfig.json +0 -23
@@ -1,91 +0,0 @@
1
- /**
2
- * Base class for errors that should be turned into a specific HTTP response
3
- * by the shared errorHandler, instead of every controller hand-rolling
4
- * try/catch + string-matching on error.message to pick a status code.
5
- *
6
- * Express 5 forwards rejected promises from async route handlers to the
7
- * mounted error handler automatically, so controllers can just
8
- * `throw new NotFoundError(...)` and stop wrapping in try/catch.
9
- */
10
- export class HttpError extends Error {
11
- statusCode: number;
12
- /**
13
- * Extra fields merged into the JSON body by the shared errorHandler. Used by
14
- * errors the client has to *act* on rather than just display — a 402 tells
15
- * the upgrade dialog which meter blocked and which plan lifts it.
16
- */
17
- details?: Record<string, unknown>;
18
-
19
- constructor(statusCode: number, message: string, details?: Record<string, unknown>) {
20
- super(message);
21
- this.name = new.target.name;
22
- this.statusCode = statusCode;
23
- this.details = details;
24
- // Restore the prototype chain (needed when compiling to ES2018 targets
25
- // where `extends Error` doesn't preserve instanceof checks correctly).
26
- Object.setPrototypeOf(this, new.target.prototype);
27
- }
28
- }
29
-
30
- export class BadRequestError extends HttpError {
31
- constructor(message = 'Bad request') {
32
- super(400, message);
33
- }
34
- }
35
-
36
- export class UnauthorizedError extends HttpError {
37
- constructor(message = 'Unauthorized') {
38
- super(401, message);
39
- }
40
- }
41
-
42
- export class ForbiddenError extends HttpError {
43
- constructor(message = 'Access denied') {
44
- super(403, message);
45
- }
46
- }
47
-
48
- export class NotFoundError extends HttpError {
49
- constructor(message = 'Not found') {
50
- super(404, message);
51
- }
52
- }
53
-
54
- export class ConflictError extends HttpError {
55
- constructor(message = 'Conflict') {
56
- super(409, message);
57
- }
58
- }
59
-
60
- export class TooManyRequestsError extends HttpError {
61
- constructor(message = 'Too many requests') {
62
- super(429, message);
63
- }
64
- }
65
-
66
- /**
67
- * Fields the upgrade dialog renders. Every one of them comes from the response
68
- * body, so the dialog needs no knowledge of which call failed.
69
- */
70
- export interface PaymentRequiredDetails {
71
- /** Machine-readable reason, e.g. `QUOTA_EXCEEDED` or `FEATURE_NOT_IN_PLAN`. */
72
- code: string;
73
- meter?: string;
74
- feature?: string;
75
- used?: number;
76
- limit?: number;
77
- planCode?: string;
78
- /** The cheapest plan that lifts this particular limit, when one exists. */
79
- upgradeTo?: string;
80
- }
81
-
82
- /**
83
- * 402, never 403. 403 means "you may not"; 402 means "you may, for money" —
84
- * and the frontend needs to tell those apart to choose between an error toast
85
- * and an upgrade dialog.
86
- */
87
- export class PaymentRequiredError extends HttpError {
88
- constructor(message: string, details: PaymentRequiredDetails) {
89
- super(402, message, { ...details });
90
- }
91
- }
@@ -1,215 +0,0 @@
1
- jest.mock('../logging/logger', () => ({
2
- __esModule: true,
3
- default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
- }));
5
-
6
- import logger from '../logging/logger';
7
- import { clearGroupOwnerCache, getGroupOwner, invalidateGroupOwner } from './client';
8
-
9
- const ORIGINAL_ENV = process.env;
10
- const ORIGINAL_FETCH = global.fetch;
11
-
12
- function jsonResponse(body: unknown, status = 200): Response {
13
- return { ok: status >= 200 && status < 300, status, json: async () => body } as Response;
14
- }
15
-
16
- function ownerResponse(overrides: Record<string, unknown> = {}) {
17
- return jsonResponse({
18
- success: true,
19
- groupId: 'group-1',
20
- ownerEmail: 'Owner@Example.com',
21
- memberCount: 3,
22
- deleted: false,
23
- ...overrides
24
- });
25
- }
26
-
27
- beforeEach(() => {
28
- process.env = {
29
- ...ORIGINAL_ENV,
30
- GROUP_API_URL: 'http://group-service:5006',
31
- INTERNAL_SERVICE_TOKEN: 'test-internal-token',
32
- SERVICE_NAME: 'album-service'
33
- };
34
- clearGroupOwnerCache();
35
- global.fetch = jest.fn();
36
- (logger.error as jest.Mock).mockClear();
37
- (logger.warn as jest.Mock).mockClear();
38
- });
39
-
40
- afterAll(() => {
41
- process.env = ORIGINAL_ENV;
42
- global.fetch = ORIGINAL_FETCH;
43
- });
44
-
45
- describe('getGroupOwner', () => {
46
- it('calls the internal endpoint with the service token and normalizes the owner', async () => {
47
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
48
-
49
- const result = await getGroupOwner('group-1');
50
-
51
- expect(global.fetch).toHaveBeenCalledWith(
52
- 'http://group-service:5006/internal/groups/group-1/owner',
53
- expect.objectContaining({
54
- headers: expect.objectContaining({ 'x-internal-token': 'test-internal-token', 'x-service-id': 'album-service' })
55
- })
56
- );
57
- // Lowercased on arrival: it becomes a UsageMeter subjectId, which is
58
- // lowercase, so an owner recorded with capitals would open a second meter.
59
- expect(result?.ownerEmail).toBe('owner@example.com');
60
- expect(result?.memberCount).toBe(3);
61
- expect(result?.deleted).toBe(false);
62
- });
63
-
64
- it('resolves a soft-deleted group rather than dropping its storage', async () => {
65
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse({ deleted: true }));
66
-
67
- const result = await getGroupOwner('group-1');
68
-
69
- expect(result?.ownerEmail).toBe('owner@example.com');
70
- expect(result?.deleted).toBe(true);
71
- });
72
-
73
- it('serves the cached answer within the TTL and refetches after it', async () => {
74
- jest.useFakeTimers();
75
- try {
76
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
77
-
78
- await getGroupOwner('group-1');
79
- await getGroupOwner('group-1');
80
- expect(global.fetch).toHaveBeenCalledTimes(1);
81
-
82
- jest.advanceTimersByTime(60_001);
83
- await getGroupOwner('group-1');
84
- expect(global.fetch).toHaveBeenCalledTimes(2);
85
- } finally {
86
- jest.useRealTimers();
87
- }
88
- });
89
-
90
- it('collapses a concurrent burst for one group into a single request', async () => {
91
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
92
-
93
- await Promise.all(Array.from({ length: 10 }, () => getGroupOwner('group-1')));
94
-
95
- expect(global.fetch).toHaveBeenCalledTimes(1);
96
- });
97
-
98
- it('drops the cached owner on invalidate, so a transferred group rebills at once', async () => {
99
- (global.fetch as jest.Mock)
100
- .mockResolvedValueOnce(ownerResponse())
101
- .mockResolvedValue(ownerResponse({ ownerEmail: 'new-owner@example.com' }));
102
-
103
- const before = await getGroupOwner('group-1');
104
- invalidateGroupOwner('group-1');
105
- const after = await getGroupOwner('group-1');
106
-
107
- expect(before?.ownerEmail).toBe('owner@example.com');
108
- expect(after?.ownerEmail).toBe('new-owner@example.com');
109
- });
110
-
111
- it('returns null without calling out on a blank group id', async () => {
112
- expect(await getGroupOwner(' ')).toBeNull();
113
- expect(global.fetch).not.toHaveBeenCalled();
114
- });
115
- });
116
-
117
- describe('when the group cannot be resolved', () => {
118
- it('returns null on a 404 and warns rather than erroring', async () => {
119
- (global.fetch as jest.Mock).mockResolvedValue(jsonResponse({ success: false }, 404));
120
-
121
- expect(await getGroupOwner('missing')).toBeNull();
122
- expect(logger.warn).toHaveBeenCalled();
123
- expect(logger.error).not.toHaveBeenCalled();
124
- });
125
-
126
- it('returns null and errors when group-service is unreachable', async () => {
127
- (global.fetch as jest.Mock).mockRejectedValue(new Error('ECONNREFUSED'));
128
-
129
- expect(await getGroupOwner('group-1')).toBeNull();
130
- expect(logger.error).toHaveBeenCalled();
131
- });
132
-
133
- it('returns null when the answer carries no owner', async () => {
134
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse({ ownerEmail: undefined }));
135
-
136
- expect(await getGroupOwner('group-1')).toBeNull();
137
- expect(logger.error).toHaveBeenCalled();
138
- });
139
-
140
- it('returns null without calling out at all when the channel is unconfigured', async () => {
141
- delete process.env.GROUP_API_URL;
142
-
143
- expect(await getGroupOwner('group-1')).toBeNull();
144
- expect(global.fetch).not.toHaveBeenCalled();
145
- expect(logger.error).toHaveBeenCalledWith('Group owner lookup not configured', expect.anything());
146
- });
147
- });
148
-
149
- describe('base URL selection', () => {
150
- it('prefers the container-network address over the public hostname', async () => {
151
- process.env.GROUP_INTERNAL_URL = 'http://group-service:5006';
152
- process.env.GROUP_API_URL = 'https://group-api.tumbaland.eu';
153
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
154
-
155
- await getGroupOwner('group-1');
156
-
157
- // The public variable is what the frontends hand to the browser, so it can
158
- // never be pointed inward — this is the server-side half of that split.
159
- expect(global.fetch).toHaveBeenCalledWith(
160
- 'http://group-service:5006/internal/groups/group-1/owner',
161
- expect.anything()
162
- );
163
- });
164
-
165
- it('falls back to the public URL, so local dev and un-migrated deploys still work', async () => {
166
- delete process.env.GROUP_INTERNAL_URL;
167
- process.env.GROUP_API_URL = 'https://group-api.tumbaland.eu';
168
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
169
-
170
- await getGroupOwner('group-1');
171
-
172
- expect(global.fetch).toHaveBeenCalledWith(
173
- 'https://group-api.tumbaland.eu/internal/groups/group-1/owner',
174
- expect.anything()
175
- );
176
- });
177
-
178
- it('never caches a failure, so recovery is immediate rather than a minute later', async () => {
179
- (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(ownerResponse());
180
-
181
- const failed = await getGroupOwner('group-1');
182
- const recovered = await getGroupOwner('group-1');
183
-
184
- expect(failed).toBeNull();
185
- expect(recovered?.ownerEmail).toBe('owner@example.com');
186
- });
187
-
188
- it('never caches a 404 either — a bad id must not spend a cache slot', async () => {
189
- (global.fetch as jest.Mock).mockResolvedValue(jsonResponse({ success: false }, 404));
190
-
191
- await getGroupOwner('missing');
192
- await getGroupOwner('missing');
193
-
194
- expect(global.fetch).toHaveBeenCalledTimes(2);
195
- });
196
- });
197
-
198
- describe('cache bounds', () => {
199
- it('evicts the oldest entries instead of growing without limit', async () => {
200
- (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
201
-
202
- for (let i = 0; i < 5_001; i++) {
203
- await getGroupOwner(`group-${i}`);
204
- }
205
- const callsAfterFill = (global.fetch as jest.Mock).mock.calls.length;
206
-
207
- // The very first group was evicted by the 5001st, so this is a refetch.
208
- await getGroupOwner('group-0');
209
- expect((global.fetch as jest.Mock).mock.calls.length).toBe(callsAfterFill + 1);
210
-
211
- // The most recent group is still cached.
212
- await getGroupOwner('group-5000');
213
- expect((global.fetch as jest.Mock).mock.calls.length).toBe(callsAfterFill + 1);
214
- });
215
- });
@@ -1,182 +0,0 @@
1
- import logger from '../logging/logger';
2
-
3
- /**
4
- * Who a group's pooled usage bills to, read over the internal-service channel.
5
- *
6
- * The JWT carries `groups` — the ids a user belongs to — but never says who
7
- * owns them, and no service outside group-service has the collection. So a
8
- * chokepoint that needs the *quota subject* for a group album or a group ticker
9
- * has to ask. See MONETIZATION.md's note on why that subject is the owner and
10
- * not the uploader.
11
- *
12
- * Same 60s TTL as the entitlement client, for a weaker reason: ownership moves
13
- * far more rarely than a plan does, so the cache could be much longer, but a
14
- * stale owner bills the wrong person and one shared number is one fewer thing
15
- * to reason about. The enforcement points are writes, so the traffic is low
16
- * either way.
17
- */
18
- const CACHE_TTL_MS = 60_000;
19
-
20
- /** Bounded so a service under enumeration can't grow the cache without limit. */
21
- const MAX_CACHE_ENTRIES = 5_000;
22
-
23
- const REQUEST_TIMEOUT_MS = 3_000;
24
-
25
- export interface GroupOwner {
26
- groupId: string;
27
- ownerEmail: string;
28
- memberCount: number;
29
- /** Soft-deleted groups still resolve: their photos still occupy storage. */
30
- deleted: boolean;
31
- /** True when group-service could not be reached and nothing was resolved. */
32
- stale?: boolean;
33
- }
34
-
35
- interface CacheEntry {
36
- value: GroupOwner;
37
- expiresAt: number;
38
- }
39
-
40
- const cache = new Map<string, CacheEntry>();
41
-
42
- /**
43
- * Concurrent callers for one group share a request — a burst of uploads into
44
- * the same shared album is the expected shape of this traffic.
45
- */
46
- const inFlight = new Map<string, Promise<GroupOwner | null>>();
47
-
48
- function putInCache(groupId: string, value: GroupOwner): void {
49
- if (cache.size >= MAX_CACHE_ENTRIES) {
50
- // Map iterates in insertion order, so the first key is the oldest write.
51
- const oldest = cache.keys().next();
52
- if (!oldest.done) cache.delete(oldest.value);
53
- }
54
- cache.set(groupId, { value, expiresAt: Date.now() + CACHE_TTL_MS });
55
- }
56
-
57
- interface GroupOwnerResponse {
58
- success?: boolean;
59
- groupId?: string;
60
- ownerEmail?: string;
61
- memberCount?: number;
62
- deleted?: boolean;
63
- }
64
-
65
- /**
66
- * Prefers the container-network address over the public hostname.
67
- *
68
- * `GROUP_API_URL` cannot simply be pointed inward: the frontend containers
69
- * template that same variable into the `config.json` the *browser* downloads,
70
- * so a Docker-internal value there breaks every group call in the UI. One
71
- * variable genuinely has two consumers with incompatible needs, so this is the
72
- * server-side half of it.
73
- *
74
- * Falling back to the public URL keeps local dev (where only `GROUP_API_URL` is
75
- * set) and any un-migrated deployment working — it just takes the long way
76
- * round, out through the proxy and back, carrying the internal token across the
77
- * public edge.
78
- */
79
- export function internalGroupUrl(): string | undefined {
80
- return process.env.GROUP_INTERNAL_URL || process.env.GROUP_API_URL;
81
- }
82
-
83
- async function fetchGroupOwner(groupId: string): Promise<GroupOwner | null> {
84
- const baseUrl = internalGroupUrl();
85
- const token = process.env.INTERNAL_SERVICE_TOKEN;
86
- const serviceId = process.env.SERVICE_NAME || 'unknown-service';
87
-
88
- if (!baseUrl || !token) {
89
- logger.error('Group owner lookup not configured', {
90
- hasGroupApiUrl: Boolean(baseUrl),
91
- hasInternalToken: Boolean(token)
92
- });
93
- return null;
94
- }
95
-
96
- try {
97
- const response = await fetch(`${baseUrl}/internal/groups/${encodeURIComponent(groupId)}/owner`, {
98
- method: 'GET',
99
- headers: {
100
- 'Content-Type': 'application/json',
101
- 'x-internal-token': token,
102
- 'x-service-id': serviceId
103
- },
104
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
105
- });
106
-
107
- // A 404 is an answer, not an outage: the group is gone. Distinguished from
108
- // an unreachable service because the two want opposite handling — one is
109
- // permanent and cacheable, the other must be retried on the next request.
110
- if (response.status === 404) {
111
- logger.warn('Group owner lookup found no such group', { groupId });
112
- return null;
113
- }
114
-
115
- if (!response.ok) {
116
- logger.error('Group owner lookup failed', { groupId, status: response.status });
117
- return null;
118
- }
119
-
120
- const body = (await response.json()) as GroupOwnerResponse;
121
- if (!body.ownerEmail) {
122
- logger.error('Group owner lookup returned no owner', { groupId });
123
- return null;
124
- }
125
-
126
- return {
127
- groupId: body.groupId ?? groupId,
128
- ownerEmail: body.ownerEmail.trim().toLowerCase(),
129
- memberCount: body.memberCount ?? 0,
130
- deleted: body.deleted === true
131
- };
132
- } catch (err) {
133
- logger.error('Group owner lookup errored', { groupId, error: (err as Error)?.message });
134
- return null;
135
- }
136
- }
137
-
138
- /**
139
- * The group's owner, cached for {@link CACHE_TTL_MS}, or `null` when the group
140
- * does not exist or group-service could not answer.
141
- *
142
- * Nothing negative is cached — neither a missing group nor a failure. A group
143
- * that 404s is nearly always a caller passing a bad id, so caching it would
144
- * spend memory on garbage, and caching a failure would keep billing the wrong
145
- * subject for a minute after the outage ended.
146
- */
147
- export async function getGroupOwner(groupId: string): Promise<GroupOwner | null> {
148
- const key = String(groupId).trim();
149
- if (!key) return null;
150
-
151
- const cached = cache.get(key);
152
- if (cached && cached.expiresAt > Date.now()) return cached.value;
153
-
154
- const pending = inFlight.get(key);
155
- if (pending) return pending;
156
-
157
- const request = fetchGroupOwner(key)
158
- .then(value => {
159
- if (value) putInCache(key, value);
160
- return value;
161
- })
162
- .finally(() => {
163
- inFlight.delete(key);
164
- });
165
-
166
- inFlight.set(key, request);
167
- return request;
168
- }
169
-
170
- /**
171
- * Drop a group's cached owner. Call after anything that moves the `owner` role,
172
- * to collapse the window in which usage bills to the previous owner.
173
- */
174
- export function invalidateGroupOwner(groupId: string): void {
175
- cache.delete(String(groupId).trim());
176
- }
177
-
178
- /** Empties the whole cache. Test seam, and a lever after a bulk ownership edit. */
179
- export function clearGroupOwnerCache(): void {
180
- cache.clear();
181
- inFlight.clear();
182
- }
@@ -1,7 +0,0 @@
1
- export { getGroupOwner, invalidateGroupOwner, clearGroupOwnerCache } from './client';
2
- export type { GroupOwner } from './client';
3
-
4
- export { groupOwnerSubject } from './subject';
5
- export type { GroupIdResolver } from './subject';
6
-
7
- export { getUserGroupIds, currentGroupsOf, clearMembershipCache } from './membership';
@@ -1,84 +0,0 @@
1
- import { clearMembershipCache, currentGroupsOf, getUserGroupIds } from './membership';
2
- import logger from '../logging/logger';
3
-
4
- jest.mock('../logging/logger', () => ({ __esModule: true, default: { error: jest.fn(), warn: jest.fn() } }));
5
-
6
- const ORIGINAL_ENV = process.env;
7
- const answer = (data: unknown, ok = true, status = 200) =>
8
- ({ ok, status, json: async () => ({ success: ok, data }) }) as unknown as Response;
9
-
10
- beforeEach(() => {
11
- clearMembershipCache();
12
- jest.useRealTimers();
13
- process.env = {
14
- ...ORIGINAL_ENV,
15
- GROUP_INTERNAL_URL: 'http://group-service:5006',
16
- INTERNAL_SERVICE_TOKEN: 'internal-token',
17
- SERVICE_NAME: 'album-service'
18
- };
19
- global.fetch = jest.fn().mockResolvedValue(answer(['g1', 'g2']));
20
- });
21
-
22
- afterAll(() => {
23
- process.env = ORIGINAL_ENV;
24
- });
25
-
26
- it('asks group-service over the internal channel', async () => {
27
- await expect(getUserGroupIds('Ada@Example.com')).resolves.toEqual(['g1', 'g2']);
28
-
29
- expect(global.fetch).toHaveBeenCalledWith(
30
- 'http://group-service:5006/api/groups/mine/ids?userEmail=ada%40example.com',
31
- expect.objectContaining({
32
- headers: expect.objectContaining({ 'x-internal-token': 'internal-token', 'x-service-id': 'album-service' })
33
- })
34
- );
35
- });
36
-
37
- it('answers from the cache for 30 seconds, then asks again', async () => {
38
- jest.useFakeTimers();
39
-
40
- await getUserGroupIds('ada@example.com');
41
- await getUserGroupIds('ADA@example.com');
42
- expect(global.fetch).toHaveBeenCalledTimes(1);
43
-
44
- jest.advanceTimersByTime(30_001);
45
- await getUserGroupIds('ada@example.com');
46
- expect(global.fetch).toHaveBeenCalledTimes(2);
47
- });
48
-
49
- it('shares one request between callers asking at the same time', async () => {
50
- await Promise.all([getUserGroupIds('ada@example.com'), getUserGroupIds('ada@example.com')]);
51
-
52
- expect(global.fetch).toHaveBeenCalledTimes(1);
53
- });
54
-
55
- it('keeps the last known membership while group-service is unreachable', async () => {
56
- jest.useFakeTimers();
57
- await getUserGroupIds('ada@example.com');
58
- jest.advanceTimersByTime(30_001);
59
- (global.fetch as jest.Mock).mockRejectedValue(new Error('connect ECONNREFUSED'));
60
-
61
- await expect(getUserGroupIds('ada@example.com')).resolves.toEqual(['g1', 'g2']);
62
- expect(logger.warn).toHaveBeenCalled();
63
- });
64
-
65
- it('grants no groups when it has never been able to ask', async () => {
66
- (global.fetch as jest.Mock).mockResolvedValue(answer(null, false, 503));
67
-
68
- await expect(getUserGroupIds('ada@example.com')).resolves.toEqual([]);
69
- expect(logger.error).toHaveBeenCalled();
70
- });
71
-
72
- it('grants no groups when not configured, without calling anything', async () => {
73
- delete process.env.GROUP_INTERNAL_URL;
74
- delete process.env.GROUP_API_URL;
75
-
76
- await expect(getUserGroupIds('ada@example.com')).resolves.toEqual([]);
77
- expect(global.fetch).not.toHaveBeenCalled();
78
- });
79
-
80
- it('never takes the groups from the token', async () => {
81
- (global.fetch as jest.Mock).mockResolvedValue(answer(['g2']));
82
-
83
- await expect(currentGroupsOf({ email: 'ada@example.com', groups: ['g1', 'g9'] })).resolves.toEqual(['g2']);
84
- });