@oneuptime/common 12.0.29 → 12.0.30
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/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
jest,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The rate limiter in front of the public on-call calendar feed routes.
|
|
12
|
+
*
|
|
13
|
+
* Beyond "does it count", the properties that decide whether the limit can be
|
|
14
|
+
* walked around or can leak the credential it guards: that the client address
|
|
15
|
+
* is taken from the hop OUR proxy wrote rather than the one the caller can
|
|
16
|
+
* forge; that rotating tokens does not buy a fresh allowance; that the window
|
|
17
|
+
* does not slide forward under sustained load; that a Redis outage fails OPEN
|
|
18
|
+
* (this is load control, the route behind it still checks the token); and --
|
|
19
|
+
* the one that is specific to this limiter -- that the token never appears in
|
|
20
|
+
* a Redis key or a log line, in any form.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
jest.mock("../../../Server/Infrastructure/Redis", () => {
|
|
24
|
+
return {
|
|
25
|
+
__esModule: true,
|
|
26
|
+
default: {
|
|
27
|
+
getClient: jest.fn(),
|
|
28
|
+
isConnected: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
jest.mock("../../../Server/Utils/Logger", () => {
|
|
34
|
+
return {
|
|
35
|
+
__esModule: true,
|
|
36
|
+
default: {
|
|
37
|
+
debug: jest.fn(),
|
|
38
|
+
info: jest.fn(),
|
|
39
|
+
warn: jest.fn(),
|
|
40
|
+
error: jest.fn(),
|
|
41
|
+
},
|
|
42
|
+
getLogAttributesFromRequest: jest.fn().mockReturnValue({}),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
jest.mock("../../../Server/Utils/Response", () => {
|
|
47
|
+
return {
|
|
48
|
+
__esModule: true,
|
|
49
|
+
default: {
|
|
50
|
+
sendErrorResponse: jest.fn(),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
import Redis from "../../../Server/Infrastructure/Redis";
|
|
56
|
+
import Response from "../../../Server/Utils/Response";
|
|
57
|
+
import logger from "../../../Server/Utils/Logger";
|
|
58
|
+
import OnCallCalendarFeedRateLimit, {
|
|
59
|
+
ON_CALL_CALENDAR_FEED_TOKEN_PATTERN,
|
|
60
|
+
OnCallCalendarFeedRateLimitDecision,
|
|
61
|
+
OnCallCalendarFeedRateLimitOutcome,
|
|
62
|
+
OnCallCalendarFeedRateLimitScope,
|
|
63
|
+
} from "../../../Server/Middleware/OnCallCalendarFeedRateLimit";
|
|
64
|
+
import ExceptionCode from "../../../Types/Exception/ExceptionCode";
|
|
65
|
+
import Exception from "../../../Types/Exception/Exception";
|
|
66
|
+
import {
|
|
67
|
+
ExpressRequest,
|
|
68
|
+
ExpressResponse,
|
|
69
|
+
NextFunction,
|
|
70
|
+
} from "../../../Server/Utils/Express";
|
|
71
|
+
import { createHash, randomBytes } from "crypto";
|
|
72
|
+
|
|
73
|
+
type MockedFn = ReturnType<typeof jest.fn>;
|
|
74
|
+
|
|
75
|
+
const getClientMock: MockedFn = Redis.getClient as unknown as MockedFn;
|
|
76
|
+
const isConnectedMock: MockedFn = Redis.isConnected as unknown as MockedFn;
|
|
77
|
+
const sendErrorResponseMock: MockedFn =
|
|
78
|
+
Response.sendErrorResponse as unknown as MockedFn;
|
|
79
|
+
const loggerWarnMock: MockedFn = logger.warn as unknown as MockedFn;
|
|
80
|
+
const loggerErrorMock: MockedFn = logger.error as unknown as MockedFn;
|
|
81
|
+
const loggerInfoMock: MockedFn = logger.info as unknown as MockedFn;
|
|
82
|
+
const loggerDebugMock: MockedFn = logger.debug as unknown as MockedFn;
|
|
83
|
+
|
|
84
|
+
/* Defaults baked into EnvironmentConfig; asserted directly in one test. */
|
|
85
|
+
const WINDOW_SECONDS: number = 60;
|
|
86
|
+
const PER_TOKEN_LIMIT: number = 60;
|
|
87
|
+
const PER_IP_LIMIT: number = 3000;
|
|
88
|
+
|
|
89
|
+
/* A token exactly as the feed routes mint them: 32 random bytes, base64url. */
|
|
90
|
+
function mintToken(): string {
|
|
91
|
+
return randomBytes(32).toString("base64url");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const TOKEN_A: string = mintToken();
|
|
95
|
+
const TOKEN_B: string = mintToken();
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* A fake that behaves like a Redis counter store rather than a bag of
|
|
99
|
+
* assertions: INCR really increments and EXPIRE really records a TTL, so the
|
|
100
|
+
* window/reset/pinning tests exercise the limiter's arithmetic instead of
|
|
101
|
+
* restating it.
|
|
102
|
+
*/
|
|
103
|
+
interface RecordedExpire {
|
|
104
|
+
key: string;
|
|
105
|
+
ttlSeconds: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class FakeRedisClient {
|
|
109
|
+
public counters: Map<string, number> = new Map();
|
|
110
|
+
public expires: Array<RecordedExpire> = [];
|
|
111
|
+
public failNextExec: Error | null = null;
|
|
112
|
+
public malformedExecResult: unknown | undefined = undefined;
|
|
113
|
+
public incrCallCount: number = 0;
|
|
114
|
+
|
|
115
|
+
public pipeline(): FakePipeline {
|
|
116
|
+
return new FakePipeline(this);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public keys(): Array<string> {
|
|
120
|
+
return Array.from(this.counters.keys());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public expiresForKey(key: string): Array<RecordedExpire> {
|
|
124
|
+
return this.expires.filter((recorded: RecordedExpire) => {
|
|
125
|
+
return recorded.key === key;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type QueuedCommand = () => [Error | null, unknown];
|
|
131
|
+
|
|
132
|
+
class FakePipeline {
|
|
133
|
+
private commands: Array<QueuedCommand> = [];
|
|
134
|
+
|
|
135
|
+
public constructor(private client: FakeRedisClient) {}
|
|
136
|
+
|
|
137
|
+
public incr(key: string): FakePipeline {
|
|
138
|
+
this.commands.push((): [Error | null, unknown] => {
|
|
139
|
+
this.client.incrCallCount++;
|
|
140
|
+
const next: number = (this.client.counters.get(key) || 0) + 1;
|
|
141
|
+
this.client.counters.set(key, next);
|
|
142
|
+
return [null, next];
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public expire(key: string, ttlSeconds: number): FakePipeline {
|
|
149
|
+
this.commands.push((): [Error | null, unknown] => {
|
|
150
|
+
this.client.expires.push({ key, ttlSeconds });
|
|
151
|
+
return [null, 1];
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public async exec(): Promise<unknown> {
|
|
158
|
+
if (this.client.failNextExec) {
|
|
159
|
+
const error: Error = this.client.failNextExec;
|
|
160
|
+
this.client.failNextExec = null;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof this.client.malformedExecResult !== "undefined") {
|
|
165
|
+
const result: unknown = this.client.malformedExecResult;
|
|
166
|
+
this.client.malformedExecResult = undefined;
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this.commands.map((command: QueuedCommand) => {
|
|
171
|
+
return command();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface BuildRequestOverrides {
|
|
177
|
+
token?: string | undefined;
|
|
178
|
+
params?: Record<string, string>;
|
|
179
|
+
headers?: Record<string, string | Array<string>>;
|
|
180
|
+
socketAddress?: string | undefined;
|
|
181
|
+
ip?: string | undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const buildRequest: (overrides?: BuildRequestOverrides) => ExpressRequest = (
|
|
185
|
+
overrides: BuildRequestOverrides = {},
|
|
186
|
+
) => {
|
|
187
|
+
const params: Record<string, string> = { ...(overrides.params || {}) };
|
|
188
|
+
|
|
189
|
+
if (overrides.token !== undefined) {
|
|
190
|
+
params["token"] = overrides.token;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
params,
|
|
195
|
+
headers: overrides.headers || {},
|
|
196
|
+
socket: { remoteAddress: overrides.socketAddress },
|
|
197
|
+
ip: overrides.ip,
|
|
198
|
+
} as unknown as ExpressRequest;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
interface BuiltResponse {
|
|
202
|
+
response: ExpressResponse;
|
|
203
|
+
headers: Record<string, string>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const buildResponse: () => BuiltResponse = () => {
|
|
207
|
+
const headers: Record<string, string> = {};
|
|
208
|
+
|
|
209
|
+
const response: ExpressResponse = {
|
|
210
|
+
setHeader: (name: string, value: string): void => {
|
|
211
|
+
headers[name] = value;
|
|
212
|
+
},
|
|
213
|
+
} as unknown as ExpressResponse;
|
|
214
|
+
|
|
215
|
+
return { response, headers };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/* Every string that reached any logger method, flattened. */
|
|
219
|
+
function everythingLogged(): string {
|
|
220
|
+
const mocks: Array<MockedFn> = [
|
|
221
|
+
loggerWarnMock,
|
|
222
|
+
loggerErrorMock,
|
|
223
|
+
loggerInfoMock,
|
|
224
|
+
loggerDebugMock,
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
return mocks
|
|
228
|
+
.flatMap((mock: MockedFn) => {
|
|
229
|
+
return mock.mock.calls.flat();
|
|
230
|
+
})
|
|
231
|
+
.map((value: unknown) => {
|
|
232
|
+
if (value instanceof Error) {
|
|
233
|
+
return `${value.message} ${value.stack || ""}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (typeof value === "string") {
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return JSON.stringify(value);
|
|
241
|
+
})
|
|
242
|
+
.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
describe("OnCallCalendarFeedRateLimit", () => {
|
|
246
|
+
let client: FakeRedisClient;
|
|
247
|
+
let nowSpy: ReturnType<typeof jest.spyOn>;
|
|
248
|
+
let currentTime: number;
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
jest.clearAllMocks();
|
|
252
|
+
|
|
253
|
+
client = new FakeRedisClient();
|
|
254
|
+
getClientMock.mockReturnValue(client);
|
|
255
|
+
isConnectedMock.mockReturnValue(true);
|
|
256
|
+
|
|
257
|
+
currentTime = 1_700_000_000_000;
|
|
258
|
+
currentTime = currentTime - (currentTime % (WINDOW_SECONDS * 1000));
|
|
259
|
+
nowSpy = jest.spyOn(Date, "now").mockImplementation(() => {
|
|
260
|
+
return currentTime;
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
afterEach(() => {
|
|
265
|
+
nowSpy.mockRestore();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const consume: (data: {
|
|
269
|
+
tokenKey?: string;
|
|
270
|
+
clientIp?: string;
|
|
271
|
+
}) => Promise<OnCallCalendarFeedRateLimitDecision> = (data: {
|
|
272
|
+
tokenKey?: string;
|
|
273
|
+
clientIp?: string;
|
|
274
|
+
}) => {
|
|
275
|
+
return OnCallCalendarFeedRateLimit.consume({
|
|
276
|
+
tokenKey: data.tokenKey || OnCallCalendarFeedRateLimit.hashToken(TOKEN_A),
|
|
277
|
+
clientIp: data.clientIp || "203.0.113.7",
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
describe("configuration", () => {
|
|
282
|
+
it("ships the documented defaults", () => {
|
|
283
|
+
expect(OnCallCalendarFeedRateLimit.getConfig()).toEqual({
|
|
284
|
+
windowSeconds: WINDOW_SECONDS,
|
|
285
|
+
perTokenLimit: PER_TOKEN_LIMIT,
|
|
286
|
+
perIpLimit: PER_IP_LIMIT,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("hands back a copy, so a caller cannot mutate the limits", () => {
|
|
291
|
+
const config: { perTokenLimit: number } =
|
|
292
|
+
OnCallCalendarFeedRateLimit.getConfig();
|
|
293
|
+
|
|
294
|
+
config.perTokenLimit = 1;
|
|
295
|
+
|
|
296
|
+
expect(OnCallCalendarFeedRateLimit.getConfig().perTokenLimit).toBe(
|
|
297
|
+
PER_TOKEN_LIMIT,
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("accepts exactly the 43-character base64url token shape", () => {
|
|
302
|
+
expect(ON_CALL_CALENDAR_FEED_TOKEN_PATTERN.test(TOKEN_A)).toBe(true);
|
|
303
|
+
expect(TOKEN_A).toHaveLength(43);
|
|
304
|
+
expect(ON_CALL_CALENDAR_FEED_TOKEN_PATTERN.test(`${TOKEN_A}=`)).toBe(
|
|
305
|
+
false,
|
|
306
|
+
);
|
|
307
|
+
expect(ON_CALL_CALENDAR_FEED_TOKEN_PATTERN.test(TOKEN_A.slice(1))).toBe(
|
|
308
|
+
false,
|
|
309
|
+
);
|
|
310
|
+
expect(
|
|
311
|
+
ON_CALL_CALENDAR_FEED_TOKEN_PATTERN.test(
|
|
312
|
+
`${TOKEN_A.slice(0, 42)}+`.replace("+", "/"),
|
|
313
|
+
),
|
|
314
|
+
).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("resolveClientIp", () => {
|
|
319
|
+
it("bills the hop our own proxy appended, not the one the caller forged", () => {
|
|
320
|
+
const request: ExpressRequest = buildRequest({
|
|
321
|
+
headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7" },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(request)).toBe(
|
|
325
|
+
"203.0.113.7",
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("is unaffected by however many entries the caller forges", () => {
|
|
330
|
+
const forged: string = Array.from(
|
|
331
|
+
{ length: 50 },
|
|
332
|
+
(_unused: unknown, index: number) => {
|
|
333
|
+
return `10.0.0.${index}`;
|
|
334
|
+
},
|
|
335
|
+
).join(", ");
|
|
336
|
+
|
|
337
|
+
const request: ExpressRequest = buildRequest({
|
|
338
|
+
headers: { "x-forwarded-for": `${forged}, 203.0.113.7` },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(request)).toBe(
|
|
342
|
+
"203.0.113.7",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("refuses an empty trusted position rather than walking left onto caller data", () => {
|
|
347
|
+
const request: ExpressRequest = buildRequest({
|
|
348
|
+
headers: { "x-forwarded-for": "9.9.9.9, , 203.0.113.7 , " },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(request)).toBe(
|
|
352
|
+
"unknown",
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("falls back to the socket address with no forwarding header", () => {
|
|
357
|
+
const request: ExpressRequest = buildRequest({
|
|
358
|
+
socketAddress: "198.51.100.4",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(request)).toBe(
|
|
362
|
+
"198.51.100.4",
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("falls back to req.ip when there is no socket address", () => {
|
|
367
|
+
const request: ExpressRequest = buildRequest({ ip: "198.51.100.9" });
|
|
368
|
+
|
|
369
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(request)).toBe(
|
|
370
|
+
"198.51.100.9",
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("puts wholly unidentifiable callers in one shared bucket", () => {
|
|
375
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(buildRequest())).toBe(
|
|
376
|
+
"unknown",
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("preserves IPv6 addresses", () => {
|
|
381
|
+
const request: ExpressRequest = buildRequest({
|
|
382
|
+
headers: { "x-forwarded-for": "2001:db8::8a2e:370:7334" },
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(OnCallCalendarFeedRateLimit.resolveClientIp(request)).toBe(
|
|
386
|
+
"2001:db8::8a2e:370:7334",
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("truncates an over-long address so it cannot bloat a Redis key", () => {
|
|
391
|
+
const request: ExpressRequest = buildRequest({
|
|
392
|
+
headers: { "x-forwarded-for": "a".repeat(5000) },
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(
|
|
396
|
+
OnCallCalendarFeedRateLimit.resolveClientIp(request).length,
|
|
397
|
+
).toBeLessThanOrEqual(64);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("resolveTokenKey", () => {
|
|
402
|
+
it("never contains the token itself", () => {
|
|
403
|
+
const key: string = OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
404
|
+
buildRequest({ token: TOKEN_A }),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
expect(key).not.toContain(TOKEN_A);
|
|
408
|
+
/* Nor any substantial substring of it. */
|
|
409
|
+
expect(key).not.toContain(TOKEN_A.slice(0, 8));
|
|
410
|
+
expect(key).not.toContain(TOKEN_A.slice(-8));
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("is a truncated SHA-256 of the token", () => {
|
|
414
|
+
const expected: string = createHash("sha256")
|
|
415
|
+
.update(TOKEN_A, "utf8")
|
|
416
|
+
.digest("hex")
|
|
417
|
+
.slice(0, 16);
|
|
418
|
+
|
|
419
|
+
expect(
|
|
420
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
421
|
+
buildRequest({ token: TOKEN_A }),
|
|
422
|
+
),
|
|
423
|
+
).toBe(`t:${expected}`);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("is stable for the same token and distinct for different tokens", () => {
|
|
427
|
+
const first: string = OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
428
|
+
buildRequest({ token: TOKEN_A }),
|
|
429
|
+
);
|
|
430
|
+
const again: string = OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
431
|
+
buildRequest({ token: TOKEN_A }),
|
|
432
|
+
);
|
|
433
|
+
const other: string = OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
434
|
+
buildRequest({ token: TOKEN_B }),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
expect(first).toBe(again);
|
|
438
|
+
expect(first).not.toBe(other);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("agrees with hashToken, so a route holding the token builds the same key", () => {
|
|
442
|
+
expect(
|
|
443
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
444
|
+
buildRequest({ token: TOKEN_A }),
|
|
445
|
+
),
|
|
446
|
+
).toBe(OnCallCalendarFeedRateLimit.hashToken(TOKEN_A));
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("ignores surrounding whitespace", () => {
|
|
450
|
+
expect(
|
|
451
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
452
|
+
buildRequest({ token: ` ${TOKEN_A} ` }),
|
|
453
|
+
),
|
|
454
|
+
).toBe(OnCallCalendarFeedRateLimit.hashToken(TOKEN_A));
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("collapses anything not shaped like a token to one shared bucket", () => {
|
|
458
|
+
for (const junk of [
|
|
459
|
+
"abc",
|
|
460
|
+
TOKEN_A.slice(1),
|
|
461
|
+
`${TOKEN_A}x`,
|
|
462
|
+
"a".repeat(43).replace("a", "+"),
|
|
463
|
+
"../../etc/passwd",
|
|
464
|
+
"x".repeat(5000),
|
|
465
|
+
]) {
|
|
466
|
+
expect(
|
|
467
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
468
|
+
buildRequest({ token: junk }),
|
|
469
|
+
),
|
|
470
|
+
).toBe("invalid");
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("uses a stable key when no token is present at all", () => {
|
|
475
|
+
expect(OnCallCalendarFeedRateLimit.resolveTokenKey(buildRequest())).toBe(
|
|
476
|
+
"none",
|
|
477
|
+
);
|
|
478
|
+
expect(
|
|
479
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
480
|
+
buildRequest({ token: "" }),
|
|
481
|
+
),
|
|
482
|
+
).toBe("none");
|
|
483
|
+
expect(
|
|
484
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey(
|
|
485
|
+
buildRequest({ token: " " }),
|
|
486
|
+
),
|
|
487
|
+
).toBe("none");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("does not throw when params is missing entirely", () => {
|
|
491
|
+
expect(
|
|
492
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey({
|
|
493
|
+
headers: {},
|
|
494
|
+
} as unknown as ExpressRequest),
|
|
495
|
+
).toBe("none");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("ignores a non-string token parameter", () => {
|
|
499
|
+
expect(
|
|
500
|
+
OnCallCalendarFeedRateLimit.resolveTokenKey({
|
|
501
|
+
params: { token: 42 },
|
|
502
|
+
headers: {},
|
|
503
|
+
} as unknown as ExpressRequest),
|
|
504
|
+
).toBe("none");
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("consume - per token budget", () => {
|
|
509
|
+
it("allows requests up to the limit", async () => {
|
|
510
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
511
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({});
|
|
512
|
+
|
|
513
|
+
expect(decision.outcome).toBe(
|
|
514
|
+
OnCallCalendarFeedRateLimitOutcome.Allowed,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("rejects the request after the limit with a token scope", async () => {
|
|
520
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
521
|
+
await consume({});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({});
|
|
525
|
+
|
|
526
|
+
expect(decision.outcome).toBe(
|
|
527
|
+
OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
528
|
+
);
|
|
529
|
+
expect(decision.scope).toBe(OnCallCalendarFeedRateLimitScope.Token);
|
|
530
|
+
expect(decision.isFirstRejectionInWindow).toBe(true);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("marks only the crossing request as the first rejection", async () => {
|
|
534
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
535
|
+
await consume({});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({});
|
|
539
|
+
|
|
540
|
+
expect(decision.outcome).toBe(
|
|
541
|
+
OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
542
|
+
);
|
|
543
|
+
expect(decision.isFirstRejectionInWindow).toBe(false);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("keeps separate budgets for separate client addresses", async () => {
|
|
547
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
548
|
+
await consume({ clientIp: "203.0.113.7" });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({
|
|
552
|
+
clientIp: "203.0.113.8",
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(decision.outcome).toBe(OnCallCalendarFeedRateLimitOutcome.Allowed);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("keeps separate budgets for separate tokens on one address", async () => {
|
|
559
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
560
|
+
await consume({
|
|
561
|
+
tokenKey: OnCallCalendarFeedRateLimit.hashToken(TOKEN_A),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({
|
|
566
|
+
tokenKey: OnCallCalendarFeedRateLimit.hashToken(TOKEN_B),
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(decision.outcome).toBe(OnCallCalendarFeedRateLimitOutcome.Allowed);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("gives every junk token one small shared budget", async () => {
|
|
573
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
574
|
+
await consume({ tokenKey: "invalid" });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({
|
|
578
|
+
tokenKey: "invalid",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(decision.outcome).toBe(
|
|
582
|
+
OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe("consume - per address ceiling", () => {
|
|
588
|
+
/*
|
|
589
|
+
* The token counter is trivially bypassed by rotating tokens; the address
|
|
590
|
+
* counter is the ceiling that survives it. Every guess at a token costs a
|
|
591
|
+
* Postgres lookup, so guessing must be bounded per address regardless of
|
|
592
|
+
* how many distinct guesses are made.
|
|
593
|
+
*/
|
|
594
|
+
it("stops an attacker who rotates the token on every request", async () => {
|
|
595
|
+
let rejected: OnCallCalendarFeedRateLimitDecision | null = null;
|
|
596
|
+
|
|
597
|
+
for (let i: number = 0; i < PER_IP_LIMIT + 1; i++) {
|
|
598
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({
|
|
599
|
+
tokenKey: OnCallCalendarFeedRateLimit.hashToken(mintToken()),
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
if (
|
|
603
|
+
decision.outcome === OnCallCalendarFeedRateLimitOutcome.RateLimited
|
|
604
|
+
) {
|
|
605
|
+
rejected = decision;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
expect(rejected).not.toBeNull();
|
|
611
|
+
expect(rejected?.scope).toBe(OnCallCalendarFeedRateLimitScope.Ip);
|
|
612
|
+
expect(rejected?.isFirstRejectionInWindow).toBe(true);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("does not let one address's ceiling affect another", async () => {
|
|
616
|
+
for (let i: number = 0; i < PER_IP_LIMIT; i++) {
|
|
617
|
+
await consume({
|
|
618
|
+
tokenKey: OnCallCalendarFeedRateLimit.hashToken(mintToken()),
|
|
619
|
+
clientIp: "203.0.113.7",
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({
|
|
624
|
+
clientIp: "203.0.113.8",
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
expect(decision.outcome).toBe(OnCallCalendarFeedRateLimitOutcome.Allowed);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("reports the token scope first when both counters are over", async () => {
|
|
631
|
+
for (let i: number = 0; i < PER_IP_LIMIT; i++) {
|
|
632
|
+
await consume({});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({});
|
|
636
|
+
|
|
637
|
+
expect(decision.outcome).toBe(
|
|
638
|
+
OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
639
|
+
);
|
|
640
|
+
expect(decision.scope).toBe(OnCallCalendarFeedRateLimitScope.Token);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe("consume - Redis keys", () => {
|
|
645
|
+
it("never writes the token into a Redis key", async () => {
|
|
646
|
+
await OnCallCalendarFeedRateLimit.getMiddleware()(
|
|
647
|
+
buildRequest({
|
|
648
|
+
token: TOKEN_A,
|
|
649
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
650
|
+
}),
|
|
651
|
+
buildResponse().response,
|
|
652
|
+
(() => {}) as unknown as NextFunction,
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
expect(client.keys().length).toBeGreaterThan(0);
|
|
656
|
+
|
|
657
|
+
for (const key of client.keys()) {
|
|
658
|
+
expect(key).not.toContain(TOKEN_A);
|
|
659
|
+
expect(key).not.toContain(TOKEN_A.slice(0, 8));
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("namespaces its keys away from the other limiters", async () => {
|
|
664
|
+
await consume({});
|
|
665
|
+
|
|
666
|
+
for (const key of client.keys()) {
|
|
667
|
+
expect(key.startsWith("oncal:rl:")).toBe(true);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("writes exactly one token counter and one address counter per request", async () => {
|
|
672
|
+
await consume({});
|
|
673
|
+
|
|
674
|
+
expect(client.keys()).toHaveLength(2);
|
|
675
|
+
expect(
|
|
676
|
+
client.keys().filter((key: string) => {
|
|
677
|
+
return key.startsWith("oncal:rl:t:");
|
|
678
|
+
}),
|
|
679
|
+
).toHaveLength(1);
|
|
680
|
+
expect(
|
|
681
|
+
client.keys().filter((key: string) => {
|
|
682
|
+
return key.startsWith("oncal:rl:i:");
|
|
683
|
+
}),
|
|
684
|
+
).toHaveLength(1);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
describe("consume - window behaviour", () => {
|
|
689
|
+
it("sets a TTL only on the request that created the counter", async () => {
|
|
690
|
+
await consume({});
|
|
691
|
+
|
|
692
|
+
expect(client.expires).toHaveLength(2);
|
|
693
|
+
|
|
694
|
+
await consume({});
|
|
695
|
+
|
|
696
|
+
expect(client.expires).toHaveLength(2);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("gives the counter a TTL longer than its own window", async () => {
|
|
700
|
+
await consume({});
|
|
701
|
+
|
|
702
|
+
for (const recorded of client.expires) {
|
|
703
|
+
expect(recorded.ttlSeconds).toBeGreaterThan(WINDOW_SECONDS);
|
|
704
|
+
expect(recorded.ttlSeconds).toBe(WINDOW_SECONDS * 2);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("starts a fresh allowance when the window rolls", async () => {
|
|
709
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
710
|
+
await consume({});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
expect((await consume({})).outcome).toBe(
|
|
714
|
+
OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
currentTime = currentTime + WINDOW_SECONDS * 1000;
|
|
718
|
+
|
|
719
|
+
expect((await consume({})).outcome).toBe(
|
|
720
|
+
OnCallCalendarFeedRateLimitOutcome.Allowed,
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("does not roll early within the same window", async () => {
|
|
725
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
726
|
+
await consume({});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
currentTime = currentTime + WINDOW_SECONDS * 1000 - 1;
|
|
730
|
+
|
|
731
|
+
expect((await consume({})).outcome).toBe(
|
|
732
|
+
OnCallCalendarFeedRateLimitOutcome.RateLimited,
|
|
733
|
+
);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("keeps the window pinned by counting rejected requests too", async () => {
|
|
737
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 5; i++) {
|
|
738
|
+
await consume({});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/* Both counters incremented on every one of them, refused or not. */
|
|
742
|
+
expect(client.incrCallCount).toBe((PER_TOKEN_LIMIT + 5) * 2);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("reports a Retry-After inside the current window", async () => {
|
|
746
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
747
|
+
await consume({});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({});
|
|
751
|
+
|
|
752
|
+
expect(decision.retryAfterSeconds).toBeGreaterThan(0);
|
|
753
|
+
expect(decision.retryAfterSeconds).toBeLessThanOrEqual(WINDOW_SECONDS);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("shrinks Retry-After as the window drains", async () => {
|
|
757
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
758
|
+
await consume({});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const atStart: number = (await consume({})).retryAfterSeconds || 0;
|
|
762
|
+
|
|
763
|
+
currentTime = currentTime + 45 * 1000;
|
|
764
|
+
|
|
765
|
+
const later: number = (await consume({})).retryAfterSeconds || 0;
|
|
766
|
+
|
|
767
|
+
expect(later).toBeLessThan(atStart);
|
|
768
|
+
expect(later).toBe(15);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("never reports a Retry-After of zero at the very end of a window", async () => {
|
|
772
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
773
|
+
await consume({});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
currentTime = currentTime + WINDOW_SECONDS * 1000 - 1;
|
|
777
|
+
|
|
778
|
+
const decision: OnCallCalendarFeedRateLimitDecision = await consume({});
|
|
779
|
+
|
|
780
|
+
expect(decision.retryAfterSeconds).toBeGreaterThanOrEqual(1);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
describe("consume - counter unavailable", () => {
|
|
785
|
+
it("reports unavailable when Redis has no client", async () => {
|
|
786
|
+
getClientMock.mockReturnValue(null);
|
|
787
|
+
|
|
788
|
+
expect((await consume({})).outcome).toBe(
|
|
789
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("reports unavailable when Redis is not connected", async () => {
|
|
794
|
+
isConnectedMock.mockReturnValue(false);
|
|
795
|
+
|
|
796
|
+
expect((await consume({})).outcome).toBe(
|
|
797
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
798
|
+
);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("reports unavailable when the pipeline throws", async () => {
|
|
802
|
+
client.failNextExec = new Error("connection reset");
|
|
803
|
+
|
|
804
|
+
expect((await consume({})).outcome).toBe(
|
|
805
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("reports unavailable when the pipeline returns null", async () => {
|
|
810
|
+
client.malformedExecResult = null;
|
|
811
|
+
|
|
812
|
+
expect((await consume({})).outcome).toBe(
|
|
813
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
814
|
+
);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("reports unavailable when the pipeline returns too few results", async () => {
|
|
818
|
+
client.malformedExecResult = [[null, 1]];
|
|
819
|
+
|
|
820
|
+
expect((await consume({})).outcome).toBe(
|
|
821
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
822
|
+
);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("reports unavailable when a command inside the pipeline failed", async () => {
|
|
826
|
+
client.malformedExecResult = [
|
|
827
|
+
[new Error("OOM"), null],
|
|
828
|
+
[null, 1],
|
|
829
|
+
];
|
|
830
|
+
|
|
831
|
+
expect((await consume({})).outcome).toBe(
|
|
832
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
833
|
+
);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("reports unavailable when a counter comes back non-numeric", async () => {
|
|
837
|
+
client.malformedExecResult = [
|
|
838
|
+
[null, "1"],
|
|
839
|
+
[null, 1],
|
|
840
|
+
];
|
|
841
|
+
|
|
842
|
+
expect((await consume({})).outcome).toBe(
|
|
843
|
+
OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("does not throw out of consume on any failure", async () => {
|
|
848
|
+
client.failNextExec = new Error("boom");
|
|
849
|
+
|
|
850
|
+
await expect(consume({})).resolves.toBeDefined();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("does not name the token key when logging a counter failure", async () => {
|
|
854
|
+
client.failNextExec = new Error("connection reset");
|
|
855
|
+
|
|
856
|
+
const tokenKey: string = OnCallCalendarFeedRateLimit.hashToken(TOKEN_A);
|
|
857
|
+
|
|
858
|
+
await consume({ tokenKey });
|
|
859
|
+
|
|
860
|
+
const logged: string = everythingLogged();
|
|
861
|
+
|
|
862
|
+
expect(logged).not.toContain(TOKEN_A);
|
|
863
|
+
expect(logged).not.toContain(tokenKey);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe("getMiddleware", () => {
|
|
868
|
+
const runMiddleware: (request?: ExpressRequest) => Promise<{
|
|
869
|
+
nextCalled: boolean;
|
|
870
|
+
headers: Record<string, string>;
|
|
871
|
+
}> = async (request?: ExpressRequest) => {
|
|
872
|
+
const { response, headers } = buildResponse();
|
|
873
|
+
let nextCalled: boolean = false;
|
|
874
|
+
|
|
875
|
+
const next: NextFunction = (() => {
|
|
876
|
+
nextCalled = true;
|
|
877
|
+
}) as unknown as NextFunction;
|
|
878
|
+
|
|
879
|
+
await OnCallCalendarFeedRateLimit.getMiddleware()(
|
|
880
|
+
request ||
|
|
881
|
+
buildRequest({
|
|
882
|
+
token: TOKEN_A,
|
|
883
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
884
|
+
}),
|
|
885
|
+
response,
|
|
886
|
+
next,
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
return { nextCalled, headers };
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
it("passes an allowed request through", async () => {
|
|
893
|
+
const result: { nextCalled: boolean } = await runMiddleware();
|
|
894
|
+
|
|
895
|
+
expect(result.nextCalled).toBe(true);
|
|
896
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("stops a refused request from reaching the route handler", async () => {
|
|
900
|
+
const request: ExpressRequest = buildRequest({
|
|
901
|
+
token: TOKEN_A,
|
|
902
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
906
|
+
await runMiddleware(request);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
sendErrorResponseMock.mockClear();
|
|
910
|
+
|
|
911
|
+
const result: { nextCalled: boolean } = await runMiddleware(request);
|
|
912
|
+
|
|
913
|
+
expect(result.nextCalled).toBe(false);
|
|
914
|
+
expect(sendErrorResponseMock).toHaveBeenCalledTimes(1);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("answers a refusal with 429", async () => {
|
|
918
|
+
const request: ExpressRequest = buildRequest({
|
|
919
|
+
token: TOKEN_A,
|
|
920
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
924
|
+
await runMiddleware(request);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const error: Exception = sendErrorResponseMock.mock
|
|
928
|
+
.calls[0]?.[2] as Exception;
|
|
929
|
+
|
|
930
|
+
expect(error.code).toBe(ExceptionCode.TooManyRequestsException);
|
|
931
|
+
expect(error.code).toBe(429);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("sets Retry-After on a refusal", async () => {
|
|
935
|
+
const request: ExpressRequest = buildRequest({
|
|
936
|
+
token: TOKEN_A,
|
|
937
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
let headers: Record<string, string> = {};
|
|
941
|
+
|
|
942
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
943
|
+
headers = (await runMiddleware(request)).headers;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
expect(Number(headers["Retry-After"])).toBeGreaterThan(0);
|
|
947
|
+
expect(Number(headers["Retry-After"])).toBeLessThanOrEqual(
|
|
948
|
+
WINDOW_SECONDS,
|
|
949
|
+
);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("does not leak the limit, the counter or the token in the message", async () => {
|
|
953
|
+
const request: ExpressRequest = buildRequest({
|
|
954
|
+
token: TOKEN_A,
|
|
955
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
959
|
+
await runMiddleware(request);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const error: Exception = sendErrorResponseMock.mock
|
|
963
|
+
.calls[0]?.[2] as Exception;
|
|
964
|
+
|
|
965
|
+
expect(error.message).not.toMatch(/\d/);
|
|
966
|
+
expect(error.message).not.toContain(TOKEN_A);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("survives a response object with no setHeader", async () => {
|
|
970
|
+
const request: ExpressRequest = buildRequest({
|
|
971
|
+
token: TOKEN_A,
|
|
972
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
const middleware: (
|
|
976
|
+
req: ExpressRequest,
|
|
977
|
+
res: ExpressResponse,
|
|
978
|
+
next: NextFunction,
|
|
979
|
+
) => Promise<void> = OnCallCalendarFeedRateLimit.getMiddleware();
|
|
980
|
+
|
|
981
|
+
const bareResponse: ExpressResponse = {} as unknown as ExpressResponse;
|
|
982
|
+
|
|
983
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 2; i++) {
|
|
984
|
+
await expect(
|
|
985
|
+
middleware(request, bareResponse, (() => {}) as NextFunction),
|
|
986
|
+
).resolves.not.toThrow();
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
/*
|
|
991
|
+
* FAILS OPEN. The limiter is load control; the route behind it still
|
|
992
|
+
* checks the token. A Redis blip must not black out every subscribed
|
|
993
|
+
* calendar.
|
|
994
|
+
*/
|
|
995
|
+
it("serves feeds unthrottled rather than failing when Redis is gone", async () => {
|
|
996
|
+
isConnectedMock.mockReturnValue(false);
|
|
997
|
+
|
|
998
|
+
const result: { nextCalled: boolean } = await runMiddleware();
|
|
999
|
+
|
|
1000
|
+
expect(result.nextCalled).toBe(true);
|
|
1001
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("serves feeds unthrottled when there is no Redis client at all", async () => {
|
|
1005
|
+
getClientMock.mockReturnValue(null);
|
|
1006
|
+
|
|
1007
|
+
const result: { nextCalled: boolean } = await runMiddleware();
|
|
1008
|
+
|
|
1009
|
+
expect(result.nextCalled).toBe(true);
|
|
1010
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it("still serves feeds when the counter errors", async () => {
|
|
1014
|
+
client.failNextExec = new Error("connection reset");
|
|
1015
|
+
|
|
1016
|
+
const result: { nextCalled: boolean } = await runMiddleware();
|
|
1017
|
+
|
|
1018
|
+
expect(result.nextCalled).toBe(true);
|
|
1019
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("never answers 503 for an outage", async () => {
|
|
1023
|
+
isConnectedMock.mockReturnValue(false);
|
|
1024
|
+
|
|
1025
|
+
for (let i: number = 0; i < 50; i++) {
|
|
1026
|
+
await runMiddleware();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
1030
|
+
expect(loggerErrorMock).not.toHaveBeenCalled();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("logs the moment a caller crosses the line, then stays quiet", async () => {
|
|
1034
|
+
const request: ExpressRequest = buildRequest({
|
|
1035
|
+
token: TOKEN_A,
|
|
1036
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
1040
|
+
await runMiddleware(request);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
loggerWarnMock.mockClear();
|
|
1044
|
+
|
|
1045
|
+
await runMiddleware(request);
|
|
1046
|
+
|
|
1047
|
+
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
|
1048
|
+
|
|
1049
|
+
for (let i: number = 0; i < 200; i++) {
|
|
1050
|
+
await runMiddleware(request);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("logs the crossing again once the window rolls", async () => {
|
|
1057
|
+
const request: ExpressRequest = buildRequest({
|
|
1058
|
+
token: TOKEN_A,
|
|
1059
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 5; i++) {
|
|
1063
|
+
await runMiddleware(request);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
currentTime = currentTime + WINDOW_SECONDS * 1000;
|
|
1067
|
+
loggerWarnMock.mockClear();
|
|
1068
|
+
|
|
1069
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 5; i++) {
|
|
1070
|
+
await runMiddleware(request);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("names the address and the scope in the crossing log, and nothing about the token", async () => {
|
|
1077
|
+
const request: ExpressRequest = buildRequest({
|
|
1078
|
+
token: TOKEN_A,
|
|
1079
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 1; i++) {
|
|
1083
|
+
await runMiddleware(request);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const line: string = String(loggerWarnMock.mock.calls[0]?.[0]);
|
|
1087
|
+
|
|
1088
|
+
expect(line).toContain("203.0.113.7");
|
|
1089
|
+
expect(line).toContain("token limit");
|
|
1090
|
+
expect(line).not.toContain(TOKEN_A);
|
|
1091
|
+
expect(line).not.toContain(
|
|
1092
|
+
OnCallCalendarFeedRateLimit.hashToken(TOKEN_A),
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it("does not log once per request while Redis is down", async () => {
|
|
1097
|
+
isConnectedMock.mockReturnValue(false);
|
|
1098
|
+
loggerWarnMock.mockClear();
|
|
1099
|
+
|
|
1100
|
+
for (let i: number = 0; i < 100; i++) {
|
|
1101
|
+
await runMiddleware();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/* One message plus its attributes line, not 200. */
|
|
1105
|
+
expect(loggerWarnMock.mock.calls.length).toBeLessThanOrEqual(2);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("logs the outage again after the throttle interval", async () => {
|
|
1109
|
+
isConnectedMock.mockReturnValue(false);
|
|
1110
|
+
|
|
1111
|
+
await runMiddleware();
|
|
1112
|
+
|
|
1113
|
+
currentTime = currentTime + 60_000;
|
|
1114
|
+
loggerWarnMock.mockClear();
|
|
1115
|
+
|
|
1116
|
+
await runMiddleware();
|
|
1117
|
+
|
|
1118
|
+
expect(loggerWarnMock.mock.calls.length).toBeGreaterThan(0);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
/*
|
|
1122
|
+
* The property that is specific to this limiter: it sits on a URL whose
|
|
1123
|
+
* path segment IS the credential, and every log line it could ever emit
|
|
1124
|
+
* -- crossing, outage, counter failure -- is exercised here with the
|
|
1125
|
+
* token in the request. None of them may contain it.
|
|
1126
|
+
*/
|
|
1127
|
+
it("never logs the token, in any code path", async () => {
|
|
1128
|
+
const request: ExpressRequest = buildRequest({
|
|
1129
|
+
token: TOKEN_A,
|
|
1130
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
/* Crossing path. */
|
|
1134
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 3; i++) {
|
|
1135
|
+
await runMiddleware(request);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/* Counter failure path. */
|
|
1139
|
+
client.failNextExec = new Error(`redis said: ${TOKEN_A}`);
|
|
1140
|
+
currentTime = currentTime + 61_000;
|
|
1141
|
+
await runMiddleware(request);
|
|
1142
|
+
|
|
1143
|
+
/* Outage path. */
|
|
1144
|
+
isConnectedMock.mockReturnValue(false);
|
|
1145
|
+
currentTime = currentTime + 61_000;
|
|
1146
|
+
await runMiddleware(request);
|
|
1147
|
+
|
|
1148
|
+
const logged: string = everythingLogged();
|
|
1149
|
+
|
|
1150
|
+
/*
|
|
1151
|
+
* The Redis error above deliberately carries the token to prove the
|
|
1152
|
+
* limiter's own lines never do; strip that one echo before asserting.
|
|
1153
|
+
*/
|
|
1154
|
+
const withoutEchoedError: string = logged
|
|
1155
|
+
.split("\n")
|
|
1156
|
+
.filter((line: string) => {
|
|
1157
|
+
return !line.includes("redis said:");
|
|
1158
|
+
})
|
|
1159
|
+
.join("\n");
|
|
1160
|
+
|
|
1161
|
+
expect(withoutEchoedError).not.toContain(TOKEN_A);
|
|
1162
|
+
expect(withoutEchoedError).not.toContain(TOKEN_A.slice(0, 10));
|
|
1163
|
+
expect(withoutEchoedError).not.toContain(
|
|
1164
|
+
OnCallCalendarFeedRateLimit.hashToken(TOKEN_A).slice(2),
|
|
1165
|
+
);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it("derives its key from the request, not from the caller's header", async () => {
|
|
1169
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
1170
|
+
await runMiddleware(
|
|
1171
|
+
buildRequest({
|
|
1172
|
+
token: TOKEN_A,
|
|
1173
|
+
headers: { "x-forwarded-for": `10.0.0.${i % 250}, 203.0.113.7` },
|
|
1174
|
+
}),
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
sendErrorResponseMock.mockClear();
|
|
1179
|
+
|
|
1180
|
+
const result: { nextCalled: boolean } = await runMiddleware(
|
|
1181
|
+
buildRequest({
|
|
1182
|
+
token: TOKEN_A,
|
|
1183
|
+
headers: { "x-forwarded-for": "1.2.3.4, 203.0.113.7" },
|
|
1184
|
+
}),
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
expect(result.nextCalled).toBe(false);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("bills a request with a malformed token to the shared invalid bucket", async () => {
|
|
1191
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT; i++) {
|
|
1192
|
+
await runMiddleware(
|
|
1193
|
+
buildRequest({
|
|
1194
|
+
token: `junk-${i}`,
|
|
1195
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1196
|
+
}),
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
sendErrorResponseMock.mockClear();
|
|
1201
|
+
|
|
1202
|
+
const result: { nextCalled: boolean } = await runMiddleware(
|
|
1203
|
+
buildRequest({
|
|
1204
|
+
token: "another-junk-token",
|
|
1205
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1206
|
+
}),
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
expect(result.nextCalled).toBe(false);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("does not let junk tokens eat a real token's budget", async () => {
|
|
1213
|
+
for (let i: number = 0; i < PER_TOKEN_LIMIT + 5; i++) {
|
|
1214
|
+
await runMiddleware(
|
|
1215
|
+
buildRequest({
|
|
1216
|
+
token: `junk-${i}`,
|
|
1217
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1218
|
+
}),
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
sendErrorResponseMock.mockClear();
|
|
1223
|
+
|
|
1224
|
+
const result: { nextCalled: boolean } = await runMiddleware(
|
|
1225
|
+
buildRequest({
|
|
1226
|
+
token: TOKEN_A,
|
|
1227
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1228
|
+
}),
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
expect(result.nextCalled).toBe(true);
|
|
1232
|
+
});
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
/*
|
|
1237
|
+
* The budgets are read from the environment at module load (through
|
|
1238
|
+
* EnvironmentConfig), so these reload the module rather than calling into the
|
|
1239
|
+
* already-configured one.
|
|
1240
|
+
*/
|
|
1241
|
+
describe("OnCallCalendarFeedRateLimit configuration", () => {
|
|
1242
|
+
const originalEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
1243
|
+
|
|
1244
|
+
afterEach(() => {
|
|
1245
|
+
process.env = { ...originalEnv };
|
|
1246
|
+
jest.resetModules();
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
interface ReloadedModule {
|
|
1250
|
+
limiter: typeof OnCallCalendarFeedRateLimit;
|
|
1251
|
+
client: FakeRedisClient;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const reload: () => Promise<ReloadedModule> = async () => {
|
|
1255
|
+
jest.resetModules();
|
|
1256
|
+
|
|
1257
|
+
const redisModule: {
|
|
1258
|
+
default: { getClient: MockedFn; isConnected: MockedFn };
|
|
1259
|
+
} = (await import("../../../Server/Infrastructure/Redis")) as unknown as {
|
|
1260
|
+
default: { getClient: MockedFn; isConnected: MockedFn };
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
const limiterModule: { default: typeof OnCallCalendarFeedRateLimit } =
|
|
1264
|
+
(await import(
|
|
1265
|
+
"../../../Server/Middleware/OnCallCalendarFeedRateLimit"
|
|
1266
|
+
)) as unknown as { default: typeof OnCallCalendarFeedRateLimit };
|
|
1267
|
+
|
|
1268
|
+
const client: FakeRedisClient = new FakeRedisClient();
|
|
1269
|
+
|
|
1270
|
+
redisModule.default.getClient.mockReturnValue(client);
|
|
1271
|
+
redisModule.default.isConnected.mockReturnValue(true);
|
|
1272
|
+
|
|
1273
|
+
return { limiter: limiterModule.default, client };
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
it("honours a configured per-token limit", async () => {
|
|
1277
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_PER_TOKEN_PER_WINDOW"] = "3";
|
|
1278
|
+
|
|
1279
|
+
const { limiter } = await reload();
|
|
1280
|
+
|
|
1281
|
+
expect(limiter.getConfig().perTokenLimit).toBe(3);
|
|
1282
|
+
|
|
1283
|
+
for (let i: number = 0; i < 3; i++) {
|
|
1284
|
+
expect(
|
|
1285
|
+
(
|
|
1286
|
+
await limiter.consume({
|
|
1287
|
+
tokenKey: "t:abc",
|
|
1288
|
+
clientIp: "203.0.113.7",
|
|
1289
|
+
})
|
|
1290
|
+
).outcome,
|
|
1291
|
+
).toBe(OnCallCalendarFeedRateLimitOutcome.Allowed);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
expect(
|
|
1295
|
+
(
|
|
1296
|
+
await limiter.consume({
|
|
1297
|
+
tokenKey: "t:abc",
|
|
1298
|
+
clientIp: "203.0.113.7",
|
|
1299
|
+
})
|
|
1300
|
+
).outcome,
|
|
1301
|
+
).toBe(OnCallCalendarFeedRateLimitOutcome.RateLimited);
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it("honours a configured per-address ceiling", async () => {
|
|
1305
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_PER_IP_PER_WINDOW"] = "4";
|
|
1306
|
+
|
|
1307
|
+
const { limiter } = await reload();
|
|
1308
|
+
|
|
1309
|
+
expect(limiter.getConfig().perIpLimit).toBe(4);
|
|
1310
|
+
|
|
1311
|
+
let rejected: boolean = false;
|
|
1312
|
+
|
|
1313
|
+
for (let i: number = 0; i < 6; i++) {
|
|
1314
|
+
const decision: OnCallCalendarFeedRateLimitDecision =
|
|
1315
|
+
await limiter.consume({
|
|
1316
|
+
tokenKey: `t:rotating-${i}`,
|
|
1317
|
+
clientIp: "203.0.113.7",
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
if (decision.outcome === OnCallCalendarFeedRateLimitOutcome.RateLimited) {
|
|
1321
|
+
rejected = true;
|
|
1322
|
+
expect(decision.scope).toBe(OnCallCalendarFeedRateLimitScope.Ip);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
expect(rejected).toBe(true);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it("honours a configured window", async () => {
|
|
1330
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_WINDOW_SECONDS"] = "120";
|
|
1331
|
+
|
|
1332
|
+
const { limiter, client } = await reload();
|
|
1333
|
+
|
|
1334
|
+
expect(limiter.getConfig().windowSeconds).toBe(120);
|
|
1335
|
+
|
|
1336
|
+
await limiter.consume({ tokenKey: "t:abc", clientIp: "203.0.113.7" });
|
|
1337
|
+
|
|
1338
|
+
for (const recorded of client.expires) {
|
|
1339
|
+
expect(recorded.ttlSeconds).toBe(240);
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it("falls back to the defaults for a value that is not a positive integer", async () => {
|
|
1344
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_PER_TOKEN_PER_WINDOW"] =
|
|
1345
|
+
"lots";
|
|
1346
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_PER_IP_PER_WINDOW"] = "0";
|
|
1347
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_WINDOW_SECONDS"] = "1.5";
|
|
1348
|
+
|
|
1349
|
+
const { limiter } = await reload();
|
|
1350
|
+
|
|
1351
|
+
expect(limiter.getConfig()).toEqual({
|
|
1352
|
+
windowSeconds: WINDOW_SECONDS,
|
|
1353
|
+
perTokenLimit: PER_TOKEN_LIMIT,
|
|
1354
|
+
perIpLimit: PER_IP_LIMIT,
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it("treats a blank value as unset", async () => {
|
|
1359
|
+
process.env["ON_CALL_CALENDAR_FEED_RATE_LIMIT_PER_TOKEN_PER_WINDOW"] = "";
|
|
1360
|
+
|
|
1361
|
+
const { limiter } = await reload();
|
|
1362
|
+
|
|
1363
|
+
expect(limiter.getConfig().perTokenLimit).toBe(PER_TOKEN_LIMIT);
|
|
1364
|
+
});
|
|
1365
|
+
});
|