Skip to content

Commit 0ed7f13

Browse files
committed
chore: improve tests
1 parent eca73d4 commit 0ed7f13

8 files changed

Lines changed: 285 additions & 273 deletions

File tree

integration/testUtils/machineAuthHelpers.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,48 @@ export const registerOAuthAuthTests = (adapter: MachineAuthTestAdapter): void =>
432432
expect(res.status()).toBe(401);
433433
});
434434

435+
test('consistently rejects the same invalid oat_ token across repeated requests', async ({ request }) => {
436+
// After the first rejection the token is cached as invalid in-process.
437+
// Subsequent requests with the same token must still return 401 (not be
438+
// accidentally accepted due to cache state).
439+
const url = new URL(adapter.oauth.verifyPath, app.serverUrl).toString();
440+
const invalidToken = `oat_integration_test_invalid_${Date.now()}`;
441+
442+
for (let i = 0; i < 3; i++) {
443+
const res = await request.get(url, { headers: { Authorization: `Bearer ${invalidToken}` } });
444+
expect(res.status()).toBe(401);
445+
}
446+
});
447+
448+
test('valid OAuth token is accepted after invalid tokens have been cached', async ({ page, context }) => {
449+
const u = createTestUtils({ app, page, context });
450+
const url = new URL(adapter.oauth.verifyPath, app.serverUrl).toString();
451+
452+
// Seed the negative cache with a few invalid tokens
453+
for (let i = 0; i < 3; i++) {
454+
await u.page.request.get(url, {
455+
headers: { Authorization: `Bearer oat_integration_cache_seed_${i}` },
456+
});
457+
}
458+
459+
// A legitimate token obtained through the real OAuth flow must still work
460+
const accessToken = await obtainOAuthAccessToken({
461+
page: u.page,
462+
oAuthApp: fakeOAuth.oAuthApp,
463+
redirectUri: new URL(adapter.oauth.callbackPath, app.serverUrl).toString(),
464+
fakeUser,
465+
signIn: u.po.signIn,
466+
});
467+
468+
const res = await u.page.request.get(url, {
469+
headers: { Authorization: `Bearer ${accessToken}` },
470+
});
471+
expect(res.status()).toBe(200);
472+
const authData = await res.json();
473+
expect(authData.userId).toBeDefined();
474+
expect(authData.tokenType).toBe(TokenType.OAuthToken);
475+
});
476+
435477
for (const [tokenType, token] of [
436478
['API key', 'ak_test_mismatch'],
437479
['M2M', 'mt_test_mismatch'],
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors';
4+
import {
5+
isOAuthTokenCachedAsInvalid,
6+
makeCachedInvalidOAuthTokenError,
7+
maybeCacheOAuthTokenAsInvalid,
8+
resetOAuthNegativeCache,
9+
} from '../oauthNegativeCache';
10+
11+
const TOKEN = 'oat_abc123';
12+
const ANOTHER_TOKEN = 'oat_xyz789';
13+
14+
function makeTokenInvalidError() {
15+
return new MachineTokenVerificationError({
16+
message: 'OAuth token not found',
17+
code: MachineTokenVerificationErrorCode.TokenInvalid,
18+
status: 404,
19+
});
20+
}
21+
22+
function makeOtherError() {
23+
return new MachineTokenVerificationError({
24+
message: 'Invalid secret key',
25+
code: MachineTokenVerificationErrorCode.InvalidSecretKey,
26+
status: 401,
27+
});
28+
}
29+
30+
describe('oauthNegativeCache', () => {
31+
beforeEach(() => {
32+
resetOAuthNegativeCache();
33+
vi.useFakeTimers();
34+
});
35+
36+
afterEach(() => {
37+
vi.useRealTimers();
38+
});
39+
40+
describe('isOAuthTokenCachedAsInvalid', () => {
41+
it('returns false for a token that has never been cached', () => {
42+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
43+
});
44+
45+
it('returns true for a token cached as invalid', () => {
46+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
47+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
48+
});
49+
50+
it('returns false for a different token not in the cache', () => {
51+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
52+
expect(isOAuthTokenCachedAsInvalid(ANOTHER_TOKEN)).toBe(false);
53+
});
54+
55+
it('returns false and evicts the entry after TTL expires', () => {
56+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
57+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
58+
59+
vi.advanceTimersByTime(30_001);
60+
61+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
62+
});
63+
64+
it('returns true just before TTL expires', () => {
65+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
66+
vi.advanceTimersByTime(29_999);
67+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
68+
});
69+
});
70+
71+
describe('maybeCacheOAuthTokenAsInvalid', () => {
72+
it('caches when error is TokenInvalid', () => {
73+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
74+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
75+
});
76+
77+
it('does not cache when error is a different MachineTokenVerificationError code', () => {
78+
maybeCacheOAuthTokenAsInvalid(makeOtherError(), TOKEN);
79+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
80+
});
81+
82+
it('does not cache when error is not a MachineTokenVerificationError', () => {
83+
maybeCacheOAuthTokenAsInvalid(new Error('network failure'), TOKEN);
84+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
85+
});
86+
87+
it('does not cache when error is null', () => {
88+
maybeCacheOAuthTokenAsInvalid(null, TOKEN);
89+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
90+
});
91+
92+
it('updates the expiry when caching the same token again', () => {
93+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
94+
95+
vi.advanceTimersByTime(20_000);
96+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
97+
98+
vi.advanceTimersByTime(20_000);
99+
// 40s total since first cache, but only 20s since re-cache; should still be valid
100+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
101+
102+
vi.advanceTimersByTime(10_001);
103+
// 30s since re-cache; should now expire
104+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
105+
});
106+
});
107+
108+
describe('makeCachedInvalidOAuthTokenError', () => {
109+
it('returns a MachineTokenVerificationError with TokenInvalid code', () => {
110+
const err = makeCachedInvalidOAuthTokenError();
111+
expect(err).toBeInstanceOf(MachineTokenVerificationError);
112+
expect(err.code).toBe(MachineTokenVerificationErrorCode.TokenInvalid);
113+
});
114+
115+
it('returns an error with status 404', () => {
116+
const err = makeCachedInvalidOAuthTokenError();
117+
expect(err.status).toBe(404);
118+
});
119+
});
120+
121+
describe('resetOAuthNegativeCache', () => {
122+
it('clears all cached entries', () => {
123+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
124+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), ANOTHER_TOKEN);
125+
resetOAuthNegativeCache();
126+
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
127+
expect(isOAuthTokenCachedAsInvalid(ANOTHER_TOKEN)).toBe(false);
128+
});
129+
});
130+
131+
describe('capacity eviction', () => {
132+
it('evicts the oldest entry when the cache reaches MAX_ENTRIES (10,000)', () => {
133+
// Fill the cache to max capacity
134+
for (let i = 0; i < 10_000; i++) {
135+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), `oat_token_${i}`);
136+
}
137+
138+
expect(isOAuthTokenCachedAsInvalid('oat_token_0')).toBe(true);
139+
140+
// Adding one more should evict oat_token_0 (the oldest)
141+
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), 'oat_overflow');
142+
143+
expect(isOAuthTokenCachedAsInvalid('oat_token_0')).toBe(false);
144+
expect(isOAuthTokenCachedAsInvalid('oat_overflow')).toBe(true);
145+
});
146+
});
147+
});

packages/backend/src/tokens/__tests__/oauthTokenRateLimiter.test.ts

Lines changed: 0 additions & 79 deletions
This file was deleted.

0 commit comments

Comments
 (0)