Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ MOBILE_REDIRECT_URI=devcard://oauth/callback

# ─── Server ───
PORT=3000
NODE_ENV=development
NODE_ENV=development

# ─── Refresh Token Cleanup ───
REFRESH_TOKEN_CLEANUP_INTERVAL_MS=86400000
2 changes: 2 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ model RefreshToken {

@@index([userId])
@@index([family])
@@index([expiresAt])
@@index([revokedAt])
@@map("refresh_tokens")
}

Expand Down
319 changes: 319 additions & 0 deletions apps/backend/src/__tests__/refreshTokenCleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import Fastify, { type FastifyInstance } from "fastify";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

import { refreshTokenCleanupPlugin } from "../plugins/refreshTokenCleanup.js";
import { cleanupExpiredAndRevokedTokens } from "../services/refreshTokenCleanupService.js";
import * as service from "../services/refreshTokenCleanupService.js";

import type { PrismaClient } from "@prisma/client";

describe("refreshTokenCleanupService", () => {
const mockPrisma = {
refreshToken: {
deleteMany: vi.fn(),
},
};

beforeEach(() => {
vi.clearAllMocks();
});

it("active token survives (neither expired nor revoked are deleted)", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 });

const result = await cleanupExpiredAndRevokedTokens(
mockPrisma as unknown as PrismaClient,
);

expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledTimes(1);
const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0];

// Explicitly verify the query structure:
// It must delete ONLY: revokedAt is not null OR expiresAt has passed (expiresAt < now)
expect(callArgs?.where?.OR).toBeDefined();
expect(callArgs.where.OR).toHaveLength(2);
expect(callArgs.where.OR).toContainEqual({ revokedAt: { not: null } });
expect(callArgs.where.OR[1].expiresAt.lt).toBeInstanceOf(Date);

expect(result.deletedCount).toBe(0);
});

it("expired token deleted", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 });

await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient);

const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0];

expect(callArgs.where.OR).toContainEqual({
expiresAt: {
lt: expect.any(Date),
},
});
});

it("revoked token deleted", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 });

await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient);

const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0];

expect(callArgs.where.OR).toContainEqual({
revokedAt: {
not: null,
},
});
});

it("mixed dataset query contains both cleanup conditions", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 5 });

await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient);

const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0];

expect(callArgs.where.OR).toHaveLength(2);
});

it("returns the exact count of deleted tokens reported by the database", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 15 });
const result = await cleanupExpiredAndRevokedTokens(
mockPrisma as unknown as PrismaClient,
);
expect(result.deletedCount).toBe(15);
});

it("empty dataset (table is empty, deleteMany returns 0)", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 });
const result = await cleanupExpiredAndRevokedTokens(
mockPrisma as unknown as PrismaClient,
);
expect(result.deletedCount).toBe(0);
});

it("service error handling is propagated correctly", async () => {
mockPrisma.refreshToken.deleteMany.mockRejectedValue(
new Error("Database query timeout"),
);
await expect(
cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient),
).rejects.toThrow("Database query timeout");
});

it("service is idempotent on multiple executions", async () => {
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 });
const run1 = await cleanupExpiredAndRevokedTokens(
mockPrisma as unknown as PrismaClient,
);
const run2 = await cleanupExpiredAndRevokedTokens(
mockPrisma as unknown as PrismaClient,
);
expect(run1.deletedCount).toBe(0);
expect(run2.deletedCount).toBe(0);
expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledTimes(2);
});
});

describe("refreshTokenCleanupPlugin", () => {
let app: FastifyInstance | null = null;
const mockPrisma = {
refreshToken: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
};

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
app = Fastify({ logger: { level: "error" } });
app.decorate("prisma", mockPrisma as unknown as PrismaClient);
});

afterEach(async () => {
vi.useRealTimers();
if (app) {
await app.close();
app = null;
}
delete process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS;
});

it("starts cleanup on register/startup and schedules interval", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 3,
durationMs: 15,
});

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "60000"; // 1 minute

await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

// Verification 1: startup cleanup run
expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Verification 2: interval execution
await vi.advanceTimersByTimeAsync(60000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);

await vi.advanceTimersByTimeAsync(60000);
expect(cleanupSpy).toHaveBeenCalledTimes(3);
});

it("invalid interval fallback (undefined)", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 0,
durationMs: 1,
});

delete process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS;

await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

expect(cleanupSpy).toHaveBeenCalledTimes(1); // startup

// Should not run at 1 minute
await vi.advanceTimersByTimeAsync(60000);
expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Should run at 24 hours (86400000ms)
await vi.advanceTimersByTimeAsync(86400000 - 60000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});

it("invalid interval fallback (NaN)", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 0,
durationMs: 1,
});

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "not-a-number";

await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

// Startup run
expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Should fallback to 24 hours
await vi.advanceTimersByTimeAsync(86400000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});

it("invalid interval fallback (<= 0)", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 0,
durationMs: 1,
});

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "0";

await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

// Startup run
expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Should fallback to 24 hours
await vi.advanceTimersByTimeAsync(86400000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});

it("invalid interval fallback (Infinity)", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 0,
durationMs: 1,
});

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "Infinity";

await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

// Startup run
expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Should fallback to 24 hours
await vi.advanceTimersByTimeAsync(86400000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});

it("startup cleanup failure logs error but does not crash app or stop scheduler", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockRejectedValue(new Error("Connection failure"));

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "5000";

await app!.register(refreshTokenCleanupPlugin);

// app!.ready() should resolve successfully without throwing
await expect(app!.ready()).resolves.toBeDefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Scheduler should still function
cleanupSpy.mockResolvedValue({ deletedCount: 0, durationMs: 1 });
await vi.advanceTimersByTimeAsync(5000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});

it("scheduled cleanup failure logs error but does not crash process", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 0,
durationMs: 1,
});

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "5000";

await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

expect(cleanupSpy).toHaveBeenCalledTimes(1);

// Fail during scheduled run
cleanupSpy.mockRejectedValue(new Error("Transaction deadlock"));
await vi.advanceTimersByTimeAsync(5000);
expect(cleanupSpy).toHaveBeenCalledTimes(2);

// Next run works again
cleanupSpy.mockResolvedValue({ deletedCount: 1, durationMs: 1 });
await vi.advanceTimersByTimeAsync(5000);
expect(cleanupSpy).toHaveBeenCalledTimes(3);
});

it("shutdown clears interval and avoids timer leaks", async () => {
const cleanupSpy = vi
.spyOn(service, "cleanupExpiredAndRevokedTokens")
.mockResolvedValue({
deletedCount: 0,
durationMs: 1,
});

process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "1000";
await app!.register(refreshTokenCleanupPlugin);
await app!.ready();

expect(cleanupSpy).toHaveBeenCalledTimes(1);

await app!.close();
app = null;

// Advance timer: should not be called again because onClose hook cleared it
await vi.advanceTimersByTimeAsync(5000);
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
});
Loading