Skip to content
Merged
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
2 changes: 1 addition & 1 deletion jest.config.js

Large diffs are not rendered by default.

188 changes: 181 additions & 7 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"pino": "^10.3.0",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"redis": "^5.12.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.5",
"rxjs": "^7.8.1",
Expand Down Expand Up @@ -195,4 +196,4 @@
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3"
}
}
}
740 changes: 739 additions & 1 deletion scripts/validate-openapi.ts

Large diffs are not rendered by default.

217 changes: 216 additions & 1 deletion src/common/guard/quota.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1 +1,216 @@
aW1wb3J0IHsgRXhlY3V0aW9uQ29udGV4dCwgSHR0cEV4Y2VwdGlvbiwgSHR0cFN0YXR1cyB9IGZyb20gIkBuZXN0anMvY29tbW9uIjsKaW1wb3J0IHsgUmVmbGVjdG9yIH0gZnJvbSAiQG5lc3Rqcy9jb3JlIjsKaW1wb3J0IHsgUXVvdGFHdWFyZCwgUXVvdGFSZWRpc0NsaWVudCB9IGZyb20gIi4vcXVvdGEuZ3VhcmQiOwppbXBvcnQgeyBFcnJvckNvZGUgfSBmcm9tICJzcmMvY29tbW9uL2Vycm9ycy9lcnJvci1jb2RlcyI7CgpmdW5jdGlvbiBjcmVhdGVDb250ZXh0KG9wdGlvbnM/OiB7CiAgdXNlcj86IHsKICAgIGlkPzogc3RyaW5nOwogICAgc3ViPzogc3RyaW5nOwogICAgYWRkcmVzcz86IHN0cmluZzsKICAgIHJvbGU/OiBzdHJpbmc7CiAgICB0aWVyPzogc3RyaW5nOwogICAgdHlwZT86IHN0cmluZzsKICAgIGFwaUtleT86IHN0cmluZzsKICB9OwogIGlwPzogc3RyaW5nOwogIG9yaWdpbmFsVXJsPzogc3RyaW5nOwogIGhlYWRlcnM/OiBSZWNvcmQ8c3RyaW5nLCBzdHJpbmc+Owp9KTogewogIGNvbnRleHQ6IEV4ZWN1dGlvbkNvbnRleHQ7CiAgcmVzcG9uc2U6IHsgaGVhZGVyOiBqZXN0Lk1vY2s7IHNldEhlYWRlcjogamVzdC5Nb2NrIH07Cn0gewogIGNvbnN0IHJlcXVlc3QgPSB7CiAgICBpcDogb3B0aW9ucz8uaXAgPz8gIjEyNy4wLjAuMSIsCiAgICBoZWFkZXJzOiBvcHRpb25zPy5oZWFkZXJzID8/IHt9LAogICAgb3JpZ2luYWxVcmw6IG9wdGlvbnM/Lm9yaWdpbmFsVXJsID8/ICIvYXBpL3Rlc3QiLAogICAgcm91dGU6IHsgcGF0aDogIi9hcGkvdGVzdCIgfSwKICAgIGF1dGhUeXBlOiBvcHRpb25zPy51c2VyPy50eXBlLAogICAgdXNlcjogb3B0aW9ucz8udXNlciwKICB9OwoKICBjb25zdCByZXNwb25zZSA9IHsKICAgIGhlYWRlcjogamVzdC5mbigpLAogICAgc2V0SGVhZGVyOiBqZXN0LmZuKCksCiAgfTsKCiAgY29uc3QgY29udGV4dCA9IHsKICAgIGdldEhhbmRsZXI6IGplc3QuZm4oKSwKICAgIGdldENsYXNzOiBqZXN0LmZuKCksCiAgICBzd2l0Y2hUb0h0dHA6ICgpID0+ICh7CiAgICAgIGdldFJlcXVlc3Q6ICgpID0+IHJlcXVlc3QsCiAgICAgIGdldFJlc3BvbnNlOiAoKSA9PiByZXNwb25zZSwKICAgIH0pLAogIH0gYXMgdW5rbm93biBhcyBFeGVjdXRpb25Db250ZXh0OwoKICByZXR1cm4geyBjb250ZXh0LCByZXNwb25zZSB9Owp9CgpmdW5jdGlvbiBjcmVhdGVSZWRpcyhpbml0aWFsID0gMCk6IHsKICBjbGllbnQ6IFF1b3RhUmVkaXNDbGllbnQ7CiAgc3RvcmU6IE1hcDxzdHJpbmcsIG51bWJlcj47CiAgZXhwaXJlczogamVzdC5Nb2NrOwp9IHsKICBjb25zdCBzdG9yZSA9IG5ldyBNYXA8c3RyaW5nLCBudW1iZXI+KCk7CiAgY29uc3QgZXhwaXJlcyA9IGplc3QuZm4oKS5tb2NrUmVzb2x2ZWQoMSk7CiAgY29uc3QgY2xpZW50OiBRdW90YVJlZGlzQ2xpZW50ID0gewogICAgaW5jcjogamVzdC5mbi5hc3luYyAoa2V5OiBzdHJpbmcpID0+IHsKICAgICAgY29uc3QgbmV4dCA9IChzdG9yZS5nZXQoa2V5KSA/PyBpbml0aWFsKSArIDE7CiAgICAgIHN0b3JlLnNldChrZXksIG5leHQpOwogICAgICByZXR1cm4gbmV4dDsKICAgIH0pLAogICAgZ2V0OiBqZXN0LmZuLmFzeW5jIChrZXk6IHN0cmluZykgPT4gewogICAgICBjb25zdCB2YWx1ZSA9IHN0b3JlLmdldChrZXkpOwogICAgICByZXR1cm4gdmFsdWUgPT09IHVuZGVmaW5lZCA/IG51bGwgOiBTdHJpbmcodmFsdWUpOwogICAgfSksCiAgICBleHBpcmU6IGV4cGlyZXMsCiAgfTsKCiAgcmV0dXJuIHsgY2xpZW50LCBzdG9yZSwgZXhwaXJlcyB9Owp9CgpkZXNjcmliZSgiUXVvdGFHdWFyZCIsICgpID0+IHsKICBsZXQgcmVmbGVjdG9yOiBSZWZsZWN0b3I7CgogIGJlZm9yZUVhY2goKCkgPT4gewogICAgcmVmbGVjdG9yID0gbmV3IFJlZmxlY3RvcigpOwogIH0pOwoKICBpdCgiYWxsb3dzIHJlcXVlc3RzIGFuZCBlbWl0cyByYXRlLWxpbWl0IGhlYWRlcnMgZm9yIHRoZSBkZWZhdWx0IHRpZXIiLCBhc3luYyAoKSA9PiB7CiAgICBjb25zdCBndWFyZCA9IG5ldyBRdW90YUd1YXJkKHJlZmxlY3Rvcik7CiAgICBqZXN0LnNweU9uKHJlZmxlY3RvciwgImdldEFsbEFuZE92ZXJyaWRlIikubW9ja1JldHVyblZhbHVlKHVuZGVmaW5lZCk7CiAgICBjb25zdCB7IGNvbnRleHQsIHJlc3BvbnNlIH0gPSBjcmVhdGVDb250ZXh0KCk7CgogICAgYXdhaXQgZXhwZWN0KGd1YXJkLmNhbkFjdGl2YXRlKGNvbnRleHQpKS5yZXNvbHZlcy50b0JlKHRydWUpOwogICAgZXhwZWN0KHJlc3BvbnNlLmhlYWRlciku dG9IYXZlQmVlbkNhbGxlZFdpdGgoIlgtUmF0ZUxpbWl0LUxpbWl0IiwgMTAwKTsKICAgIGV4cGVjdChyZXNwb25zZS5oZWFkZXIpLnRvSGF2ZUJlZW5DYWxsZWRXaXRoKCJYLVJhdGVMaW1pdC1SZW1haW5pbmciLCA5OSk7CiAgICBleHBlY3QocmVzcG9uc2UuaGVhZGVyKS50b0hhdmVCZWVuQ2FsbGVkV2l0aCgiWC1SYXRlTGltaXQtVGllciIsICJmcmVlIik7CiAgfSk7CgogIGl0KCJ1c2VzIHRoZSBhdXRoZW50aWNhdGVkIHVzZXIgdGllciB3aGVuIG9uZSBpcyBhdmFpbGFibGUiLCBhc3luYyAoKSA9PiB7CiAgICBjb25zdCBndWFyZCA9IG5ldyBRdW90YUd1YXJkKHJlZmxlY3Rvcik7CiAgICBqZXN0LnNweU9uKHJlZmxlY3RvciwgImdldEFsbEFuZE92ZXJyaWRlIikubW9ja1JldHVyblZhbHVlKHVuZGVmaW5lZCk7CiAgICBjb25zdCB7IGNvbnRleHQsIHJlc3BvbnNlIH0gPSBjcmVhdGVDb250ZXh0KHsKICAgICAgdXNlcjogeyBpZDogInVzZXItMSIsIHJvbGU6ICJhZG1pbiIgfSwKICAgIH0pOwoKICAgIGF3YWl0IGV4cGVjdChndWFyZC5jYW5BY3RpdmF0ZShjb250ZXh0KSkucmVzb2x2ZXMudG9CZSh0cnVlKTsKICAgIGV4cGVjdChyZXNwb25zZS5oZWFkZXIpLnRvSGF2ZUJlZW5DYWxsZWRXaXRoKAogICAgICAiWC1SYXRlTGltaXQtVGllciIsCiAgICAgICJlbnRlcnByaXNlIiwKICAgICk7CiAgfSk7CgogIGl0KCJyZWplY3RzIHJlcXVlc3RzIGFmdGVyIHRoZSBjb25maWd1cmVkIGxpbWl0IGlzIHJlYWNoZWQiLCBhc3luYyAoKSA9PiB7CiAgICBjb25zdCBndWFyZCA9IG5ldyBRdW90YUd1YXJkKHJlZmxlY3Rvcik7CiAgICBqZXN0LnNweU9uKHJlZmxlY3RvciwgImdldEFsbEFuZE92ZXJyaWRlIikubW9ja1JldHVyblZhbHVlKHsKICAgICAgbGV2ZWw6ICJhdXRoIiwKICAgICAgbGltaXQ6IDEsCiAgICAgIHdpbmRvd01zOiA2MF8wMDAsCiAgICB9KTsKCiAgICBjb25zdCB7IGNvbnRleHQgfSA9IGNyZWF0ZUNvbnRleHQoewogICAgICB1c2VyOiB7IGlkOiAidXNlci0yIiwgcm9sZTogInVzZXIiIH0sCiAgICB9KTsKCiAgICBhd2FpdCBleHBlY3QoZ3VhcmQuY2FuQWN0aXZhdGUoY29udGV4dCkpLnJlc29sdmVzLnRvQmUodHJ1ZSk7CgogICAgdHJ5IHsKICAgICAgYXdhaXQgZ3VhcmQuY2FuQWN0aXZhdGUoY29udGV4dCk7CiAgICAgIHRocm93IG5ldyBFcnJvcigiRXhwZWN0ZWQgcmF0ZSBsaW1pdCByZWplY3Rpb24iKTsKICAgIH0gY2F0Y2ggKGVycm9yKSB7CiAgICAgIGV4cGVjdChlcnJvcikudG9CbGVJbnN0YW5jZU9mKEh0dHBFeGNlcHRpb24pOwogICAgICBleHBlY3QoKGVycm9yIGFzIEh0dHBFeGNlcHRpb24pLmdldFN0YXR1cygpKS50b0JlKAogICAgICAgIEh0dHBTdGF0dXMuVE9PX01BTllfUkVRVUVTVFMsCiAgICAgICk7CiAgICB9CiAgfSk7CgogIGl0KCJpbmNyZW1lbnRzIHRoZSBtb250aGx5IFJlZGlzIGNvdW50ZXIgYW5kIGVtaXRzIHF1b3RhIGhlYWRlcnMiLCBhc3luYyAoKSA9PiB7CiAgICBjb25zdCB7IGNsaWVudCwgc3RvcmUsIGV4cGlyZXMgfSA9IGNyZWF0ZVJlZGlzKCk7CiAgICBjb25zdCBndWFyZCA9IG5ldyBRdW90YUd1YXJkKHJlZmxlY3RvciwgY2xpZW50KTsKICAgIGplc3Quc3B5T24ocmVmbGVjdG9yLCAiZ2V0QWxsQW5kT3ZlcnJpZGUiKS5tb2NrUmV0dXJuVmFsdWUodW5kZWZpbmVkKTsKICAgIGNvbnN0IHsgY29udGV4dCwgcmVzcG9uc2UgfSA9IGNyZWF0ZUNvbnRleHQoewogICAgICB1c2VyOiB7IGlkOiAidXNlci0zIiwgYXBpS2V5OiAia2V5LTEiLCB0aWVyOiAiZnJlZSIgfSwKICAgIH0pOwoKICAgIGF3YWl0IGV4cGVjdChndWFyZC5jYW5BY3RpdmF0ZShjb250ZXh0KSkucmVzb2x2ZXMudG9CZSh0cnVlKTsKCiAgICBjb25zdCBtb250aEtleSA9IEFycmF5LmZyb20oc3RvcmUua2V5cygpKVswXTsKICAgIGV4cGVjdChtb250aEtleSkudG9NYXRjaCgvXnF1b3RhOmtleS0xOlxkewR9LVxkewJ9JC8pOwogICAgZXhwZWN0KHN0b3JlLmdldChtb250aEtleSkpLnRvQmUoMSk7CiAgICBleHBlY3QoZXhwaXJlcykudG9IYXZlQmVlbkNhbGxlZCgpOwogICAgZXhwZWN0KHJlc3BvbnNlLmhlYWRlcikudG9IYXZlQmVlbkNhbGxlZFdpdGgoIlgtUXVvdGEtTGltaXQiLCAxMF8wMDApOwogICAgZXhwZWN0KHJlc3BvbnNlLmhlYWRlcikudG9IYXZlQmVlbkNhbGxlZFdpdGgoIlgtUXVvdGEtUmVtYWluaW5nIiwgOV85OTkpOwogIH0pOwoKICBpdCgicmV0dXJucyA0Mjkgd2l0aCBRVU9UQV9FWENFRURFRCB3aGVuIHRoZSBtb250aGx5IHF1b3RhIGlzIGV4aGF1c3RlZCIsIGFzeW5jICgpID0+IHsKICAgIGNvbnN0IHsgY2xpZW50IH0gPSBjcmVhdGVSZWRpcyg5OTk5KTsKICAgIGNvbnN0IGd1YXJkID0gbmV3IFF1b3RhR3VhcmQocmVmbGVjdG9yLCBjbGllbnQpOwogICAgamVzdC5zcHlPbihyZWZsZWN0b3IsICJnZXRBbGxBbmRPdmVycmlkZSIpLm1vY2tSZXR1cm5WYWx1ZSh1bmRlZmluZWQpOwogICAgY29uc3QgeyBjb250ZXh0LCByZXNwb25zZSB9ID0gY3JlYXRlQ29udGV4dCh7CiAgICAgIHVzZXI6IHsgaWQ6ICJ1c2VyLTQiLCBhcGlLZXk6ICJrZXktMiIsIHRpZXI6ICJmcmVlIiB9LAogICAgfSk7CgogICAgdHJ5IHsKICAgICAgYXdhaXQgZ3VhcmQuY2FuQWN0aXZhdGUoY29udGV4dCk7CiAgICAgIHRocm93IG5ldyBFcnJvcigiRXhwZWN0ZWQgcXVvdGEgcmVqZWN0aW9uIik7CiAgICB9IGNhdGNoIChlcnJvcikgewogICAgICBleHBlY3QoZXJyb3IpLnRvQmVsbnN0YW5jZU9mKEh0dHBFeGNlcHRpb24pOwogICAgICBjb25zdCBleGNlcHRpb24gPSBlcnJvciBhcyBIdHRwRXhjZXB0aW9uOwogICAgICBleHBlY3QoZXhjb3B0aW9uLmdldFN0YXR1cygpKS50b0JlKEh0dHBTdGF0dXMuVE9PX01BTllfUkVRVUVTVFMpOwogICAgICBjb25zdCBib2R5ID0gZXhjb3B0aW9uLmdldFJlc3BvbnNlKCkgYXMgeyBjb2RlOiBzdHJpbmc7IHJlc2V0QXQ6IHN0cmluZyB9OwogICAgICBleHBlY3QoYm9keS5jb2RlKS50b0JlKEVycm9yQ29kZS5RVU9UQV9FWENFRURFRCk7CiAgICAgIGV4cGVjdCh0eXBlb2YgYm9keS5yZXNldEF0KS50b0JlKCJzdHJpbmciKTsKICAgIH0KCiAgICBleHBlY3QocmVzcG9uc2UuaGVhZGVyKS50b0hhdmVCZWVuQ2FsbGVkV2l0aCgiWC1RdW90YS1SZW1haW5pbmciLCAwKTsKICB9KTsKCiAgaXQoImRvZXMgbm90IGVuZm9yY2UgbW9udGhseSBxdW90YSBmb3IgZW50ZXJwcmlzZSB0aWVyIiwgYXN5bmMgKCkgPT4gewogICAgY29uc3QgeyBjbGllbnQgfSA9IGNyZWF0ZVJlZGlzKDEwXzAwMF8wMDApOwogICAgY29uc3QgZ3VhcmQgPSBuZXcgUXVvdGFHdWFyZChyZWZsZWN0b3IsIGNsaWVudCk7CiAgICBqZXN0LnNweU9uKHJlZmxlY3RvciwgImdldEFsbEFuZE92ZXJyaWRlIikubW9ja1JldHVyblZhbHVlKHVuZGVmaW5lZCk7CiAgICBjb25zdCB7IGNvbnRleHQsIHJlc3BvbnNlIH0gPSBjcmVhdGVDb250ZXh0KHsKICAgICAgdXNlcjogeyBpZDogInVzZXItNSIsIGFwaUtleTogImtleS0zIiwgdGllcjogImVudGVycHJpc2UiIH0sCiAgICB9KTsKCiAgICBhd2FpdCBleHBlY3QoZ3VhcmQuY2FuQWN0aXZhdGUoY29udGV4dCkpLnJlc29sdmVzLnRvQmUodHJ1ZSk7CiAgICBleHBlY3QocmVzcG9uc2UuaGVhZGVyKS50b0hhdmVCZWVuQ2FsbGVkV2l0aCgiWC1RdW90YS1MaW1pdCIsIC0xKTsKICB9KTsKfSk7Cg==
import { ExecutionContext, HttpException, HttpStatus } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { QuotaGuard } from "./quota.guard";

// The guard builds its own client from REDIS_URL; tests replace the whole
// driver so the monthly counter is deterministic and no server is required.
const mockQuotaStore = new Map<string, number>();

jest.mock("redis", () => ({
createClient: jest.fn(() => ({
isOpen: true,
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
incr: jest.fn(async (key: string) => {
const next = (mockQuotaStore.get(key) ?? 0) + 1;
mockQuotaStore.set(key, next);
return next;
}),
expire: jest.fn().mockResolvedValue(true),
})),
}));

function createContext(options?: {
user?: {
id?: string;
sub?: string;
address?: string;
role?: string;
tier?: string;
type?: string;
};
ip?: string;
originalUrl?: string;
headers?: Record<string, string>;
}): {
context: ExecutionContext;
response: { header: jest.Mock; setHeader: jest.Mock };
} {
const request = {
ip: options?.ip ?? "127.0.0.1",
headers: options?.headers ?? {},
originalUrl: options?.originalUrl ?? "/api/test",
route: { path: "/api/test" },
authType: options?.user?.type,
user: options?.user,
};

const response = {
header: jest.fn(),
setHeader: jest.fn(),
};

const context = {
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as unknown as ExecutionContext;

return { context, response };
}

describe("QuotaGuard", () => {
let guard: QuotaGuard;
let reflector: Reflector;

beforeEach(() => {
reflector = new Reflector();
guard = new QuotaGuard(reflector);
});

it("allows requests and emits rate-limit headers for the default tier", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context, response } = createContext();

await expect(guard.canActivate(context)).resolves.toBe(true);
expect(response.header).toHaveBeenCalledWith("X-RateLimit-Limit", 100);
expect(response.header).toHaveBeenCalledWith("X-RateLimit-Remaining", 99);
expect(response.header).toHaveBeenCalledWith("X-RateLimit-Tier", "free");
});

it("uses the authenticated user tier when one is available", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context, response } = createContext({
user: { id: "user-1", role: "admin" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);
expect(response.header).toHaveBeenCalledWith(
"X-RateLimit-Tier",
"enterprise",
);
});

it("rejects requests after the configured limit is reached", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue({
level: "auth",
limit: 1,
windowMs: 60_000,
});

const { context } = createContext({
user: { id: "user-2", role: "user" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);

try {
await guard.canActivate(context);
throw new Error("Expected rate limit rejection");
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(
HttpStatus.TOO_MANY_REQUESTS,
);
}
});

describe("monthly API quota (redis-backed)", () => {
const previousRedisUrl = process.env.REDIS_URL;

function currentMonthKey(): string {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
}

beforeEach(() => {
// Must be set before the guard is constructed: the client promise is
// resolved once and cached per instance.
process.env.REDIS_URL = "redis://localhost:6379";
mockQuotaStore.clear();
guard = new QuotaGuard(reflector);
});

afterEach(() => {
if (previousRedisUrl === undefined) {
delete process.env.REDIS_URL;
} else {
process.env.REDIS_URL = previousRedisUrl;
}
});

it("increments the per-key monthly counter once per request", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context } = createContext({
user: { id: "user-7", tier: "free" },
headers: { "x-api-key": "key-counter" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(guard.canActivate(context)).resolves.toBe(true);

expect(
mockQuotaStore.get(`quota:key-counter:${currentMonthKey()}`),
).toBe(2);
});

it("emits the paid-tier monthly limit, not the free-tier fallback", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context, response } = createContext({
user: { id: "user-8", tier: "paid" },
headers: { "x-api-key": "key-paid" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);

expect(response.header).toHaveBeenCalledWith("X-Quota-Limit", 1_000_000);
expect(response.header).toHaveBeenCalledWith(
"X-Quota-Remaining",
999_999,
);
});

it("returns 429 with QUOTA_EXCEEDED when the free quota is exhausted", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
mockQuotaStore.set(`quota:key-done:${currentMonthKey()}`, 10_000);

const { context } = createContext({
user: { id: "user-9", tier: "free" },
headers: { "x-api-key": "key-done" },
});

try {
await guard.canActivate(context);
throw new Error("Expected monthly quota rejection");
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(
HttpStatus.TOO_MANY_REQUESTS,
);
const body = (error as HttpException).getResponse() as {
code?: string;
};
expect(body.code).toBe("QUOTA_EXCEEDED");
}
});

it("reports enterprise usage as unlimited without touching redis", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context, response } = createContext({
user: { id: "user-10", role: "admin" },
headers: { "x-api-key": "key-ent" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);

expect(response.header).toHaveBeenCalledWith(
"X-Quota-Remaining",
"unlimited",
);
expect(mockQuotaStore.size).toBe(0);
});
});
});
2 changes: 1 addition & 1 deletion src/common/guard/quota.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {

const MONTHLY_QUOTA_LIMITS: Record<RateLimitTier, number> = {
free: 10_000,
pro: 1_000_000,
paid: 1_000_000,
enterprise: Number.POSITIVE_INFINITY,
};

Expand Down
2 changes: 1 addition & 1 deletion src/storage-compatibility/storage-schema-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function migrationFileVersion(fileName: string): MigrationVersion | null {

/**
* Discover the same migration locations configured by src/config/typeorm.config.ts:
* src/migrations/* and any nested **/migration/* directory.
* src/migrations/* and any nested **\/migration/* directory.
*
* Only timestamp-prefixed migration source/build files are considered. The
* timestamp is TypeORM's durable migration identity and is also what is stored
Expand Down
Loading