Skip to content

Commit b3adbf1

Browse files
committed
fix: address PR review on token throttle PR
Two CodeRabbit findings: 1. Add `revokedAt: null` to the throttled `updateMany` WHERE clause so a token revoked between findFirst and updateMany doesn't get a stale lastAccessedAt write. Matches the original findFirst guard. 2. Replace the ±50ms time tolerance in the cutoff assertion with `vi.useFakeTimers()` + a fixed system time so the assertion is exact and won't flake on slow CI runners. Also asserts the new `revokedAt: null` predicate is in place.
1 parent 6e72cf1 commit b3adbf1

4 files changed

Lines changed: 28 additions & 14 deletions

File tree

apps/webapp/app/services/organizationAccessToken.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,13 @@ export async function authenticateOrganizationAccessToken(
113113

114114
// Conditional updateMany — only writes if the existing lastAccessedAt is
115115
// null or older than the throttle window. The WHERE runs inside the UPDATE
116-
// so concurrent auths don't race into a double-write.
116+
// so concurrent auths don't race into a double-write. `revokedAt: null`
117+
// matches the findFirst guard above so a token revoked between the read
118+
// and write doesn't get a stale lastAccessedAt update.
117119
await prisma.organizationAccessToken.updateMany({
118120
where: {
119121
id: organizationAccessToken.id,
122+
revokedAt: null,
120123
OR: [
121124
{ lastAccessedAt: null },
122125
{ lastAccessedAt: { lt: new Date(Date.now() - OAT_LAST_ACCESSED_THROTTLE_MS) } },

apps/webapp/app/services/personalAccessToken.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,13 @@ export async function authenticatePersonalAccessToken(
213213

214214
// Conditional updateMany — only writes if the existing lastAccessedAt is
215215
// null or older than the throttle window. The WHERE runs inside the UPDATE
216-
// so concurrent auths don't race into a double-write.
216+
// so concurrent auths don't race into a double-write. `revokedAt: null`
217+
// matches the findFirst guard above so a token revoked between the read
218+
// and write doesn't get a stale lastAccessedAt update.
217219
await prisma.personalAccessToken.updateMany({
218220
where: {
219221
id: personalAccessToken.id,
222+
revokedAt: null,
220223
OR: [
221224
{ lastAccessedAt: null },
222225
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },

apps/webapp/test/services/organizationAccessToken.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, test, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
22

33
const { findFirstMock, updateManyMock } = vi.hoisted(() => ({
44
findFirstMock: vi.fn(),
@@ -29,11 +29,17 @@ import {
2929
} from "~/services/organizationAccessToken.server";
3030

3131
beforeEach(() => {
32+
vi.useFakeTimers();
33+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
3234
findFirstMock.mockReset();
3335
updateManyMock.mockReset();
3436
updateManyMock.mockResolvedValue({ count: 1 });
3537
});
3638

39+
afterEach(() => {
40+
vi.useRealTimers();
41+
});
42+
3743
describe("authenticateOrganizationAccessToken — lastAccessedAt throttle", () => {
3844
test("issues a conditional updateMany that skips writes when lastAccessedAt is recent", async () => {
3945
findFirstMock.mockResolvedValueOnce({
@@ -42,15 +48,14 @@ describe("authenticateOrganizationAccessToken — lastAccessedAt throttle", () =
4248
hashedToken: "hashed:tr_oat_validtoken",
4349
});
4450

45-
const before = Date.now();
4651
const result = await authenticateOrganizationAccessToken("tr_oat_validtoken");
47-
const after = Date.now();
4852

4953
expect(result).toEqual({ organizationId: "org_1" });
5054
expect(updateManyMock).toHaveBeenCalledTimes(1);
5155

5256
const call = updateManyMock.mock.calls[0][0];
5357
expect(call.where.id).toBe("oat_123");
58+
expect(call.where.revokedAt).toBeNull();
5459
expect(call.data.lastAccessedAt).toBeInstanceOf(Date);
5560

5661
// The WHERE clause should require the existing lastAccessedAt to be null
@@ -60,9 +65,9 @@ describe("authenticateOrganizationAccessToken — lastAccessedAt throttle", () =
6065
{ lastAccessedAt: { lt: expect.any(Date) } },
6166
]);
6267

68+
// With fake timers, the cutoff lands exactly throttle-ms before "now".
6369
const cutoff = call.where.OR[1].lastAccessedAt.lt as Date;
64-
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - OAT_LAST_ACCESSED_THROTTLE_MS - 50);
65-
expect(cutoff.getTime()).toBeLessThanOrEqual(after - OAT_LAST_ACCESSED_THROTTLE_MS + 50);
70+
expect(cutoff.getTime()).toBe(Date.now() - OAT_LAST_ACCESSED_THROTTLE_MS);
6671
});
6772

6873
test("skips updateMany when token is not found", async () => {

apps/webapp/test/services/personalAccessToken.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, test, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
22

33
const { findFirstMock, updateManyMock } = vi.hoisted(() => ({
44
findFirstMock: vi.fn(),
@@ -35,11 +35,17 @@ import {
3535
} from "~/services/personalAccessToken.server";
3636

3737
beforeEach(() => {
38+
vi.useFakeTimers();
39+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
3840
findFirstMock.mockReset();
3941
updateManyMock.mockReset();
4042
updateManyMock.mockResolvedValue({ count: 1 });
4143
});
4244

45+
afterEach(() => {
46+
vi.useRealTimers();
47+
});
48+
4349
describe("authenticatePersonalAccessToken — lastAccessedAt throttle", () => {
4450
test("issues a conditional updateMany that skips writes when lastAccessedAt is recent", async () => {
4551
findFirstMock.mockResolvedValueOnce({
@@ -49,15 +55,14 @@ describe("authenticatePersonalAccessToken — lastAccessedAt throttle", () => {
4955
encryptedToken: { nonce: "n", ciphertext: "c", tag: "t" },
5056
});
5157

52-
const before = Date.now();
5358
const result = await authenticatePersonalAccessToken("tr_pat_validtoken");
54-
const after = Date.now();
5559

5660
expect(result).toEqual({ userId: "user_1" });
5761
expect(updateManyMock).toHaveBeenCalledTimes(1);
5862

5963
const call = updateManyMock.mock.calls[0][0];
6064
expect(call.where.id).toBe("pat_123");
65+
expect(call.where.revokedAt).toBeNull();
6166
expect(call.data.lastAccessedAt).toBeInstanceOf(Date);
6267

6368
// The WHERE clause should require the existing lastAccessedAt to be null
@@ -67,11 +72,9 @@ describe("authenticatePersonalAccessToken — lastAccessedAt throttle", () => {
6772
{ lastAccessedAt: { lt: expect.any(Date) } },
6873
]);
6974

75+
// With fake timers, the cutoff lands exactly throttle-ms before "now".
7076
const cutoff = call.where.OR[1].lastAccessedAt.lt as Date;
71-
// Cutoff should be exactly throttle-ms before "now" (within the test
72-
// window). Confirms the throttle constant is wired through correctly.
73-
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - PAT_LAST_ACCESSED_THROTTLE_MS - 50);
74-
expect(cutoff.getTime()).toBeLessThanOrEqual(after - PAT_LAST_ACCESSED_THROTTLE_MS + 50);
77+
expect(cutoff.getTime()).toBe(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS);
7578
});
7679

7780
test("skips updateMany when token is not found", async () => {

0 commit comments

Comments
 (0)