diff --git a/.env.example b/.env.example index a5e8daa..4ac4c30 100644 --- a/.env.example +++ b/.env.example @@ -201,6 +201,10 @@ FILE_CLEANUP_ENABLED=true FILE_ORPHAN_RETENTION_DAYS=7 FILE_EXPIRED_RETENTION_DAYS=1 FILE_DEFAULT_EXPIRY_HOURS=720 +# Comma-separated allowlist of MIME types accepted at the upload boundary. +# Requests with any other MIME type are rejected with 400 before storage. +# Default covers images, documents, video, audio and archives. +FILE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/tiff,image/bmp,image/avif,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain,text/csv,application/json,application/xml,video/mp4,video/webm,video/ogg,video/quicktime,video/x-msvideo,video/x-matroska,audio/mpeg,audio/wav,audio/ogg,audio/webm,audio/aac,audio/flac,audio/mp4,application/zip,application/x-tar,application/gzip,application/x-rar-compressed,application/x-7z-compressed,application/x-bzip2 # S3 Configuration (when FILE_STORAGE_BACKEND=s3) S3_BUCKET=alian-structure-files diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 16cfc02..4dd7ee0 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -159,5 +159,12 @@ jobs: uses: actions/upload-artifact@v4 with: name: typescript-client - path: client/ + path: sdks/typescript + retention-days: 30 + + - name: Upload Python client artifact + uses: actions/upload-artifact@v4 + with: + name: python-client + path: sdks/python retention-days: 30 diff --git a/README.md b/README.md index fa723b7..d06390a 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,32 @@ The backend supports two authentication methods: All authentication endpoints use JWT tokens for session management with bcrypt password hashing for traditional auth. +#### Token lifetimes and algorithm + +Token lifetimes and the signing algorithm are defined once, in +[`src/config/jwt.config.ts`](src/config/jwt.config.ts), and referenced by both +`AuthService` and `EnhancedAuthService`: + +| Token | Lifetime | Constant | +| --- | --- | --- | +| Access token | 15 minutes | `ACCESS_TOKEN_TTL` | +| Refresh token | 7 days | `REFRESH_TOKEN_TTL_MS` | + +Tokens are signed with `HS256` and verification is restricted to an explicit +`algorithms: ['HS256']` allowlist; a token presented under any other algorithm is +rejected rather than the verifier choosing for itself. + +Clients must treat an expired access token as a normal condition and exchange it +for a new one via the refresh endpoint - a 15-minute access token means an +unattended client will need to refresh roughly every quarter hour. + +> **Upgrade note.** `EnhancedAuthService` previously issued access tokens via +> `jwtService.sign(payload)` with no options, so the lifetime was whatever the +> `JwtModule` default happened to be. Sessions issued before this change keep +> whatever lifetime they were minted with until they expire; only newly issued +> tokens are guaranteed to carry a 15-minute `exp`. The refresh token lifetime +> is unchanged. + ### Job Control API Fine-grained control over compute jobs with role-based access control: diff --git a/docs/kubernetes-health-probes.md b/docs/kubernetes-health-probes.md index 171e7ef..2ef60ea 100644 --- a/docs/kubernetes-health-probes.md +++ b/docs/kubernetes-health-probes.md @@ -29,11 +29,20 @@ All endpoints are public (no authentication required) and excluded from rate lim ### Status values -| Value | Meaning | -|---|---| -| `ok` | All components healthy | -| `degraded` | Some non-critical components down (readiness only) | -| `error` | One or more critical components down — returns HTTP 503 | +| Enum (`HealthStatus`) | Value | Meaning | HTTP Status Mapping | +|---|---|---|---| +| `HealthStatus.OK` | `ok` | All components healthy | `200 OK` | +| `HealthStatus.DEGRADED` | `degraded` | Some non-critical components down (readiness only) | `200 OK` | +| `HealthStatus.ERROR` | `error` | One or more critical components down | `503 Service Unavailable` | + +### Component status values + +| Enum (`ComponentStatus`) | Value | Meaning | +|---|---|---| +| `ComponentStatus.UP` | `up` | Subsystem responding within timeout threshold | +| `ComponentStatus.DOWN` | `down` | Subsystem unreachable or throwing error | +| `ComponentStatus.DEGRADED` | `degraded` | Subsystem responding with high latency or partial capacity | +| `ComponentStatus.UNKNOWN` | `unknown` | Status evaluation skipped or unconfigured | ## Kubernetes probe configuration diff --git a/jest.config.js b/jest.config.js index d269f66..9da4ca4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,13 @@ module.exports = { moduleFileExtensions: ['js', 'json', 'ts'], + // Unit specs are colocated with the code they cover, so `rootDir` is `src`. + // + // NOTE: because rootDir is `src`, a `*.spec.ts` written anywhere else in the + // repository matches `testRegex` below but is *never executed* - `npm test` + // still passes, which makes the gap invisible. Tests that need the whole app + // (supertest, TypeOrmModule.forRoot, dropSchema) belong in `test/` and must + // use the `.e2e-spec.ts` suffix so that test/jest-e2e.json picks them up. + // src/config/jest-project-coverage.spec.ts enforces both halves of this. rootDir: 'src', testRegex: '.*\\.spec\\.ts$', transform: { diff --git a/test/oracle/payload-signing.service.spec.ts b/src/blockchain/oracle/services/payload-signing.service.spec.ts similarity index 96% rename from test/oracle/payload-signing.service.spec.ts rename to src/blockchain/oracle/services/payload-signing.service.spec.ts index eeec7e2..16b41fc 100644 --- a/test/oracle/payload-signing.service.spec.ts +++ b/src/blockchain/oracle/services/payload-signing.service.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; -import { Wallet } from "ethers"; -import { PayloadSigningService } from "../../src/blockchain/oracle/services/payload-signing.service"; +import { HDNodeWallet } from "ethers"; +import { PayloadSigningService } from "./payload-signing.service"; describe("PayloadSigningService", () => { let service: PayloadSigningService; - let testWallet: Wallet; + let testWallet: HDNodeWallet; let configService: ConfigService; beforeEach(async () => { // Create a test wallet - testWallet = Wallet.createRandom(); + testWallet = HDNodeWallet.createRandom(); // Mock ConfigService const mockConfigService = { @@ -207,7 +207,7 @@ describe("PayloadSigningService", () => { payload, ); - const differentWallet = Wallet.createRandom(); + const differentWallet = HDNodeWallet.createRandom(); const isValid = service.verifySignature( signature, diff --git a/src/common/utils/random-id.guard.spec.ts b/src/common/utils/random-id.guard.spec.ts new file mode 100644 index 0000000..c684ab9 --- /dev/null +++ b/src/common/utils/random-id.guard.spec.ts @@ -0,0 +1,63 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Regression guard for the `Math.random()` -> `crypto.randomUUID()` migration. + * + * The identifiers that were generated with `Math.random()` are not just ugly: + * they are the members of the rate-limiter sliding-window sorted set and the + * ids of persisted, externally visible rate-limit violation records. A + * non-cryptographic PRNG makes those values predictable and collision-prone, + * which is a correctness problem rather than a style one. + * + * This test fails the moment somebody reintroduces `Math.random()` in the + * directories covered by the issue, so the guarantee does not depend on + * reviewer vigilance. + */ + +const SRC_ROOT = path.resolve(__dirname, "..", ".."); + +/** Directories in scope, per the issue's acceptance criteria. */ +const GUARDED_DIRECTORIES = [ + path.join(SRC_ROOT, "rate-limiting"), + path.join(SRC_ROOT, "observability"), + path.join(SRC_ROOT, "common", "middleware"), + // The issue lists the profiling middleware/service by their pre-move paths + // (src/common/middleware/profiling.middleware.ts and + // src/observability/profiling.service.ts); in the current tree they live in + // src/profiling, so that is the directory that has to stay clean. + path.join(SRC_ROOT, "profiling"), +]; + +/** + * Strip comments so the explanatory comments that *mention* `Math.random()` do + * not trip the guard. Only executable code is interesting here. + */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function collectTypeScriptFiles(dir: string): string[] { + return fs + .readdirSync(dir, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return entry.isFile() && entry.name.endsWith(".ts") ? [entryPath] : []; + }); +} + +describe("Math.random() is not used to build identifiers", () => { + it.each(GUARDED_DIRECTORIES)( + "has no Math.random() call sites in %s", + (dir) => { + const offenders = collectTypeScriptFiles(dir) + .filter((file) => /Math\s*\.\s*random\s*\(/.test(stripComments(fs.readFileSync(file, "utf8")))) + .map((file) => path.relative(SRC_ROOT, file)); + + expect(offenders).toEqual([]); + }, + ); +}); \ No newline at end of file diff --git a/src/common/utils/random-id.spec.ts b/src/common/utils/random-id.spec.ts new file mode 100644 index 0000000..122273c --- /dev/null +++ b/src/common/utils/random-id.spec.ts @@ -0,0 +1,84 @@ +import { + RANDOM_ID_ENTROPY_BITS, + UUID_V4_PATTERN, + generatePrefixedTimestampedId, + generateRandomId, + generateTimestampedId, +} from "./random-id"; + +describe("random-id", () => { + describe("generateRandomId", () => { + it("returns an RFC 4122 v4 UUID", () => { + expect(generateRandomId()).toMatch(UUID_V4_PATTERN); + }); + + it("stays unique across 100k rapid invocations", () => { + const COUNT = 100_000; + const seen = new Set(); + + for (let i = 0; i < COUNT; i++) { + seen.add(generateRandomId()); + } + + expect(seen.size).toBe(COUNT); + }); + }); + + describe("generateTimestampedId", () => { + it("keeps the - shape used by the rate-limiting API", () => { + const [timestamp, uuid] = generateTimestampedId().split("-", 2); + + expect(Number(timestamp)).toBeGreaterThan(0); + expect(uuid).toMatch(UUID_V4_PATTERN); + }); + + it("stays unique across 100k rapid invocations within a single millisecond", () => { + // The whole point of the CSPRNG suffix: the timestamp prefix alone is + // *not* unique, because every call in a tight loop lands in the same + // millisecond. Two violations recorded in that millisecond used to be + // able to collide. + const COUNT = 100_000; + const seen = new Set(); + const prefixes = new Set(); + + for (let i = 0; i < COUNT; i++) { + const id = generateTimestampedId(); + prefixes.add(id.slice(0, id.indexOf("-"))); + seen.add(id); + } + + expect(seen.size).toBe(COUNT); + // Sanity check that the collisions we are guarding against are real: + // the generator genuinely ran inside a small number of milliseconds. + expect(prefixes.size).toBeLessThan(COUNT); + }); + }); + + describe("generatePrefixedTimestampedId", () => { + it("prefixes the id with the supplied type segment", () => { + expect(generatePrefixedTimestampedId("cpu")).toMatch( + /^cpu-\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(generatePrefixedTimestampedId("heap")).toMatch( + /^heap-\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("stays unique across 100k rapid invocations", () => { + const COUNT = 100_000; + const seen = new Set(); + + for (let i = 0; i < COUNT; i++) { + seen.add(generatePrefixedTimestampedId("cpu")); + } + + expect(seen.size).toBe(COUNT); + }); + }); + + it("derives ids from a CSPRNG, not Math.random", () => { + // 122 random bits per v4 UUID. A base-36 `Math.random().toString(36) + // .slice(2, 9)` suffix carried well under 32 bits and collided under load. + expect(RANDOM_ID_ENTROPY_BITS).toBeGreaterThanOrEqual(122); + }); +}); \ No newline at end of file diff --git a/src/common/utils/random-id.ts b/src/common/utils/random-id.ts new file mode 100644 index 0000000..caebb35 --- /dev/null +++ b/src/common/utils/random-id.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "crypto"; + +/** + * Cryptographically secure random identifiers. + * + * These replace ad-hoc `Math.random()` identifiers that were previously used + * for rate-limit violation records, sliding-window bucket members and + * profiling/tracing correlation ids. + * + * `Math.random()` is a non-cryptographic PRNG: it is not seeded from a CSPRNG, + * its output is predictable, and collisions are realistic for the short + * base-36 suffixes that were being generated. That matters because the values + * produced here are: + * + * - written as *members* of the rate-limiter sliding-window sorted set, where a + * collision means one request evicts or double-counts another client's window + * entry (a rate-limit bypass under load), and + * - returned to clients as rate-limit violation `id` values and persisted into + * the audit trail, where a collision makes two distinct violations + * indistinguishable. + * + * `crypto.randomUUID()` (RFC 4122 v4) draws 122 bits from the platform CSPRNG, + * so collisions are not a practical concern. + */ + +/** Number of random bits in a v4 UUID, before the version/variant bits. */ +export const RANDOM_ID_ENTROPY_BITS = 122; + +/** + * Canonical RFC 4122 v4 UUID string (36 chars, hyphenated). + * + * Use for correlation/tracing ids where the value is opaque and only needs to + * be globally unique. + */ +export function generateRandomId(): string { + return randomUUID(); +} + +/** + * `-` request/record identifier. + * + * The leading epoch-millisecond prefix is retained deliberately: several of + * these ids are externally visible on the rate-limiting API and are used to + * build on-disk profile filenames, where the timestamp keeps the entries + * lexicographically sortable. The suffix is now a CSPRNG UUID instead of a + * `Math.random()` base-36 fragment, which is what actually guarantees + * uniqueness. + */ +export function generateTimestampedId(): string { + return `${Date.now()}-${randomUUID()}`; +} + +/** + * `--` identifier. + * + * Used for profiling artefacts (CPU profiles, heap snapshots) where the `type` + * segment makes the on-disk filename self-describing. + */ +export function generatePrefixedTimestampedId(prefix: string): string { + return `${prefix}-${Date.now()}-${randomUUID()}`; +} + +/** + * RFC 4122 v4 UUID shape, used to assert in tests that generated ids keep the + * collision-resistant shape after the `Math.random()` removal. + */ +export const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; \ No newline at end of file diff --git a/src/config/jest-project-coverage.spec.ts b/src/config/jest-project-coverage.spec.ts new file mode 100644 index 0000000..dbfa7ad --- /dev/null +++ b/src/config/jest-project-coverage.spec.ts @@ -0,0 +1,74 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Guards the invariant behind the jest spec relocation: every `*.spec.ts` in + * the repository is reachable by one of the configured Jest projects. + * + * `jest.config.js` pins `rootDir: 'src'`, so a unit spec written anywhere + * else matches `testRegex` but is silently never executed. `npm test` passes, + * the test count looks healthy, and the coverage gap is invisible. Five + * specs were sitting in `test/` in exactly that state. + * + * This fails if a `*.spec.ts` is ever added outside `src/` without also + * matching the e2e project's `testRegex`. + */ + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +/* eslint-disable @typescript-eslint/no-var-requires */ +const UNIT_CONFIG = require(path.join(REPO_ROOT, "jest.config.js")); +const E2E_CONFIG = require(path.join(REPO_ROOT, "test", "jest-e2e.json")); + +const IGNORED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "coverage", + ".git", +]); + +const UNIT_ROOT = path.resolve(REPO_ROOT, UNIT_CONFIG.rootDir); +const UNIT_TEST_REGEX = new RegExp(UNIT_CONFIG.testRegex); +const E2E_TEST_REGEX = new RegExp(E2E_CONFIG.testRegex); + +function collectSpecs(dir: string, found: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (IGNORED_DIRECTORIES.has(entry.name)) continue; + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectSpecs(entryPath, found); + } else if (entry.isFile() && entry.name.endsWith(".spec.ts")) { + found.push(entryPath); + } + } + return found; +} + +describe("jest project coverage", () => { + it("pins the unit suite rootDir to src, so specs are colocated with the code", () => { + // Stated explicitly because it is the reason a spec outside src/ can be + // silently skipped: the rootDir, not the testRegex, decides what runs. + expect(path.relative(REPO_ROOT, UNIT_ROOT)).toBe("src"); + }); + + it("only finds *.spec.ts files that a configured Jest project will run", () => { + const orphaned = collectSpecs(REPO_ROOT).filter((file) => { + if (UNIT_TEST_REGEX.test(file) && file.startsWith(UNIT_ROOT)) return false; + // Anything else has to be picked up by the e2e project instead. + return !E2E_TEST_REGEX.test(file); + }); + + expect( + orphaned.map((file) => path.relative(REPO_ROOT, file)), + ).toEqual([]); + }); + + it("has no *.spec.ts left under test/ (e2e specs use the .e2e-spec.ts suffix)", () => { + const testDir = path.join(REPO_ROOT, "test"); + const unitSpecs = collectSpecs(testDir).filter( + (file) => !E2E_TEST_REGEX.test(file), + ); + + expect(unitSpecs).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts new file mode 100644 index 0000000..619ce95 --- /dev/null +++ b/src/config/jwt.config.ts @@ -0,0 +1,120 @@ +import { JwtModuleOptions } from "@nestjs/jwt"; + +/** + * Deriving the duration/algorithm types from `JwtModuleOptions` rather than + * re-declaring them as `string`/`string[]` keeps this file honest: `jsonwebtoken` + * types `expiresIn` as `number | ms.StringValue` (a template-literal union) and + * `algorithms` as `Algorithm[]`, so a bare `string` is not assignable and the + * compiler catches a malformed TTL at the definition rather than at runtime. + */ +type TokenLifetime = NonNullable< + NonNullable["expiresIn"] +>; +type VerifyAlgorithms = NonNullable< + NonNullable["algorithms"] +>; + +/** + * Single source of truth for JWT lifetimes and algorithm policy. + * + * Previously the access-token lifetime was written out as the string literal + * `"15m"` in two different services (`AuthService.register`/`AuthService.login` + * passed it explicitly, `EnhancedAuthService.generateTokens` relied on the + * `JwtModule` default). That is exactly the kind of drift that lets a token end + * up with no `exp` claim at all - and a token with no `exp` never expires, so a + * leak cannot be contained and logout cannot meaningfully revoke it. Keeping the + * TTL here means both services reference the same value and the module default + * cannot silently change what the services think they are issuing. + */ + +/** + * Access-token lifetime, as a `ms`-style string understood by `jsonwebtoken`. + * Shared by `AuthService` and `EnhancedAuthService`. + */ +export const ACCESS_TOKEN_TTL: TokenLifetime = "15m"; + +/** + * `ACCESS_TOKEN_TTL` expressed in seconds, for assertions and for places that + * need a numeric value (e.g. `exp - iat` checks). + */ +export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; + +/** + * Refresh-token lifetime. Deliberately unchanged - refresh tokens are + * long-lived by design and are revoked server-side via the `refresh_tokens` + * table rather than by expiry alone. + */ +export const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** `REFRESH_TOKEN_TTL_MS` as a `jsonwebtoken` duration string. */ +export const REFRESH_TOKEN_TTL: TokenLifetime = "7d"; + +/** + * Lifetime for the long-lived tokens issued by `DashboardModule` (websocket + * gateway tickets) and `NotificationModule` (consumer tokens). These are + * separate issuers from the 15-minute access-token issuer in `AuthModule`, and + * their TTLs are unchanged. + */ +export const LONG_LIVED_TOKEN_TTL: TokenLifetime = "24h"; + +/** + * Algorithm used to sign new tokens. Pinned explicitly rather than relying on + * `jsonwebtoken`'s default so that changing the default can never silently + * change what we issue. + */ +export const JWT_SIGNING_ALGORITHM = "HS256"; + +/** + * Algorithms accepted during verification. Pinning an allowlist means a token + * presented with some other algorithm is rejected outright instead of the + * verifier deciding for itself. + */ +export const JWT_VERIFY_ALGORITHMS: VerifyAlgorithms = [ + JWT_SIGNING_ALGORITHM, +]; + +export interface JwtIssuerConfig { + /** Signing/verification secret. */ + secret: string; + /** Default lifetime applied when a caller does not pass its own `expiresIn`. */ + expiresIn: TokenLifetime; +} + +/** + * Build the `JwtModule` options for an issuer. + * + * Both the signing and the verification side are pinned: + * + * - `signOptions.algorithm` - so we always issue `HS256`, never whatever the + * library defaults to. + * - `verifyOptions.algorithms` - an explicit allowlist. `jsonwebtoken.verify` + * throws `JsonWebTokenError: invalid algorithm` for anything outside it, + * which blocks algorithm-substitution attempts. + * + * Note that `algorithms` belongs on `verifyOptions` (it is a *verification* + * concern); `JwtModuleOptions` has no top-level `algorithms` key, so putting + * it at the top level of the module options would be silently ignored. + */ +export function buildJwtModuleOptions(config: JwtIssuerConfig): JwtModuleOptions { + return { + secret: config.secret, + signOptions: { + expiresIn: config.expiresIn, + algorithm: JWT_SIGNING_ALGORITHM, + }, + // Copied so callers cannot mutate the shared allowlist. + verifyOptions: { + algorithms: [...JWT_VERIFY_ALGORITHMS], + }, + }; +} + +/** + * Options for the access-token issuer (`AuthModule`), which backs both + * `AuthService` and `EnhancedAuthService`. + */ +export function buildAccessTokenJwtModuleOptions( + secret: string, +): JwtModuleOptions { + return buildJwtModuleOptions({ secret, expiresIn: ACCESS_TOKEN_TTL }); +} \ No newline at end of file diff --git a/src/core/auth/auth-token-ttl.spec.ts b/src/core/auth/auth-token-ttl.spec.ts new file mode 100644 index 0000000..9e98d9b --- /dev/null +++ b/src/core/auth/auth-token-ttl.spec.ts @@ -0,0 +1,281 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { JwtModule, JwtService } from "@nestjs/jwt"; +import { AuthService } from "./auth.service"; +import { EnhancedAuthService } from "./enhanced-auth.service"; +import { EmailService } from "./email.service"; +import { TokenBlacklistService } from "./token-blacklist.service"; +import { User } from "../user/entities/user.entity"; +import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; +import { + ACCESS_TOKEN_TTL, + ACCESS_TOKEN_TTL_SECONDS, + JWT_SIGNING_ALGORITHM, + REFRESH_TOKEN_TTL_MS, + buildAccessTokenJwtModuleOptions, +} from "src/config/jwt.config"; + +jest.mock("bcrypt", () => ({ + hash: jest.fn().mockResolvedValue("hashed-pw"), + compare: jest.fn().mockResolvedValue(true), +})); +jest.mock("speakeasy", () => ({ + generateSecret: jest.fn().mockReturnValue({ + base32: "SECRET", + otpauth_url: "otpauth://totp/test", + }), +})); +jest.mock("qrcode", () => ({ + toDataURL: jest.fn().mockResolvedValue("data:image/png;base64,stub"), +})); + +const SECRET = "test-secret-for-access-token-ttl"; + +/** + * Fixed epoch so the `exp - iat` arithmetic is exact rather than dependent on + * how long the test took to run. + */ +const FIXED_NOW = Date.UTC(2026, 0, 1, 12, 0, 0); + +interface Claims { + sub: string; + iat: number; + exp: number; +} + +describe("access token lifetime and algorithm policy", () => { + let authService: AuthService; + let enhancedAuthService: EnhancedAuthService; + let jwtService: JwtService; + + const storedUser: Partial = { + id: "user-1", + email: "user@example.com", + username: "user", + password: "hashed-pw", + role: undefined as never, + isActive: true, + }; + + const userRepository = { + findOne: jest.fn(), + create: jest.fn((v) => ({ ...storedUser, ...v })), + save: jest.fn(async (v) => v), + update: jest.fn(async () => undefined), + }; + + const refreshTokenRepository = { + create: jest.fn((v) => v), + save: jest.fn(async (v) => v), + update: jest.fn(), + findOne: jest.fn(), + }; + + const twoFactorRepository = { + findOne: jest.fn(async () => null), + find: jest.fn(async () => []), + create: jest.fn((v) => v), + save: jest.fn(async (v) => v), + update: jest.fn(), + delete: jest.fn(), + }; + + const emailService = { + send2faChangeNotification: jest.fn(), + sendEmail: jest.fn(), + }; + + /** Decode the payload without verifying it, so `exp`/`iat` can be read. */ + function decodeClaims(token: string): Claims { + return jwtService.decode(token, { json: true }); + } + + function ttlSecondsOf(token: string): number { + const claims = decodeClaims(token); + return claims.exp - claims.iat; + } + + beforeEach(async () => { + jest.clearAllMocks(); + userRepository.findOne.mockResolvedValue(storedUser); + + const module: TestingModule = await Test.createTestingModule({ + // The *real* issuer configuration, so this test tracks whatever + // AuthModule actually registers instead of a copy of it that can drift. + imports: [JwtModule.register(buildAccessTokenJwtModuleOptions(SECRET))], + providers: [ + AuthService, + EnhancedAuthService, + { provide: getRepositoryToken(User), useValue: userRepository }, + { + provide: getRepositoryToken(RefreshToken), + useValue: refreshTokenRepository, + }, + { + provide: getRepositoryToken(TwoFactorAuth), + useValue: twoFactorRepository, + }, + // Mocked rather than instantiated: the real service opens a + // `setInterval` in its constructor, which keeps a Jest worker alive. + { provide: TokenBlacklistService, useValue: { revoke: jest.fn() } }, + { provide: EmailService, useValue: emailService }, + ], + }).compile(); + + authService = module.get(AuthService); + enhancedAuthService = module.get(EnhancedAuthService); + jwtService = module.get(JwtService); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("ACCESS_TOKEN_TTL is applied to every access token", () => { + it("is 15 minutes", () => { + expect(ACCESS_TOKEN_TTL).toBe("15m"); + expect(ACCESS_TOKEN_TTL_SECONDS).toBe(900); + }); + + it("EnhancedAuthService.login() issues a token with exp - iat === 15m", async () => { + const { accessToken } = await enhancedAuthService.login( + { email: "user@example.com", password: "password123" }, + "127.0.0.1", + "jest", + ); + + expect(decodeClaims(accessToken).exp).toBeDefined(); + expect(ttlSecondsOf(accessToken)).toBe(ACCESS_TOKEN_TTL_SECONDS); + }); + + it("EnhancedAuthService.register() issues a token with exp - iat === 15m", async () => { + userRepository.findOne.mockResolvedValue(null); + + const { accessToken } = await enhancedAuthService.register( + { + email: "new@example.com", + password: "password123", + username: "new", + }, + "127.0.0.1", + "jest", + ); + + expect(ttlSecondsOf(accessToken)).toBe(ACCESS_TOKEN_TTL_SECONDS); + }); + + it("AuthService.login() issues a token with the same TTL as EnhancedAuthService", async () => { + const { token } = await authService.login({ + email: "user@example.com", + password: "password123", + }); + + const { accessToken } = await enhancedAuthService.login( + { email: "user@example.com", password: "password123" }, + "127.0.0.1", + "jest", + ); + + // The regression this guards: the two services used to disagree, because + // only AuthService passed `expiresIn` and EnhancedAuthService relied on + // the JwtModule default. + expect(ttlSecondsOf(token)).toBe(ACCESS_TOKEN_TTL_SECONDS); + expect(ttlSecondsOf(token)).toBe(ttlSecondsOf(accessToken)); + }); + + it("AuthService.register() issues a token with exp - iat === 15m", async () => { + userRepository.findOne.mockResolvedValue(null); + + const { token } = await authService.register({ + email: "new@example.com", + password: "password123", + username: "new", + }); + + expect(ttlSecondsOf(token)).toBe(ACCESS_TOKEN_TTL_SECONDS); + }); + + it("keeps the refresh token lifetime at 7 days", async () => { + jest.useFakeTimers().setSystemTime(FIXED_NOW); + + await enhancedAuthService.login( + { email: "user@example.com", password: "password123" }, + "127.0.0.1", + "jest", + ); + + const calls = refreshTokenRepository.create.mock.calls; + const persisted = calls[calls.length - 1][0]; + + expect(REFRESH_TOKEN_TTL_MS).toBe(7 * 24 * 60 * 60 * 1000); + expect(persisted.expiresAt.getTime()).toBe(FIXED_NOW + REFRESH_TOKEN_TTL_MS); + }); + }); + + describe("tokens past their TTL fail verification", () => { + it("rejects an EnhancedAuthService token once 15m have elapsed", async () => { + jest.useFakeTimers().setSystemTime(FIXED_NOW); + + const { accessToken } = await enhancedAuthService.login( + { email: "user@example.com", password: "password123" }, + "127.0.0.1", + "jest", + ); + + // One second before expiry the token is still accepted. + jest.setSystemTime(FIXED_NOW + (ACCESS_TOKEN_TTL_SECONDS - 1) * 1000); + expect(() => jwtService.verify(accessToken)).not.toThrow(); + + // One second after expiry it is rejected. + jest.setSystemTime(FIXED_NOW + (ACCESS_TOKEN_TTL_SECONDS + 1) * 1000); + expect(() => jwtService.verify(accessToken)).toThrow(/expired/i); + }); + + it("rejects an AuthService token once 15m have elapsed", async () => { + jest.useFakeTimers().setSystemTime(FIXED_NOW); + + const { token } = await authService.login({ + email: "user@example.com", + password: "password123", + }); + + jest.setSystemTime(FIXED_NOW + (ACCESS_TOKEN_TTL_SECONDS + 1) * 1000); + expect(() => jwtService.verify(token)).toThrow(/expired/i); + }); + }); + + describe("algorithm allowlist", () => { + it("signs with the pinned HS256 algorithm", () => { + const token = jwtService.sign( + { sub: "user-1" }, + { expiresIn: ACCESS_TOKEN_TTL }, + ); + const { header } = jwtService.decode<{ header: { alg: string } }>(token, { + complete: true, + }); + + expect(JWT_SIGNING_ALGORITHM).toBe("HS256"); + expect(header.alg).toBe("HS256"); + }); + + it("refuses a token signed with an algorithm outside the allowlist", () => { + // Whoever controls the signing secret must not be able to have a token + // accepted under an algorithm the verifier did not opt into. + const rogue = jwtService.sign( + { sub: "attacker" }, + { secret: SECRET, algorithm: "HS512", expiresIn: ACCESS_TOKEN_TTL }, + ); + + expect(() => jwtService.verify(rogue)).toThrow(/algorithm/i); + }); + + it("still accepts an HS256 token signed with the configured secret", () => { + const token = jwtService.sign( + { sub: "user-1" }, + { expiresIn: ACCESS_TOKEN_TTL }, + ); + + expect(jwtService.verify<{ sub: string }>(token).sub).toBe("user-1"); + }); + }); +}); \ No newline at end of file diff --git a/src/core/auth/auth.module.ts b/src/core/auth/auth.module.ts index ddc5447..73fde45 100644 --- a/src/core/auth/auth.module.ts +++ b/src/core/auth/auth.module.ts @@ -35,6 +35,7 @@ import { User } from "../user/entities/user.entity"; import { EmailVerification } from "./entities/email-verification.entity"; import { Wallet } from "./entities/wallet.entity"; import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; +import { buildAccessTokenJwtModuleOptions } from "src/config/jwt.config"; /** * AuthModule — Authentication Architecture Overview @@ -69,10 +70,10 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - secret: configService.get("JWT_SECRET"), - signOptions: { expiresIn: "15m" }, - }), + useFactory: (configService: ConfigService) => + buildAccessTokenJwtModuleOptions( + configService.get("JWT_SECRET"), + ), }), TypeOrmModule.forFeature([ User, diff --git a/src/core/auth/auth.service.ts b/src/core/auth/auth.service.ts index 780b006..afb05ef 100644 --- a/src/core/auth/auth.service.ts +++ b/src/core/auth/auth.service.ts @@ -13,6 +13,7 @@ import { User } from "../user/entities/user.entity"; import { RegisterDto, LoginDto } from "./dto/auth.dto"; import { TokenBlacklistService } from "./token-blacklist.service"; import { resolveRateLimitTierFromRole } from "src/config/quota.config"; +import { ACCESS_TOKEN_TTL } from "src/config/jwt.config"; @Injectable() export class AuthService { @@ -96,7 +97,9 @@ export class AuthService { jti, tier: resolveRateLimitTierFromRole(user.role), }; - const token = this.jwtService.sign(payload, { expiresIn: "15m" }); + // Same constant as `EnhancedAuthService.generateTokens()` - the two + // services must not be able to drift apart again. + const token = this.jwtService.sign(payload, { expiresIn: ACCESS_TOKEN_TTL }); return { token, @@ -148,7 +151,9 @@ export class AuthService { jti, tier: resolveRateLimitTierFromRole(user.role), }; - const token = this.jwtService.sign(payload, { expiresIn: "15m" }); + // Same constant as `EnhancedAuthService.generateTokens()` - the two + // services must not be able to drift apart again. + const token = this.jwtService.sign(payload, { expiresIn: ACCESS_TOKEN_TTL }); return { token, diff --git a/src/core/auth/email-linking.service.spec.ts b/src/core/auth/email-linking.service.spec.ts new file mode 100644 index 0000000..99ea837 --- /dev/null +++ b/src/core/auth/email-linking.service.spec.ts @@ -0,0 +1,322 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { + BadRequestException, + ConflictException, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import { EmailLinkingService } from "./email-linking.service"; +import { EmailService } from "./email.service"; +import { User } from "../user/entities/user.entity"; +import { EmailVerification } from "./entities/email-verification.entity"; +import { Wallet } from "./entities/wallet.entity"; + +const WALLET_ADDRESS = "0x1234567890123456789012345678901234567890"; + +/** + * The repository mocks are held in local variables rather than being read back + * off `module.get(...)`: `Repository.create` is an overloaded method, so + * `repository.create.mock` is not typed on a `Repository` and the assertion + * would not compile. + */ +type RepositoryMock = { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + delete: jest.Mock; +}; + +/** + * `EmailLinkingService` resolves everything through the Wallet repository + * (`walletRepository.findOne({ where: { address }, relations: ["user"] })`), + * not directly through the User repository, so the Wallet repo is the primary + * seam for these tests. + */ +describe("EmailLinkingService", () => { + let service: EmailLinkingService; + let userRepo: RepositoryMock; + let verificationRepo: RepositoryMock; + let walletRepo: RepositoryMock; + let emailService: { sendVerificationEmail: jest.Mock }; + + // The User entity has many required columns; these are the ones + // EmailLinkingService actually reads. + const buildUser = (overrides: Partial = {}): User => + ({ + id: "user-1", + walletAddress: WALLET_ADDRESS, + email: null, + emailVerified: false, + ...overrides, + }) as unknown as User; + + const buildWallet = (user: User | null): Wallet => + ({ + id: "wallet-1", + address: WALLET_ADDRESS, + user, + }) as unknown as Wallet; + + beforeEach(async () => { + userRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(async (user) => user), + delete: jest.fn(), + }; + verificationRepo = { + findOne: jest.fn(), + create: jest.fn((v) => v), + save: jest.fn(async (v) => v), + delete: jest.fn(), + }; + walletRepo = { + findOne: jest.fn(), + create: jest.fn((v) => v), + save: jest.fn(async (v) => v), + delete: jest.fn(), + }; + emailService = { + sendVerificationEmail: jest.fn().mockResolvedValue({ + messageId: "test-message-id", + previewUrl: "https://ethereal.email/message/test", + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailLinkingService, + { provide: getRepositoryToken(User), useValue: userRepo }, + { + provide: getRepositoryToken(EmailVerification), + useValue: verificationRepo, + }, + { provide: getRepositoryToken(Wallet), useValue: walletRepo }, + { provide: EmailService, useValue: emailService }, + ], + }).compile(); + + service = module.get(EmailLinkingService); + + // Sensible defaults; individual tests override what they care about. + walletRepo.findOne.mockResolvedValue(buildWallet(buildUser())); + userRepo.findOne.mockResolvedValue(null); + verificationRepo.delete.mockResolvedValue(undefined); + }); + + describe("initiateEmailLinking", () => { + it("sends a verification email and returns a preview url", async () => { + const result = await service.initiateEmailLinking( + WALLET_ADDRESS, + "test@example.com", + ); + + expect(result.message).toContain("Verification email sent"); + expect(result.previewUrl).toBe("https://ethereal.email/message/test"); + expect(emailService.sendVerificationEmail).toHaveBeenCalledWith( + "test@example.com", + expect.stringMatching(/^[0-9a-f]{64}$/), + ); + }); + + it("persists a 64 hex character verification token valid for 15 minutes", async () => { + const before = Date.now(); + await service.initiateEmailLinking(WALLET_ADDRESS, "Test@Example.com"); + + const createArgs = verificationRepo.create.mock + .calls[0][0] as EmailVerification; + + expect(createArgs.email).toBe("test@example.com"); + expect(createArgs.walletAddress).toBe(WALLET_ADDRESS.toLowerCase()); + expect(createArgs.token).toMatch(/^[0-9a-f]{64}$/); + expect(createArgs.expiresAt.getTime()).toBeGreaterThanOrEqual( + before + 15 * 60 * 1000, + ); + expect(createArgs.expiresAt.getTime()).toBeLessThanOrEqual( + Date.now() + 15 * 60 * 1000, + ); + }); + + it("rejects an invalid email format", async () => { + await expect( + service.initiateEmailLinking(WALLET_ADDRESS, "invalid-email"), + ).rejects.toThrow(BadRequestException); + }); + + it("rejects an unknown wallet", async () => { + walletRepo.findOne.mockResolvedValue(null); + + await expect( + service.initiateEmailLinking(WALLET_ADDRESS, "test@example.com"), + ).rejects.toThrow(NotFoundException); + }); + + it("rejects an email already linked to a different account", async () => { + userRepo.findOne.mockResolvedValue(buildUser({ id: "someone-else" })); + + await expect( + service.initiateEmailLinking(WALLET_ADDRESS, "test@example.com"), + ).rejects.toThrow(ConflictException); + }); + + it("rejects an email that is already verified for this wallet", async () => { + walletRepo.findOne.mockResolvedValue( + buildWallet( + buildUser({ email: "test@example.com", emailVerified: true }), + ), + ); + + await expect( + service.initiateEmailLinking(WALLET_ADDRESS, "test@example.com"), + ).rejects.toThrow(ConflictException); + }); + }); + + describe("verifyEmailAndLink", () => { + const buildVerification = (expiresAt: Date): EmailVerification => + ({ + id: "1", + email: "test@example.com", + token: "a".repeat(64), + walletAddress: WALLET_ADDRESS, + expiresAt, + createdAt: new Date(), + }) as EmailVerification; + + it("marks the user email as verified and links it to the wallet", async () => { + const user = buildUser(); + verificationRepo.findOne.mockResolvedValue( + buildVerification(new Date(Date.now() + 10 * 60 * 1000)), + ); + walletRepo.findOne.mockResolvedValue(buildWallet(user)); + + const result = await service.verifyEmailAndLink("a".repeat(64)); + + expect(result.message).toContain("successfully verified"); + expect(result.email).toBe("test@example.com"); + expect(result.walletAddress).toBe(WALLET_ADDRESS); + expect(user.email).toBe("test@example.com"); + expect(user.emailVerified).toBe(true); + expect(userRepo.save).toHaveBeenCalled(); + }); + + it("rejects an unknown token", async () => { + verificationRepo.findOne.mockResolvedValue(null); + + await expect( + service.verifyEmailAndLink("invalid-token"), + ).rejects.toThrow(NotFoundException); + }); + + it("rejects an expired token and deletes the record", async () => { + verificationRepo.findOne.mockResolvedValue( + buildVerification(new Date(Date.now() - 1000)), + ); + + await expect( + service.verifyEmailAndLink("a".repeat(64)), + ).rejects.toThrow(UnauthorizedException); + expect(verificationRepo.delete).toHaveBeenCalledWith({ + token: "a".repeat(64), + }); + }); + + it("rejects a verification whose wallet no longer exists", async () => { + verificationRepo.findOne.mockResolvedValue( + buildVerification(new Date(Date.now() + 10 * 60 * 1000)), + ); + walletRepo.findOne.mockResolvedValue(null); + + await expect( + service.verifyEmailAndLink("a".repeat(64)), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("getAccountInfo", () => { + it("returns the linked email for a wallet that has one", async () => { + walletRepo.findOne.mockResolvedValue( + buildWallet( + buildUser({ email: "test@example.com", emailVerified: true }), + ), + ); + + const result = await service.getAccountInfo(WALLET_ADDRESS); + + expect(result.walletAddress).toBe(WALLET_ADDRESS); + expect(result.email).toBe("test@example.com"); + expect(result.emailVerified).toBe(true); + }); + + it("returns a null email when the wallet has none linked", async () => { + walletRepo.findOne.mockResolvedValue(buildWallet(buildUser())); + + const result = await service.getAccountInfo(WALLET_ADDRESS); + + expect(result.email).toBeNull(); + expect(result.emailVerified).toBe(false); + }); + + it("returns a null email when the wallet is unknown", async () => { + walletRepo.findOne.mockResolvedValue(null); + + const result = await service.getAccountInfo(WALLET_ADDRESS); + + expect(result.email).toBeNull(); + expect(result.emailVerified).toBe(false); + }); + }); + + describe("unlinkEmail", () => { + it("clears the email and any pending verification", async () => { + const user = buildUser({ + email: "test@example.com", + emailVerified: true, + }); + walletRepo.findOne.mockResolvedValue(buildWallet(user)); + + const result = await service.unlinkEmail(WALLET_ADDRESS); + + expect(result.message).toContain("successfully unlinked"); + expect(user.email).toBeNull(); + expect(user.emailVerified).toBe(false); + expect(verificationRepo.delete).toHaveBeenCalledWith({ + walletAddress: WALLET_ADDRESS.toLowerCase(), + }); + }); + + it("throws when no email is linked", async () => { + walletRepo.findOne.mockResolvedValue(buildWallet(buildUser())); + + await expect( + service.unlinkEmail(WALLET_ADDRESS), + ).rejects.toThrow(NotFoundException); + }); + + it("throws when the wallet is unknown", async () => { + walletRepo.findOne.mockResolvedValue(null); + + await expect( + service.unlinkEmail(WALLET_ADDRESS), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("getUserByEmail", () => { + it("looks the user up by a normalised, verified-only email", async () => { + const user = buildUser({ + email: "test@example.com", + emailVerified: true, + }); + userRepo.findOne.mockResolvedValue(user); + + await expect(service.getUserByEmail("TEST@example.com")).resolves.toBe( + user, + ); + expect(userRepo.findOne).toHaveBeenCalledWith({ + where: { email: "test@example.com", emailVerified: true }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/core/auth/enhanced-auth.service.credentials.spec.ts b/src/core/auth/enhanced-auth.service.credentials.spec.ts new file mode 100644 index 0000000..d04b2bd --- /dev/null +++ b/src/core/auth/enhanced-auth.service.credentials.spec.ts @@ -0,0 +1,317 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { JwtService } from "@nestjs/jwt"; +import { + BadRequestException, + ConflictException, + UnauthorizedException, +} from "@nestjs/common"; +import * as bcrypt from "bcrypt"; +import { EnhancedAuthService } from "./enhanced-auth.service"; +import { EmailService } from "./email.service"; +import { User } from "../user/entities/user.entity"; +import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; + +jest.mock("bcrypt", () => ({ + hash: jest.fn().mockResolvedValue("hashed-pw"), + compare: jest.fn(), +})); +jest.mock("speakeasy", () => ({ + generateSecret: jest.fn().mockReturnValue({ + base32: "SECRET", + otpauth_url: "otpauth://totp/test", + }), +})); +jest.mock("qrcode", () => ({ + toDataURL: jest.fn().mockResolvedValue("data:image/png;base64,stub"), +})); + +/** + * Registration, login and refresh-token behaviour. + * + * `enhanced-auth.service.spec.ts` covers the 2FA flows and + * `auth-token-ttl.spec.ts` covers token lifetimes; neither exercises the + * credential guard rails or refresh-token rotation. This suite does. + */ +describe("EnhancedAuthService credentials and refresh tokens", () => { + let service: EnhancedAuthService; + let userRepository: { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; + }; + let refreshTokenRepository: { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; + }; + let twoFactorRepository: { findOne: jest.Mock }; + let jwtService: { sign: jest.Mock }; + + const activeUser = { + id: "user-1", + email: "user@example.com", + username: "user", + password: "hashed-pw", + role: undefined, + isActive: true, + emailVerified: true, + } as unknown as User; + + beforeEach(async () => { + jest.clearAllMocks(); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + userRepository = { + findOne: jest.fn(), + create: jest.fn((v) => ({ ...activeUser, ...v })), + save: jest.fn(async (v) => v), + update: jest.fn(), + }; + refreshTokenRepository = { + findOne: jest.fn(), + create: jest.fn((v) => v), + save: jest.fn(async (v) => v), + update: jest.fn(), + }; + twoFactorRepository = { findOne: jest.fn().mockResolvedValue(null) }; + jwtService = { sign: jest.fn().mockReturnValue("signed.jwt.token") }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EnhancedAuthService, + { provide: getRepositoryToken(User), useValue: userRepository }, + { + provide: getRepositoryToken(RefreshToken), + useValue: refreshTokenRepository, + }, + { + provide: getRepositoryToken(TwoFactorAuth), + useValue: twoFactorRepository, + }, + { provide: JwtService, useValue: jwtService }, + { + provide: EmailService, + useValue: { + send2faChangeNotification: jest.fn(), + sendEmail: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(EnhancedAuthService); + }); + + describe("register", () => { + const dto = { + email: "new@example.com", + password: "password123", + username: "newuser", + }; + + it("creates the account and returns a token pair", async () => { + userRepository.findOne.mockResolvedValue(null); + + const result = await service.register(dto, "127.0.0.1", "jest"); + + expect(result.accessToken).toBeTruthy(); + expect(result.refreshToken).toBeTruthy(); + expect(result.requiresTwoFactor).toBe(false); + expect(userRepository.save).toHaveBeenCalled(); + expect(refreshTokenRepository.save).toHaveBeenCalled(); + }); + + it("rejects an email that is already registered", async () => { + userRepository.findOne.mockResolvedValue(activeUser); + + await expect(service.register(dto, "127.0.0.1")).rejects.toThrow( + new ConflictException("Email already registered"), + ); + }); + + it("rejects a username that is already taken", async () => { + userRepository.findOne.mockResolvedValue({ + ...activeUser, + email: "different@example.com", + username: "newuser", + }); + + await expect(service.register(dto, "127.0.0.1")).rejects.toThrow( + new ConflictException("Username already taken"), + ); + }); + + it("rejects an unknown referral code", async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.register( + { ...dto, referralCode: "NOPE12" }, + "127.0.0.1", + ), + ).rejects.toThrow(new BadRequestException("Invalid referral code")); + }); + }); + + describe("login", () => { + const dto = { email: "user@example.com", password: "password123" }; + + it("returns a token pair for valid credentials", async () => { + userRepository.findOne.mockResolvedValue(activeUser); + + const result = await service.login(dto, "127.0.0.1"); + + expect(result.accessToken).toBeTruthy(); + expect(result.refreshToken).toBeTruthy(); + // No 2FA configured, so the token is already 2FA-verified. + expect(result.requiresTwoFactor).toBe(false); + }); + + it("records the login timestamp", async () => { + userRepository.findOne.mockResolvedValue(activeUser); + + await service.login(dto, "127.0.0.1"); + + expect(userRepository.update).toHaveBeenCalledWith( + activeUser.id, + expect.objectContaining({ lastLoginAt: expect.any(Date) }), + ); + }); + + it("rejects an unknown email", async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect(service.login(dto, "127.0.0.1")).rejects.toThrow( + UnauthorizedException, + ); + }); + + it("rejects a wrong password", async () => { + userRepository.findOne.mockResolvedValue(activeUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect(service.login(dto, "127.0.0.1")).rejects.toThrow( + new UnauthorizedException("Invalid credentials"), + ); + expect(refreshTokenRepository.save).not.toHaveBeenCalled(); + }); + + it("rejects a deactivated account", async () => { + userRepository.findOne.mockResolvedValue({ + ...activeUser, + isActive: false, + }); + + await expect(service.login(dto, "127.0.0.1")).rejects.toThrow( + new UnauthorizedException("Account is deactivated"), + ); + }); + + it("points wallet-only accounts at wallet login", async () => { + userRepository.findOne.mockResolvedValue({ + ...activeUser, + password: null, + }); + + await expect(service.login(dto, "127.0.0.1")).rejects.toThrow( + new BadRequestException( + "This account uses wallet authentication. Please use wallet login.", + ), + ); + }); + }); + + describe("refreshToken", () => { + const validToken = { + id: "rt-1", + token: "stored-refresh-token", + user: activeUser, + expiresAt: new Date(Date.now() + 60_000), + }; + + it("rotates the refresh token and revokes the old one", async () => { + refreshTokenRepository.findOne.mockResolvedValue(validToken); + + const result = await service.refreshToken( + { refreshToken: "stored-refresh-token" }, + "127.0.0.1", + ); + + expect(result.accessToken).toBeTruthy(); + expect(result.refreshToken).toBeTruthy(); + expect(result.refreshToken).not.toBe("stored-refresh-token"); + + expect(refreshTokenRepository.update).toHaveBeenCalledWith( + "rt-1", + expect.objectContaining({ + revoked: true, + revokedAt: expect.any(Date), + replacedByToken: result.refreshToken, + }), + ); + }); + + it("issues a non-2FA-verified token when 2FA is configured", async () => { + refreshTokenRepository.findOne.mockResolvedValue(validToken); + twoFactorRepository.findOne.mockResolvedValue({ userId: activeUser.id }); + + await service.refreshToken( + { refreshToken: "stored-refresh-token" }, + "127.0.0.1", + ); + + const calls = jwtService.sign.mock.calls; + expect(calls[calls.length - 1][0].twoFactorVerified).toBe(false); + }); + + it("rejects an unknown refresh token", async () => { + refreshTokenRepository.findOne.mockResolvedValue(null); + + await expect( + service.refreshToken({ refreshToken: "nope" }, "127.0.0.1"), + ).rejects.toThrow( + new UnauthorizedException("Invalid or expired refresh token"), + ); + }); + + it("rejects an expired refresh token", async () => { + refreshTokenRepository.findOne.mockResolvedValue({ + ...validToken, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + service.refreshToken({ refreshToken: "stored-refresh-token" }, "127.0.0.1"), + ).rejects.toThrow(UnauthorizedException); + expect(refreshTokenRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe("revokeAllRefreshTokens", () => { + it("revokes every live refresh token for the user", async () => { + await service.revokeAllRefreshTokens("user-1"); + + expect(refreshTokenRepository.update).toHaveBeenCalledWith( + { userId: "user-1", revoked: false }, + { revoked: true, revokedAt: expect.any(Date) }, + ); + }); + }); + + describe("validateUser", () => { + it("returns the user when one exists", async () => { + userRepository.findOne.mockResolvedValue(activeUser); + + await expect(service.validateUser("user-1")).resolves.toBe(activeUser); + }); + + it("returns null when no user exists", async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect(service.validateUser("missing")).resolves.toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/core/auth/enhanced-auth.service.ts b/src/core/auth/enhanced-auth.service.ts index 1202c97..325183d 100644 --- a/src/core/auth/enhanced-auth.service.ts +++ b/src/core/auth/enhanced-auth.service.ts @@ -19,6 +19,10 @@ import * as qrcode from "qrcode"; import { EmailService } from "./email.service"; import { User } from "src/core/user/entities/user.entity"; import { resolveRateLimitTierFromRole } from "src/config/quota.config"; +import { + ACCESS_TOKEN_TTL, + REFRESH_TOKEN_TTL_MS, +} from "src/config/jwt.config"; import { normalizeRole } from "src/common/guard/roles.enum"; import { RefreshToken, @@ -561,14 +565,21 @@ export class EnhancedAuthService { tier: resolveRateLimitTierFromRole(user.role), twoFactorVerified, }; - const accessToken = this.jwtService.sign(payload); + // Sign with an *explicit* `expiresIn`. Relying on the JwtModule default is + // what allowed this call site to silently drift from `AuthService`: if the + // module ever registers without `signOptions.expiresIn`, the resulting + // token has no `exp` claim at all and therefore never expires - a leaked + // access token would stay valid forever and logout could not revoke it. + const accessToken = this.jwtService.sign(payload, { + expiresIn: ACCESS_TOKEN_TTL, + }); // Generate refresh token const refreshTokenValue = this.generateRefreshToken(); const refreshTokenEntity = this.refreshTokenRepository.create({ userId: user.id, token: refreshTokenValue, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), ipAddress, userAgent, }); diff --git a/test/recovery.spec.ts b/src/core/auth/recovery.service.spec.ts similarity index 88% rename from test/recovery.spec.ts rename to src/core/auth/recovery.service.spec.ts index 2df4fbb..be412f6 100644 --- a/test/recovery.spec.ts +++ b/src/core/auth/recovery.service.spec.ts @@ -1,10 +1,10 @@ import { Test, TestingModule } from "@nestjs/testing"; import { NotFoundException } from "@nestjs/common"; -import { RecoveryService } from "../src/core/auth/recovery.service"; -import { EmailLinkingService } from "../src/core/auth/email-linking.service"; -import { EmailService } from "../src/core/auth/email.service"; -import { ChallengeService } from "../src/core/auth/challenge.service"; -import { User } from "../src/core/user/entities/user.entity"; +import { RecoveryService } from "./recovery.service"; +import { EmailLinkingService } from "./email-linking.service"; +import { EmailService } from "./email.service"; +import { ChallengeService } from "./challenge.service"; +import { User } from "../user/entities/user.entity"; describe("RecoveryService", () => { let service: RecoveryService; @@ -12,14 +12,16 @@ describe("RecoveryService", () => { let emailService: EmailService; let challengeService: ChallengeService; - const mockUser: User = { + // `User` has a lot of required columns; only the ones this service reads + // (`walletAddress` via getUserByEmail) are populated here. + const mockUser = { id: "123", walletAddress: "0x1234567890123456789012345678901234567890", email: "test@example.com", emailVerified: true, createdAt: new Date(), updatedAt: new Date(), - }; + } as unknown as User; const mockEmailLinkingService = { getUserByEmail: jest.fn(), diff --git a/src/core/auth/wallet-auth.challenge-flow.spec.ts b/src/core/auth/wallet-auth.challenge-flow.spec.ts new file mode 100644 index 0000000..a9bf535 --- /dev/null +++ b/src/core/auth/wallet-auth.challenge-flow.spec.ts @@ -0,0 +1,344 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { JwtService } from "@nestjs/jwt"; +import { ConfigService } from "@nestjs/config"; +import { UnauthorizedException } from "@nestjs/common"; +import { HDNodeWallet } from "ethers"; +import { WalletAuthService } from "./wallet-auth.service"; +import { ChallengeService } from "./challenge.service"; +import { EnhancedAuthService } from "./enhanced-auth.service"; +import { EmailService } from "./email.service"; +import { User, UserRole } from "../user/entities/user.entity"; +// Aliased: `HDNodeWallet` here is the ethers signing wallet, the entity is +// what `WalletAuthService` injects its repository for. In ethers v6 +// `Wallet.createRandom()` returns an `HDNodeWallet`, which is a sibling of +// `Wallet` rather than a subtype, so the variable needs the concrete type. +import { + Wallet as WalletEntity, + WalletType, + WalletStatus, +} from "./entities/wallet.entity"; +import { ProvenanceService } from "src/infrastructure/audit/provenance.service"; + +/** + * Covers the challenge -> sign -> verify -> validate login flow. + * + * `src/core/auth/wallet-auth.service.spec.ts` already covers the + * wallet-management half of the service (linkWallet / unlinkWallet / + * recoverWallet / validateToken) with `ethers` mocked out. This suite takes + * the other half: it uses the real `ChallengeService` and real ethers + * signatures, so it exercises the cryptographic path rather than a stub. + */ +describe("WalletAuthService challenge and signature flow", () => { + let walletAuthService: WalletAuthService; + let challengeService: ChallengeService; + let jwtService: JwtService; + let walletRepository: { findOne: jest.Mock }; + let provenanceService: { createProvenanceRecord: jest.Mock }; + let testWallet: HDNodeWallet; + + const mockUser = { + id: "user-1", + email: "user@example.com", + emailVerified: true, + role: UserRole.USER, + } as unknown as User; + + const linkedWallet = (address: string) => + ({ + id: "wallet-1", + address, + type: WalletType.PRIMARY, + status: WalletStatus.ACTIVE, + user: mockUser, + }) as unknown as WalletEntity; + + const mockJwtService = { + sign: jest.fn(), + verify: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockJwtService.sign.mockImplementation( + (payload) => `test-token-${JSON.stringify(payload)}`, + ); + mockJwtService.verify.mockImplementation((token) => { + if (typeof token === "string" && token.startsWith("test-token-")) { + return JSON.parse(token.slice("test-token-".length)); + } + throw new Error("Invalid token"); + }); + + provenanceService = { + createProvenanceRecord: jest.fn().mockResolvedValue(undefined), + }; + walletRepository = { findOne: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WalletAuthService, + // Real: the challenge lifecycle is part of what is under test. + ChallengeService, + { provide: JwtService, useValue: mockJwtService }, + { + provide: EnhancedAuthService, + useValue: { + isTwoFactorEnabled: jest.fn().mockResolvedValue(false), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { findOne: jest.fn().mockResolvedValue(mockUser) }, + }, + { provide: getRepositoryToken(WalletEntity), useValue: walletRepository }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue(undefined) }, + }, + { + provide: EmailService, + useValue: { + sendWalletLinkedNotification: jest.fn(), + sendWalletUnlinkedNotification: jest.fn(), + }, + }, + { provide: ProvenanceService, useValue: provenanceService }, + ], + }).compile(); + + walletAuthService = module.get(WalletAuthService); + challengeService = module.get(ChallengeService); + jwtService = module.get(JwtService); + + testWallet = HDNodeWallet.createRandom(); + }); + + describe("challenge issuance", () => { + it("issues a signable message bound to the address", () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + + expect(message).toContain("Sign this message to authenticate:"); + }); + + it("carries a 32 byte (64 hex character) challenge id", () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const challengeId = challengeService.extractChallengeId(message); + + expect(challengeId).toMatch(/^[0-9a-f]{64}$/); + }); + + it("issues a different challenge per request", () => { + const address = testWallet.address; + const first = challengeService.extractChallengeId( + challengeService.issueChallengeForAddress(address), + ); + const second = challengeService.extractChallengeId( + challengeService.issueChallengeForAddress(address), + ); + + expect(first).not.toBe(second); + }); + + it("stores the challenge with a lowercased address and a future expiry", () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const challenge = challengeService.getChallenge( + challengeService.extractChallengeId(message), + ); + + expect(challenge.address).toBe(testWallet.address.toLowerCase()); + expect(challenge.expiresAt).toBeGreaterThan(Date.now()); + }); + }); + + describe("signature verification", () => { + beforeEach(() => { + // A wallet must already be linked to an account for a token to be + // issued; an unlinked wallet is rejected outright. + walletRepository.findOne.mockImplementation( + async ({ where }) => linkedWallet(where.address), + ); + }); + + it("issues a token for a valid signature on a linked wallet", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const signature = await testWallet.signMessage(message); + + const result = await walletAuthService.verifySignatureAndIssueToken( + message, + signature, + ); + + expect(result.token).toBeTruthy(); + expect(result.address).toBe(testWallet.address.toLowerCase()); + expect(jwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: mockUser.id, + address: testWallet.address.toLowerCase(), + }), + ); + }); + + it("records a successful provenance entry", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const signature = await testWallet.signMessage(message); + + await walletAuthService.verifySignatureAndIssueToken(message, signature); + + expect(provenanceService.createProvenanceRecord).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "wallet-auth", + input: expect.objectContaining({ success: true }), + }), + ); + }); + + it("consumes the challenge so it cannot be replayed", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const challengeId = challengeService.extractChallengeId(message); + const signature = await testWallet.signMessage(message); + + await walletAuthService.verifySignatureAndIssueToken(message, signature); + + expect(challengeService.getChallenge(challengeId)).toBeNull(); + }); + + it("rejects a malformed signature", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const invalidSignature = `0x${"0".repeat(130)}`; + + await expect( + walletAuthService.verifySignatureAndIssueToken( + message, + invalidSignature, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it("rejects a signature from a different wallet", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const otherWallet = HDNodeWallet.createRandom(); + const signature = await otherWallet.signMessage(message); + + await expect( + walletAuthService.verifySignatureAndIssueToken(message, signature), + ).rejects.toThrow(UnauthorizedException); + }); + + it("rejects a challenge that was already consumed", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + challengeService.consumeChallenge( + challengeService.extractChallengeId(message), + ); + const signature = await testWallet.signMessage(message); + + await expect( + walletAuthService.verifySignatureAndIssueToken(message, signature), + ).rejects.toThrow( + new UnauthorizedException( + "Challenge not found or expired. Please request a new challenge.", + ), + ); + }); + + it("rejects a message that is not a challenge at all", async () => { + const signature = await testWallet.signMessage("hello"); + + await expect( + walletAuthService.verifySignatureAndIssueToken("hello", signature), + ).rejects.toThrow(UnauthorizedException); + }); + + it("rejects a valid signature from a wallet that is not linked to an account", async () => { + walletRepository.findOne.mockResolvedValue(null); + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const signature = await testWallet.signMessage(message); + + await expect( + walletAuthService.verifySignatureAndIssueToken(message, signature), + ).rejects.toThrow(UnauthorizedException); + expect(jwtService.sign).not.toHaveBeenCalled(); + }); + + it("records a failed provenance entry when verification fails", async () => { + const message = challengeService.issueChallengeForAddress( + testWallet.address, + ); + const otherWallet = HDNodeWallet.createRandom(); + const signature = await otherWallet.signMessage(message); + + await expect( + walletAuthService.verifySignatureAndIssueToken(message, signature), + ).rejects.toThrow(UnauthorizedException); + expect(provenanceService.createProvenanceRecord).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ success: false }), + }), + ); + }); + }); + + describe("token validation", () => { + it("returns the payload for a token it issued", () => { + const payload = { + address: testWallet.address.toLowerCase(), + iat: Math.floor(Date.now() / 1000), + }; + + expect(walletAuthService.validateToken(jwtService.sign(payload))).toEqual( + payload, + ); + }); + + it("rejects a malformed token", () => { + expect(() => walletAuthService.validateToken("invalid-token")).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("end-to-end flow", () => { + it("authenticates a wallet from challenge through to a validated token", async () => { + walletRepository.findOne.mockImplementation(async ({ where }) => + linkedWallet(where.address), + ); + const address = testWallet.address; + + const message = challengeService.issueChallengeForAddress(address); + expect(message).toContain("Sign this message to authenticate:"); + + const signature = await testWallet.signMessage(message); + + const { token } = await walletAuthService.verifySignatureAndIssueToken( + message, + signature, + ); + expect(token).toBeTruthy(); + + expect(walletAuthService.validateToken(token).address).toBe( + address.toLowerCase(), + ); + }); + }); +}); \ No newline at end of file diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts index 5e04d30..d6f81e9 100644 --- a/src/dashboard/dashboard.module.ts +++ b/src/dashboard/dashboard.module.ts @@ -17,6 +17,7 @@ import { } from "./websocket/services/reconnection.service"; import { WebSocketHealthService } from "./websocket/services/websocket-health.service"; import { WsExceptionFilter } from "./websocket/filters/ws-exception.filter"; +import { buildJwtModuleOptions, LONG_LIVED_TOKEN_TTL } from "src/config/jwt.config"; @Module({ imports: [ @@ -24,12 +25,15 @@ import { WsExceptionFilter } from "./websocket/filters/ws-exception.filter"; JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - secret: - configService.get("JWT_SECRET") || - "default-secret-change-in-production", - signOptions: { expiresIn: "24h" }, - }), + useFactory: (configService: ConfigService) => + buildJwtModuleOptions({ + secret: + configService.get("JWT_SECRET") || + "default-secret-change-in-production", + // The websocket gateway issues its own long-lived ticket, separate + // from the 15m access-token issuer in AuthModule. Unchanged. + expiresIn: LONG_LIVED_TOKEN_TTL, + }), }), ], controllers: [DashboardController], diff --git a/src/health/dto/health-response.dto.ts b/src/health/dto/health-response.dto.ts index fe7fdb3..981470c 100644 --- a/src/health/dto/health-response.dto.ts +++ b/src/health/dto/health-response.dto.ts @@ -1,8 +1,26 @@ import { ApiProperty } from "@nestjs/swagger"; +export enum ComponentStatus { + UP = "up", + DOWN = "down", + DEGRADED = "degraded", + UNKNOWN = "unknown", +} + +export enum HealthStatus { + OK = "ok", + DEGRADED = "degraded", + ERROR = "error", +} + export class ComponentStatusDto { - @ApiProperty({ enum: ["up", "down"], example: "up" }) - status: "up" | "down"; + @ApiProperty({ + enum: ComponentStatus, + enumName: "ComponentStatus", + example: ComponentStatus.UP, + description: "Component availability status: up, down, degraded, or unknown", + }) + status: ComponentStatus | "up" | "down" | "degraded" | "unknown"; @ApiProperty({ required: false, @@ -17,14 +35,15 @@ export class ComponentStatusDto { export class HealthResponseDto { @ApiProperty({ - enum: ["ok", "degraded", "error"], - example: "ok", + enum: HealthStatus, + enumName: "HealthStatus", + example: HealthStatus.OK, description: "ok = all components up; degraded = some up; error = all critical components down", }) - status: "ok" | "degraded" | "error"; + status: HealthStatus | "ok" | "degraded" | "error"; - @ApiProperty({ example: "2024-01-01T00:00:00.000Z" }) + @ApiProperty({ example: "2024-01-01T00:00:00.000Z", description: "ISO 8601 evaluation timestamp" }) timestamp: string; @ApiProperty({ example: 123.456, description: "Process uptime in seconds" }) @@ -37,6 +56,7 @@ export class HealthResponseDto { database: { status: "up", responseTime: 5 }, redis: { status: "up", responseTime: 2 }, }, + description: "Map of component health details keyed by subsystem identifier", }) components: Record; -} +} \ No newline at end of file diff --git a/src/infrastructure/file-upload/file-upload.controller.spec.ts b/src/infrastructure/file-upload/file-upload.controller.spec.ts new file mode 100644 index 0000000..4158570 --- /dev/null +++ b/src/infrastructure/file-upload/file-upload.controller.spec.ts @@ -0,0 +1,104 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { FileUploadController } from "./file-upload.controller"; +import { FileStorageService } from "./services/file-storage.service"; +import { FileCleanupService } from "./services/file-cleanup.service"; +import { FileScanService } from "./services/file-scan.service"; +import { FileMetadataService } from "./services/file-metadata.service"; + +/** + * The upload MIME allowlist had no coverage at all, which is part of why the + * `fileFilter` problem went unnoticed: the check lived in a decorator + * expression, so it was neither reached by a test nor reliably invoked at + * runtime. It is now enforced in `uploadFile()`, which is what these exercise. + */ +describe("FileUploadController upload allowlist", () => { + let controller: FileUploadController; + let storageService: { upload: jest.Mock }; + let configService: { get: jest.Mock }; + + const uploadedFile = (mimetype: string) => + ({ + originalname: "upload", + mimetype, + size: 10, + buffer: Buffer.from("data"), + }) as unknown as Express.Multer.File; + + const user = { id: "user-1" } as never; + const dto = {} as never; + + beforeEach(async () => { + jest.clearAllMocks(); + + storageService = { upload: jest.fn().mockResolvedValue({ file: {}, thumbnails: [] }) }; + configService = { get: jest.fn().mockReturnValue(undefined) }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FileUploadController], + providers: [ + { provide: FileStorageService, useValue: storageService }, + { provide: FileCleanupService, useValue: {} }, + { provide: FileScanService, useValue: {} }, + { provide: FileMetadataService, useValue: {} }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + controller = module.get(FileUploadController); + }); + + describe("default allowlist (FILE_ALLOWED_MIME_TYPES unset)", () => { + it("accepts an allowed mime type", async () => { + await expect( + controller.uploadFile(uploadedFile("image/png"), dto, user), + ).resolves.toBeDefined(); + expect(storageService.upload).toHaveBeenCalled(); + }); + + it("rejects a mime type that is not on the allowlist", async () => { + await expect( + controller.uploadFile(uploadedFile("application/x-msdownload"), dto, user), + ).rejects.toThrow(BadRequestException); + expect(storageService.upload).not.toHaveBeenCalled(); + }); + + it("rejects a request with no file", async () => { + await expect( + controller.uploadFile(undefined as never, dto, user), + ).rejects.toThrow("No file provided"); + expect(storageService.upload).not.toHaveBeenCalled(); + }); + }); + + describe("configured allowlist (FILE_ALLOWED_MIME_TYPES set)", () => { + it("honours the configured list instead of the default", async () => { + configService.get.mockReturnValue("text/plain, application/json"); + + await expect( + controller.uploadFile(uploadedFile("text/plain"), dto, user), + ).resolves.toBeDefined(); + + // On the default list, but not on the configured one. + await expect( + controller.uploadFile(uploadedFile("image/png"), dto, user), + ).rejects.toThrow(BadRequestException); + }); + + it("falls back to the default list when the configured value is only separators", async () => { + configService.get.mockReturnValue(" , ,"); + + await expect( + controller.uploadFile(uploadedFile("image/png"), dto, user), + ).resolves.toBeDefined(); + }); + }); + + it("rejects before touching storage when the user is unknown", async () => { + await expect( + controller.uploadFile(uploadedFile("image/png"), dto, undefined as never), + ).rejects.toThrow("User identity not found"); + expect(storageService.upload).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/file-upload/file-upload.controller.ts b/src/infrastructure/file-upload/file-upload.controller.ts index a455b1e..9e30d52 100644 --- a/src/infrastructure/file-upload/file-upload.controller.ts +++ b/src/infrastructure/file-upload/file-upload.controller.ts @@ -39,6 +39,60 @@ import { FileSearchDto, FileCleanupDto, } from "./dto/file-upload.dto"; +import { CurrentUser } from "src/core/auth/decorators/current-user.decorator"; +import { AuthUser } from "src/core/auth/strategies/interfaces/auth-strategy.interface"; +import { BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +/** + * Default MIME type allowlist used when FILE_ALLOWED_MIME_TYPES is not set. + * Mirrors the FileValidationService default categories. + */ +const DEFAULT_ALLOWED_MIME_TYPES = [ + // Images + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "image/tiff", + "image/bmp", + "image/avif", + // Documents + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain", + "text/csv", + "application/json", + "application/xml", + // Video + "video/mp4", + "video/webm", + "video/ogg", + "video/quicktime", + "video/x-msvideo", + "video/x-matroska", + // Audio + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/webm", + "audio/aac", + "audio/flac", + "audio/mp4", + // Archives + "application/zip", + "application/x-tar", + "application/gzip", + "application/x-rar-compressed", + "application/x-7z-compressed", + "application/x-bzip2", +]; @ApiTags("Files") @ApiBearerAuth() @@ -51,8 +105,25 @@ export class FileUploadController { private readonly cleanupService: FileCleanupService, private readonly scanService: FileScanService, private readonly metadataService: FileMetadataService, + private readonly configService: ConfigService, ) {} + /** + * Resolves the MIME type allowlist from FILE_ALLOWED_MIME_TYPES env var. + * Falls back to the default list when unset or empty. + */ + private getAllowedMimeTypes(): Set { + const raw = this.configService.get("FILE_ALLOWED_MIME_TYPES"); + if (!raw) { + return new Set(DEFAULT_ALLOWED_MIME_TYPES); + } + const parsed = raw + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + return parsed.length > 0 ? new Set(parsed) : new Set(DEFAULT_ALLOWED_MIME_TYPES); + } + // ── Upload ──────────────────────────────────────────────────────── @Post("upload") @@ -62,6 +133,19 @@ export class FileUploadController { limits: { fileSize: 50 * 1024 * 1024, // 50MB }, + // NOTE: the MIME allowlist check deliberately lives in `uploadFile()` + // rather than in a multer `fileFilter` here. + // + // Decorator expressions are evaluated once, when the class is defined - + // not per request - so an arrow function inside one captures whatever + // `this` is at class-definition time (the class/prototype), and then + // reuses it for every subsequent request. Calling a prototype method + // through it is not the instance method, which is both why the type + // checker flags this and why the check was never reliable. `uploadFile` + // runs per request against the real instance, so `this` is correct. + // + // The size limit stays on the interceptor: multer enforces it while + // streaming, before the body is buffered, so it must not be moved. }), ) @ApiOperation({ summary: "Upload a file" }) @@ -85,8 +169,22 @@ export class FileUploadController { async uploadFile( @UploadedFile() file: Express.Multer.File, @Body() dto: UploadFileDto, + @CurrentUser() user: AuthUser, ) { - const userId = "system"; + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + + // MIME allowlist. See the note on the interceptor above for why this is + // here rather than in a multer `fileFilter`. + if (!file) { + throw new BadRequestException("No file provided"); + } + if (!this.getAllowedMimeTypes().has(file.mimetype)) { + throw new BadRequestException(`Unsupported file type: ${file.mimetype}`); + } + + const userId = user.id; const result = await this.storageService.upload(file, userId, dto); return { @@ -114,12 +212,16 @@ export class FileUploadController { @ApiQuery({ name: "limit", required: false }) @ApiQuery({ name: "offset", required: false }) async listFiles( + @CurrentUser() user: AuthUser, @Query("name") name?: string, @Query("category") category?: string, @Query("limit") limit?: number, @Query("offset") offset?: number, ) { - const userId = "system"; + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const { files, total } = await this.storageService.search( { name, @@ -148,8 +250,11 @@ export class FileUploadController { @Get("search") @ApiOperation({ summary: "Search files with filters" }) - async searchFiles(@Query() dto: FileSearchDto) { - const userId = "system"; + async searchFiles(@Query() dto: FileSearchDto, @CurrentUser() user: AuthUser) { + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const { files, total } = await this.storageService.search(dto, userId); return { @@ -262,8 +367,11 @@ export class FileUploadController { @Get("stats/overview") @ApiOperation({ summary: "Get file storage statistics" }) - async getStats() { - const userId = "system"; + async getStats(@CurrentUser() user: AuthUser) { + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const stats = await this.storageService.getStorageStats(userId); return { success: true, stats }; } diff --git a/src/infrastructure/webhooks/webhook.controller.ts b/src/infrastructure/webhooks/webhook.controller.ts index a90b783..a6ab69f 100644 --- a/src/infrastructure/webhooks/webhook.controller.ts +++ b/src/infrastructure/webhooks/webhook.controller.ts @@ -27,6 +27,9 @@ import { UpdateWebhookSubscriptionDto, PublishWebhookEventDto, } from "./dto/webhook.dto"; +import { CurrentUser } from "src/core/auth/decorators/current-user.decorator"; +import { AuthUser } from "src/core/auth/strategies/interfaces/auth-strategy.interface"; +import { BadRequestException } from "@nestjs/common"; @ApiTags("Webhooks") @ApiBearerAuth() @@ -48,9 +51,12 @@ export class WebhookController { @ApiResponse({ status: 201, description: "Subscription created with signing key" }) async createSubscription( @Body() dto: CreateWebhookSubscriptionDto, + @CurrentUser() user: AuthUser, ) { - // TODO: extract userId from auth guard - const userId = "system"; + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const sub = await this.subscriptionService.create(userId, dto); return { success: true, @@ -61,8 +67,11 @@ export class WebhookController { @Get("subscriptions") @ApiOperation({ summary: "List all webhook subscriptions" }) - async listSubscriptions() { - const userId = "system"; + async listSubscriptions(@CurrentUser() user: AuthUser) { + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const subscriptions = await this.subscriptionService.findAll(userId); // Strip signing keys from response const safe = subscriptions.map(({ signingKey, ...rest }) => rest); @@ -72,8 +81,11 @@ export class WebhookController { @Get("subscriptions/:id") @ApiOperation({ summary: "Get a webhook subscription by ID" }) @ApiParam({ name: "id" }) - async getSubscription(@Param("id") id: string) { - const userId = "system"; + async getSubscription(@Param("id") id: string, @CurrentUser() user: AuthUser) { + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const sub = await this.subscriptionService.findOne(id, userId); const { signingKey, ...rest } = sub; return { success: true, subscription: rest }; @@ -85,8 +97,12 @@ export class WebhookController { async updateSubscription( @Param("id") id: string, @Body() dto: UpdateWebhookSubscriptionDto, + @CurrentUser() user: AuthUser, ) { - const userId = "system"; + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const sub = await this.subscriptionService.update(id, userId, dto); const { signingKey, ...rest } = sub; return { success: true, subscription: rest }; @@ -96,8 +112,11 @@ export class WebhookController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Disable a webhook subscription" }) @ApiParam({ name: "id" }) - async deleteSubscription(@Param("id") id: string) { - const userId = "system"; + async deleteSubscription(@Param("id") id: string, @CurrentUser() user: AuthUser) { + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; await this.subscriptionService.remove(id, userId); return { success: true, message: "Subscription disabled" }; } @@ -106,8 +125,11 @@ export class WebhookController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Rotate the signing key for a subscription" }) @ApiParam({ name: "id" }) - async rotateKey(@Param("id") id: string) { - const userId = "system"; + async rotateKey(@Param("id") id: string, @CurrentUser() user: AuthUser) { + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const result = await this.subscriptionService.rotateKey(id, userId); return { success: true, @@ -123,8 +145,12 @@ export class WebhookController { async toggleStatus( @Param("id") id: string, @Query("status") status: "active" | "paused" | "disabled", + @CurrentUser() user: AuthUser, ) { - const userId = "system"; + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const sub = await this.subscriptionService.toggleStatus(id, userId, status as any); const { signingKey, ...rest } = sub; return { success: true, subscription: rest }; @@ -190,10 +216,14 @@ export class WebhookController { @ApiQuery({ name: "limit", required: false }) @ApiQuery({ name: "offset", required: false }) async listDeadLetters( + @CurrentUser() user: AuthUser, @Query("limit") limit?: number, @Query("offset") offset?: number, ) { - const userId = "system"; + if (!user?.id) { + throw new BadRequestException("User identity not found in request"); + } + const userId = user.id; const deadLetters = await this.deliveryService.getDeadLetters( userId, limit || 50, diff --git a/test/portfolio/mpt.spec.ts b/src/investment/portfolio/algorithms/modern-portfolio-theory.spec.ts similarity index 98% rename from test/portfolio/mpt.spec.ts rename to src/investment/portfolio/algorithms/modern-portfolio-theory.spec.ts index 6d19533..d7f2bb3 100644 --- a/test/portfolio/mpt.spec.ts +++ b/src/investment/portfolio/algorithms/modern-portfolio-theory.spec.ts @@ -1,4 +1,4 @@ -import { ModernPortfolioTheory } from "../../src/investment/portfolio/algorithms/modern-portfolio-theory"; +import { ModernPortfolioTheory } from "./modern-portfolio-theory"; describe("ModernPortfolioTheory", () => { describe("calculatePortfolioMetrics", () => { diff --git a/src/notifications/notification.module.ts b/src/notifications/notification.module.ts index c5090f2..e2b09b1 100644 --- a/src/notifications/notification.module.ts +++ b/src/notifications/notification.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bull'; import { JwtModule } from '@nestjs/jwt'; +import { buildJwtModuleOptions, LONG_LIVED_TOKEN_TTL } from 'src/config/jwt.config'; // Entities import { Notification } from './entities/notification.entity'; @@ -61,10 +62,13 @@ import { NotificationController } from './notification.controller'; JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET', 'change-me-in-production'), - signOptions: { expiresIn: '24h' }, - }), + useFactory: (configService: ConfigService) => + buildJwtModuleOptions({ + secret: configService.get('JWT_SECRET', 'change-me-in-production'), + // Consumer tokens, separate from the 15m access-token issuer in + // AuthModule. Unchanged. + expiresIn: LONG_LIVED_TOKEN_TTL, + }), }), ], controllers: [NotificationController], diff --git a/src/observability/request-timing.middleware.ts b/src/observability/request-timing.middleware.ts index 5f13734..f5b9f2c 100644 --- a/src/observability/request-timing.middleware.ts +++ b/src/observability/request-timing.middleware.ts @@ -8,6 +8,7 @@ import { httpRequestsInProgress, errorTotal, } from "../config/metrics"; +import { generateRandomId } from "src/common/utils/random-id"; /** * Normalise an Express request path so it can be safely used as a Prometheus @@ -52,9 +53,15 @@ export class RequestTimingMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { const headerRequestId = req.headers["x-request-id"]; + // Correlation id for this request's timing sample. The previous fallback, + // `Math.random().toString(36).substring(7)`, is not a CSPRNG and returned a + // ~9 character predictable string: overlapping requests can collide on the + // same key and end up sharing (and clobbering) each other's entry in + // `activeRequests`. A v4 UUID matches the `x-request-id` values that + // callers already send, so the key space is uniform. const requestId: string = Array.isArray(headerRequestId) ? headerRequestId[0] - : headerRequestId || Math.random().toString(36).substring(7); + : headerRequestId || generateRandomId(); const startTime = Date.now(); const normalisedPath = normaliseRoute(req.path); // At middleware entry, Express has not yet matched a route, so fall back diff --git a/src/profiling/profiling.middleware.ts b/src/profiling/profiling.middleware.ts index 9af7e2f..5434975 100644 --- a/src/profiling/profiling.middleware.ts +++ b/src/profiling/profiling.middleware.ts @@ -2,13 +2,19 @@ import { Injectable, NestMiddleware } from "@nestjs/common"; import { Request, Response, NextFunction } from "express"; import { performance } from "perf_hooks"; import { createSpan } from "../config/tracing"; +import { generateRandomId } from "src/common/utils/random-id"; @Injectable() export class ProfilingMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { const requestStartTime = performance.now(); + // Correlation id for the performance marks/measure below and for the + // OpenTelemetry span. Opaque to consumers, so a plain v4 UUID is the right + // shape - `Math.random().toString(36).substr(2, 9)` produced ~9 base-36 + // characters of predictable output, which collide on busy nodes and make + // two concurrent requests share a performance entry. const requestId = - req.headers["x-request-id"] || Math.random().toString(36).substr(2, 9); + req.headers["x-request-id"] || generateRandomId(); // Track middleware execution performance.mark(`${requestId}-middleware-start`); diff --git a/src/profiling/profiling.service.ts b/src/profiling/profiling.service.ts index 41cd526..977955b 100644 --- a/src/profiling/profiling.service.ts +++ b/src/profiling/profiling.service.ts @@ -8,6 +8,7 @@ import { Session } from "inspector"; import { getHeapSnapshot, getHeapStatistics } from "v8"; import { performance, PerformanceObserver } from "perf_hooks"; import * as Sentry from "@sentry/node"; +import { generatePrefixedTimestampedId } from "src/common/utils/random-id"; import { register } from "../config/metrics"; const execAsync = promisify(exec); @@ -122,7 +123,12 @@ export class ProfilingService { const session = new Session(); session.connect(); - const profileId = `cpu-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // Key into `activeProfiles` and the on-disk artefact name. The `cpu-` + // prefix keeps the filename self-describing and the epoch-millis segment + // keeps the profiles directory lexicographically sortable; only the + // `Math.random()` suffix is being replaced, since two profiles started in + // the same millisecond could previously overwrite each other. + const profileId = generatePrefixedTimestampedId("cpu"); const filePath = path.join(this.profilesDir, `${profileId}.cpuprofile`); const metadata: ProfileMetadata = { @@ -177,7 +183,8 @@ export class ProfilingService { path: string; size: number; }> { - const snapshotId = `heap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // See `startCPUProfile`: `heap-` prefix + timestamp, CSPRNG suffix. + const snapshotId = generatePrefixedTimestampedId("heap"); const filePath = path.join(this.profilesDir, `${snapshotId}.heapsnapshot`); const writeStream = fs.createWriteStream(filePath); diff --git a/src/rate-limiting/index.ts b/src/rate-limiting/index.ts index 6c93b41..d3bb70c 100644 --- a/src/rate-limiting/index.ts +++ b/src/rate-limiting/index.ts @@ -4,6 +4,11 @@ export type { RateLimitConfig } from "./rate-limiter.service"; export { DistributedRateLimitGuard } from "./rate-limiting.guard"; export { RateLimitMiddleware } from "./rate-limit.middleware"; export { RateLimitingController } from "./rate-limiting.controller"; +export { MemoryRateLimitStrategy } from "./strategies/memory-rate-limit.strategy"; +export { RedisRateLimitStrategy } from "./strategies/redis-rate-limit.strategy"; +export { IRateLimitStrategy } from "./strategies/rate-limit-strategy.interface"; +export { ViolationsService } from "./violations/violations.service"; +export { PolicyService } from "./policies/policy.service"; export { RateLimitStrategy, RateLimitPolicy, @@ -36,4 +41,4 @@ export { AddBlacklistDto, ViolationQueryDto, AddEndpointRuleDto, -} from "./dto/rate-limit-dto"; +} from "./dto/rate-limit-dto"; \ No newline at end of file diff --git a/src/rate-limiting/policies/policy.service.ts b/src/rate-limiting/policies/policy.service.ts new file mode 100644 index 0000000..a766d93 --- /dev/null +++ b/src/rate-limiting/policies/policy.service.ts @@ -0,0 +1,291 @@ +import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; +import Redis from "ioredis"; +import { CACHE_REDIS_CLIENT } from "../../common/cache/cache.constants"; +import { + BlacklistEntry, + EndpointRateLimitRule, + WhitelistEntry, +} from "../interfaces"; + +@Injectable() +export class PolicyService { + private readonly logger = new Logger(PolicyService.name); + private readonly memoryWhitelist = new Map(); + private readonly memoryBlacklist = new Map(); + private readonly endpointRules: EndpointRateLimitRule[] = []; + + constructor( + @Optional() + @Inject(CACHE_REDIS_CLIENT) + private readonly redis: Redis | null = null, + ) {} + + addEndpointRule(rule: EndpointRateLimitRule): void { + this.endpointRules.push(rule); + } + + getEndpointRule( + path: string, + method?: string, + ): EndpointRateLimitRule | undefined { + return this.endpointRules.find((rule) => { + const methodMatch = + !rule.method || + rule.method === "*" || + rule.method.toUpperCase() === method?.toUpperCase(); + const pathMatch = this.matchPathPattern(path, rule.pathPattern); + return methodMatch && pathMatch; + }); + } + + getEndpointRules(): EndpointRateLimitRule[] { + return [...this.endpointRules]; + } + + async addWhitelist( + entry: Omit, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const item: WhitelistEntry = { + id: `${entry.type}:${entry.value}`, + type: entry.type, + value: entry.value.trim().toLowerCase(), + reason: entry.reason, + createdAt: Date.now(), + expiresAt: entry.expiresAt, + }; + + this.memoryWhitelist.set(item.id, item); + + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + await this.redis.hset( + `${keyPrefix}whitelist`, + item.id, + JSON.stringify(item), + ); + } catch (err: any) { + this.logger.warn( + { error: err?.message }, + "Failed to persist whitelist to Redis", + ); + } + } + + return item; + } + + async removeWhitelist( + value: string, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const norm = value.trim().toLowerCase(); + let removed = false; + + for (const [id, entry] of this.memoryWhitelist.entries()) { + if (entry.value === norm || id === value) { + this.memoryWhitelist.delete(id); + removed = true; + } + } + + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + await this.redis.hdel(`${keyPrefix}whitelist`, value, norm); + } catch (err: any) { + this.logger.warn( + { error: err?.message }, + "Failed to remove whitelist from Redis", + ); + } + } + + return removed; + } + + async listWhitelist( + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + const raw = await this.redis.hgetall(`${keyPrefix}whitelist`); + return Object.values(raw).map((v) => JSON.parse(v)); + } catch { + return Array.from(this.memoryWhitelist.values()); + } + } + return Array.from(this.memoryWhitelist.values()); + } + + async isWhitelisted( + ip?: string, + userId?: string, + key?: string, + path?: string, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const entries = await this.listWhitelist(keyPrefix, isRedisHealthy); + const now = Date.now(); + + for (const entry of entries) { + if (entry.expiresAt && entry.expiresAt < now) continue; + + if (entry.type === "ip" && ip) { + if (this.matchIpPattern(ip, entry.value)) return true; + } + if (entry.type === "user" && userId) { + if (entry.value === userId.toLowerCase()) return true; + } + if (entry.type === "key" && key) { + if (entry.value === key.toLowerCase()) return true; + } + if (entry.type === "path" && path) { + if (this.matchPathPattern(path, entry.value)) return true; + } + } + + return false; + } + + async addBlacklist( + entry: Omit, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const item: BlacklistEntry = { + id: `${entry.type}:${entry.value}`, + type: entry.type, + value: entry.value.trim().toLowerCase(), + reason: entry.reason, + createdAt: Date.now(), + expiresAt: entry.expiresAt, + }; + + this.memoryBlacklist.set(item.id, item); + + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + await this.redis.hset( + `${keyPrefix}blacklist`, + item.id, + JSON.stringify(item), + ); + } catch (err: any) { + this.logger.warn( + { error: err?.message }, + "Failed to persist blacklist to Redis", + ); + } + } + + return item; + } + + async removeBlacklist( + value: string, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const norm = value.trim().toLowerCase(); + let removed = false; + + for (const [id, entry] of this.memoryBlacklist.entries()) { + if (entry.value === norm || id === value) { + this.memoryBlacklist.delete(id); + removed = true; + } + } + + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + await this.redis.hdel(`${keyPrefix}blacklist`, value, norm); + } catch (err: any) { + this.logger.warn( + { error: err?.message }, + "Failed to remove blacklist from Redis", + ); + } + } + + return removed; + } + + async listBlacklist( + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + const raw = await this.redis.hgetall(`${keyPrefix}blacklist`); + return Object.values(raw).map((v) => JSON.parse(v)); + } catch { + return Array.from(this.memoryBlacklist.values()); + } + } + return Array.from(this.memoryBlacklist.values()); + } + + async isBlacklisted( + ip?: string, + userId?: string, + key?: string, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const entries = await this.listBlacklist(keyPrefix, isRedisHealthy); + const now = Date.now(); + + for (const entry of entries) { + if (entry.expiresAt && entry.expiresAt < now) continue; + + if (entry.type === "ip" && ip) { + if (this.matchIpPattern(ip, entry.value)) return true; + } + if (entry.type === "user" && userId) { + if (entry.value === userId.toLowerCase()) return true; + } + if (entry.type === "key" && key) { + if (entry.value === key.toLowerCase()) return true; + } + } + + return false; + } + + private matchIpPattern(ip: string, pattern: string): boolean { + if (pattern === ip || pattern === "*") return true; + if (pattern.includes("/")) { + return false; + } + if (pattern.endsWith("*")) { + const prefix = pattern.slice(0, -1); + return ip.startsWith(prefix); + } + return false; + } + + private matchPathPattern(path: string, pattern: string): boolean { + if (pattern === path || pattern === "*") return true; + if (pattern.endsWith("*")) { + const prefix = pattern.slice(0, -1); + return path.startsWith(prefix); + } + return false; + } +} \ No newline at end of file diff --git a/src/rate-limiting/rate-limiter.service.spec.ts b/src/rate-limiting/rate-limiter.service.spec.ts index 2689fbd..4f034ff 100644 --- a/src/rate-limiting/rate-limiter.service.spec.ts +++ b/src/rate-limiting/rate-limiter.service.spec.ts @@ -1,5 +1,5 @@ import { RateLimiterService, RateLimitConfig } from "./rate-limiter.service"; -import { RateLimitStrategy } from "./interfaces"; +import { RateLimitStrategy, RateLimitViolation } from "./interfaces"; const DEFAULT_CONFIG: RateLimitConfig = { keyPrefix: "alian:rl:", @@ -282,6 +282,36 @@ describe("RateLimiterService", () => { expect(analytics.topViolators[0].tracker).toBe("ip:1.2.3.4"); expect(analytics.topRoutes[0].route).toBe("/api/v1/auth/login"); }); + + it("assigns every violation a CSPRNG-backed id", async () => { + // Annotated rather than left to inference: `reason` is a string + // literal union on RateLimitViolation, and widening it to `string` by + // extracting the literal into a variable makes it unassignable. + const violation: Omit = { + ip: "1.2.3.4", + tracker: "ip:1.2.3.4", + route: "/api/v1/auth/login", + method: "POST", + tier: "free", + strategy: RateLimitStrategy.TokenBucket, + limit: 5, + reason: "rate_limit_exceeded", + }; + + const ids = new Set(); + for (let i = 0; i < 5_000; i++) { + const { id } = await service.recordViolation(violation); + // Ids are externally visible on the rate-limiting API: keep the + // existing `-` shape, but the suffix must now be + // a v4 UUID rather than a `Math.random()` base-36 fragment. + expect(id).toMatch( + /^\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + ids.add(id); + } + + expect(ids.size).toBe(5_000); + }); }); describe("endpoint rule configuration", () => { @@ -450,6 +480,32 @@ describe("RateLimiterService", () => { expect(decision.allowed).toBe(true); }); + it("passes a unique CSPRNG member per sliding-window request", async () => { + const policy = { + limit: 5, + windowMs: 60_000, + burst: 5, + strategy: RateLimitStrategy.SlidingWindow, + }; + + for (let i = 0; i < 2_000; i++) { + await service.consume("redis-sw-unique:g:f", policy, null, "global", "f"); + } + + // The 5th ARGV is the sorted-set member. A colliding member would let one + // request evict or double-count another client's window entry. + const members = mockRedis.eval.mock.calls.map( + (call: unknown[]) => call[5] as string, + ); + expect(members.length).toBe(2_000); + expect(new Set(members).size).toBe(2_000); + for (const member of members) { + expect(member).toMatch( + /^\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + } + }); + it("falls back to memory when Redis eval throws", async () => { mockRedis.eval = jest.fn().mockRejectedValue(new Error("Redis error")); diff --git a/src/rate-limiting/rate-limiter.service.ts b/src/rate-limiting/rate-limiter.service.ts index 889eeaf..f381548 100644 --- a/src/rate-limiting/rate-limiter.service.ts +++ b/src/rate-limiting/rate-limiter.service.ts @@ -17,76 +17,10 @@ import { rateLimitErrorsTotal, rateLimitStorageHealth, } from "./rate-limiting.metrics"; - -const TOKEN_BUCKET_LUA = ` -local key = KEYS[1] -local capacity = tonumber(ARGV[1]) -local rate_per_sec = tonumber(ARGV[2]) -local now_ms = tonumber(ARGV[3]) -local cost = tonumber(ARGV[4]) - -local data = redis.call('HMGET', key, 'tokens', 'ts') -local tokens = tonumber(data[1]) -local ts = tonumber(data[2]) - -if tokens == nil then - tokens = capacity - ts = now_ms -end - -local elapsed_ms = now_ms - ts -local token_delta = elapsed_ms * rate_per_sec / 1000 -tokens = math.min(capacity, tokens + token_delta) -ts = now_ms - -if tokens >= cost then - tokens = tokens - cost - redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(ts)) - local ttl = math.ceil((capacity - tokens + 1) / rate_per_sec) - if ttl < 1 then ttl = 1 end - redis.call('EXPIRE', key, ttl) - return {1, math.floor(tokens), 0} -else - local wait_ms = math.ceil((cost - tokens) / rate_per_sec * 1000) - redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(ts)) - local ttl = math.ceil((cost - tokens + 1) / rate_per_sec) - if ttl < 1 then ttl = 1 end - redis.call('EXPIRE', key, ttl) - return {0, math.floor(tokens), wait_ms} -end -`; - -const SLIDING_WINDOW_LUA = ` -local key = KEYS[1] -local limit = tonumber(ARGV[1]) -local window_ms = tonumber(ARGV[2]) -local now_ms = tonumber(ARGV[3]) -local request_id = ARGV[4] - -local min_score = now_ms - window_ms -redis.call('ZREMRANGEBYSCORE', key, 0, min_score) - -local count = redis.call('ZCARD', key) - -if count < limit then - redis.call('ZADD', key, now_ms, request_id) - redis.call('PEXPIRE', key, window_ms) - return {1, limit - count - 1, 0} -else - local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') - local reset_at = now_ms - if #oldest > 0 then - reset_at = tonumber(oldest[2]) + window_ms - end - local retry_after = math.max(0, reset_at - now_ms) - return {0, 0, retry_after} -end -`; - -interface MemoryTokenBucketState { - tokens: number; - ts: number; -} +import { MemoryRateLimitStrategy } from "./strategies/memory-rate-limit.strategy"; +import { RedisRateLimitStrategy } from "./strategies/redis-rate-limit.strategy"; +import { ViolationsService } from "./violations/violations.service"; +import { PolicyService } from "./policies/policy.service"; interface MemoryEntry { tracker: string; @@ -113,18 +47,15 @@ export interface RateLimitConfig { export class RateLimiterService { private readonly logger = new Logger(RateLimiterService.name); private readonly redis: Redis | null; - private readonly memoryBuckets = new Map(); - private readonly memoryWindows = new Map(); private readonly registry = new Map(); private readonly keyPrefix: string; private readonly defaultStrategy: RateLimitStrategy; private readonly enableFallback: boolean; - // In-memory collections - private readonly memoryWhitelist = new Map(); - private readonly memoryBlacklist = new Map(); - private readonly memoryViolations: RateLimitViolation[] = []; - private readonly endpointRules: EndpointRateLimitRule[] = []; + private readonly memoryStrategy: MemoryRateLimitStrategy; + private readonly redisStrategy: RedisRateLimitStrategy; + private readonly violationsService: ViolationsService; + private readonly policyService: PolicyService; private lastRedisCheck = 0; private redisHealthy = false; @@ -132,6 +63,10 @@ export class RateLimiterService { constructor( @Optional() @Inject(CACHE_REDIS_CLIENT) redis: Redis | null, @Optional() @Inject("RATE_LIMIT_CONFIG") config?: RateLimitConfig, + @Optional() memoryStrategy?: MemoryRateLimitStrategy, + @Optional() redisStrategy?: RedisRateLimitStrategy, + @Optional() violationsService?: ViolationsService, + @Optional() policyService?: PolicyService, ) { this.redis = redis; this.keyPrefix = config?.keyPrefix ?? "alian:rl:"; @@ -139,15 +74,18 @@ export class RateLimiterService { config?.defaultStrategy ?? RateLimitStrategy.TokenBucket; this.enableFallback = config?.enableFallback ?? true; + this.memoryStrategy = memoryStrategy ?? new MemoryRateLimitStrategy(); + this.redisStrategy = redisStrategy ?? new RedisRateLimitStrategy(this.redis); + this.violationsService = violationsService ?? new ViolationsService(this.redis); + this.policyService = policyService ?? new PolicyService(this.redis); + if (config?.endpointRules) { - this.endpointRules.push(...config.endpointRules); + for (const rule of config.endpointRules) { + this.policyService.addEndpointRule(rule); + } } } - // ========================================== - // Core Rate Limit Consumption - // ========================================== - async consume( key: string, policy: Partial, @@ -163,13 +101,21 @@ export class RateLimiterService { }; const storageKey = `${this.keyPrefix}${key}`; - let decision: RateLimitDecision; if (this.redis && (await this.isRedisHealthy())) { - decision = await this.consumeRedis(storageKey, resolvedPolicy); + try { + decision = await this.redisStrategy.consume(storageKey, resolvedPolicy); + } catch (err: any) { + rateLimitErrorsTotal.inc({ operation: "consume", storage: "redis" }); + this.logger.warn( + { error: err?.message, key }, + "Redis rate-limit consume failed, falling back to memory", + ); + decision = this.memoryStrategy.consume(storageKey, resolvedPolicy); + } } else { - decision = await this.consumeMemory(storageKey, resolvedPolicy); + decision = this.memoryStrategy.consume(storageKey, resolvedPolicy); } this.updateRegistry( @@ -184,247 +130,28 @@ export class RateLimiterService { return decision; } - private async consumeRedis( - key: string, - policy: RateLimitPolicy, - ): Promise { - try { - const ratePerSec = policy.limit / (policy.windowMs / 1000); - - let result: [number, number, number]; - - if (policy.strategy === RateLimitStrategy.SlidingWindow) { - const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - result = (await this.redis!.eval( - SLIDING_WINDOW_LUA, - 1, - key, - policy.limit, - policy.windowMs, - Date.now(), - requestId, - )) as [number, number, number]; - } else { - result = (await this.redis!.eval( - TOKEN_BUCKET_LUA, - 1, - key, - policy.burst, - ratePerSec, - Date.now(), - 1, - )) as [number, number, number]; - } - - const [allowed, remaining, retryAfterMs] = result; - - let resetAt: number; - if (policy.strategy === RateLimitStrategy.SlidingWindow) { - resetAt = - retryAfterMs > 0 - ? Date.now() + retryAfterMs - : Date.now() + policy.windowMs; - } else { - resetAt = - retryAfterMs > 0 - ? Date.now() + retryAfterMs - : Date.now() + - Math.ceil(((policy.burst - remaining) / ratePerSec) * 1000); - } - - return { - allowed: allowed === 1, - remaining: Math.max(0, remaining), - resetAt, - retryAfterMs: retryAfterMs > 0 ? retryAfterMs : undefined, - }; - } catch (err: any) { - rateLimitErrorsTotal.inc({ operation: "consume", storage: "redis" }); - this.logger.warn( - { error: err?.message, key }, - "Redis rate-limit consume failed, falling back to memory", - ); - return this.consumeMemory(key, policy); - } - } - - private async consumeMemory( - key: string, - policy: RateLimitPolicy, - ): Promise { - const now = Date.now(); - - if (policy.strategy === RateLimitStrategy.SlidingWindow) { - return this.consumeMemorySlidingWindow(key, policy, now); - } - - return this.consumeMemoryTokenBucket(key, policy, now); - } - - private consumeMemoryTokenBucket( - key: string, - policy: RateLimitPolicy, - now: number, - ): RateLimitDecision { - const ratePerSec = policy.limit / (policy.windowMs / 1000); - const state = this.memoryBuckets.get(key); - - let tokens = policy.burst; - let ts = now; - - if (state) { - const elapsed = now - state.ts; - const refill = (elapsed * ratePerSec) / 1000; - tokens = Math.min(policy.burst, state.tokens + refill); - ts = now; - } - - const allowed = tokens >= 1; - if (allowed) { - tokens -= 1; - } - - this.memoryBuckets.set(key, { tokens, ts }); - - const remaining = Math.max(0, Math.floor(tokens)); - let retryAfterMs: number | undefined; - let resetAt = now; - - if (!allowed) { - retryAfterMs = Math.ceil(((1 - tokens) / ratePerSec) * 1000); - resetAt = now + retryAfterMs; - } else { - resetAt = now + Math.ceil(((policy.burst - tokens) / ratePerSec) * 1000); - } - - return { - allowed, - remaining, - resetAt, - retryAfterMs, - }; - } - - private consumeMemorySlidingWindow( - key: string, - policy: RateLimitPolicy, - now: number, - ): RateLimitDecision { - const windowStart = now - policy.windowMs; - let entries = this.memoryWindows.get(key); - if (!entries) { - entries = []; - this.memoryWindows.set(key, entries); - } - - entries = entries.filter((ts) => ts > windowStart); - this.memoryWindows.set(key, entries); - - const count = entries.length; - - if (count < policy.limit) { - entries.push(now); - const remaining = policy.limit - count - 1; - return { - allowed: true, - remaining: Math.max(0, remaining), - resetAt: now + policy.windowMs, - }; - } - - const oldest = entries[0] ?? now; - const retryAfterMs = Math.max(0, oldest + policy.windowMs - now); - return { - allowed: false, - remaining: 0, - resetAt: oldest + policy.windowMs, - retryAfterMs, - }; - } - - // ========================================== - // Whitelist & Blacklist Management - // ========================================== - async addWhitelist( entry: Omit, ): Promise { - const item: WhitelistEntry = { - id: `${entry.type}:${entry.value}`, - type: entry.type, - value: entry.value.trim().toLowerCase(), - reason: entry.reason, - createdAt: Date.now(), - expiresAt: entry.expiresAt, - }; - - this.memoryWhitelist.set(item.id, item); - - if (this.redis && (await this.isRedisHealthy())) { - try { - await this.redis.hset( - `${this.keyPrefix}whitelist`, - item.id, - JSON.stringify(item), - ); - } catch (err: any) { - this.logger.warn( - { error: err?.message }, - "Failed to persist whitelist to Redis", - ); - } - } - - return item; + return this.policyService.addWhitelist( + entry, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async removeWhitelist(value: string): Promise { - const norm = value.trim().toLowerCase(); - let removed = false; - - for (const [id, entry] of this.memoryWhitelist.entries()) { - if (entry.value === norm || id === value) { - this.memoryWhitelist.delete(id); - removed = true; - } - } - - if (this.redis && (await this.isRedisHealthy())) { - try { - await this.redis.hdel(`${this.keyPrefix}whitelist`, value, norm); - } catch (err: any) { - this.logger.warn( - { error: err?.message }, - "Failed to remove whitelist from Redis", - ); - } - } - - return removed; + return this.policyService.removeWhitelist( + value, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async listWhitelist(): Promise { - if (this.redis && (await this.isRedisHealthy())) { - try { - const raw = await this.redis.hgetall(`${this.keyPrefix}whitelist`); - const result: WhitelistEntry[] = []; - const now = Date.now(); - for (const str of Object.values(raw)) { - const parsed: WhitelistEntry = JSON.parse(str); - if (!parsed.expiresAt || parsed.expiresAt > now) { - result.push(parsed); - this.memoryWhitelist.set(parsed.id, parsed); - } - } - return result; - } catch { - // fall back to memory - } - } - - const now = Date.now(); - return Array.from(this.memoryWhitelist.values()).filter( - (e) => !e.expiresAt || e.expiresAt > now, + return this.policyService.listWhitelist( + this.keyPrefix, + () => this.isRedisHealthy(), ); } @@ -434,117 +161,38 @@ export class RateLimiterService { key?: string, path?: string, ): Promise { - const list = await this.listWhitelist(); - const now = Date.now(); - - for (const entry of list) { - if (entry.expiresAt && entry.expiresAt <= now) continue; - - if (ip && entry.type === "ip") { - if ( - entry.value === ip.toLowerCase() || - entry.value === "*" || - this.matchIpPattern(ip, entry.value) - ) { - return true; - } - } - - if (userId && entry.type === "user") { - if (entry.value === String(userId).toLowerCase()) return true; - } - - if (key && entry.type === "key") { - if (entry.value === String(key).toLowerCase()) return true; - } - - if (path && entry.type === "path") { - if (this.matchPathPattern(path, entry.value)) return true; - } - } - - return false; + return this.policyService.isWhitelisted( + ip, + userId, + key, + path, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async addBlacklist( entry: Omit, ): Promise { - const item: BlacklistEntry = { - id: `${entry.type}:${entry.value}`, - type: entry.type, - value: entry.value.trim().toLowerCase(), - reason: entry.reason, - createdAt: Date.now(), - expiresAt: entry.expiresAt, - }; - - this.memoryBlacklist.set(item.id, item); - - if (this.redis && (await this.isRedisHealthy())) { - try { - await this.redis.hset( - `${this.keyPrefix}blacklist`, - item.id, - JSON.stringify(item), - ); - } catch (err: any) { - this.logger.warn( - { error: err?.message }, - "Failed to persist blacklist to Redis", - ); - } - } - - return item; + return this.policyService.addBlacklist( + entry, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async removeBlacklist(value: string): Promise { - const norm = value.trim().toLowerCase(); - let removed = false; - - for (const [id, entry] of this.memoryBlacklist.entries()) { - if (entry.value === norm || id === value) { - this.memoryBlacklist.delete(id); - removed = true; - } - } - - if (this.redis && (await this.isRedisHealthy())) { - try { - await this.redis.hdel(`${this.keyPrefix}blacklist`, value, norm); - } catch (err: any) { - this.logger.warn( - { error: err?.message }, - "Failed to remove blacklist from Redis", - ); - } - } - - return removed; + return this.policyService.removeBlacklist( + value, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async listBlacklist(): Promise { - if (this.redis && (await this.isRedisHealthy())) { - try { - const raw = await this.redis.hgetall(`${this.keyPrefix}blacklist`); - const result: BlacklistEntry[] = []; - const now = Date.now(); - for (const str of Object.values(raw)) { - const parsed: BlacklistEntry = JSON.parse(str); - if (!parsed.expiresAt || parsed.expiresAt > now) { - result.push(parsed); - this.memoryBlacklist.set(parsed.id, parsed); - } - } - return result; - } catch { - // fall back to memory - } - } - - const now = Date.now(); - return Array.from(this.memoryBlacklist.values()).filter( - (e) => !e.expiresAt || e.expiresAt > now, + return this.policyService.listBlacklist( + this.keyPrefix, + () => this.isRedisHealthy(), ); } @@ -553,85 +201,23 @@ export class RateLimiterService { userId?: string, key?: string, ): Promise { - const list = await this.listBlacklist(); - const now = Date.now(); - - for (const entry of list) { - if (entry.expiresAt && entry.expiresAt <= now) continue; - - if (ip && entry.type === "ip") { - if ( - entry.value === ip.toLowerCase() || - this.matchIpPattern(ip, entry.value) - ) { - return true; - } - } - - if (userId && entry.type === "user") { - if (entry.value === String(userId).toLowerCase()) return true; - } - - if (key && entry.type === "key") { - if (entry.value === String(key).toLowerCase()) return true; - } - } - - return false; - } - - private matchIpPattern(ip: string, pattern: string): boolean { - if (pattern === ip) return true; - if (pattern.endsWith("*")) { - const prefix = pattern.slice(0, -1); - return ip.startsWith(prefix); - } - return false; - } - - private matchPathPattern(path: string, pattern: string): boolean { - if (pattern === path || pattern === "*") return true; - if (pattern.endsWith("*")) { - const prefix = pattern.slice(0, -1); - return path.startsWith(prefix); - } - return false; + return this.policyService.isBlacklisted( + ip, + userId, + key, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } - // ========================================== - // Violation Reporting & Analytics - // ========================================== - async recordViolation( violation: Omit, ): Promise { - const record: RateLimitViolation = { - ...violation, - id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - timestamp: Date.now(), - }; - - this.memoryViolations.unshift(record); - if (this.memoryViolations.length > 2000) { - this.memoryViolations.length = 2000; - } - - if (this.redis && (await this.isRedisHealthy())) { - try { - await this.redis.lpush( - `${this.keyPrefix}violations`, - JSON.stringify(record), - ); - await this.redis.ltrim(`${this.keyPrefix}violations`, 0, 1999); - } catch (err: any) { - this.logger.warn( - { error: err?.message }, - "Failed to store violation in Redis", - ); - } - } - - return record; + return this.violationsService.recordViolation( + violation, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async getViolations(filter?: { @@ -642,119 +228,32 @@ export class RateLimiterService { limit?: number; since?: number; }): Promise { - let list: RateLimitViolation[] = []; - - if (this.redis && (await this.isRedisHealthy())) { - try { - const raw = await this.redis.lrange( - `${this.keyPrefix}violations`, - 0, - filter?.limit ? Math.min(filter.limit * 2, 500) : 500, - ); - list = raw.map((r) => JSON.parse(r)); - } catch { - list = [...this.memoryViolations]; - } - } else { - list = [...this.memoryViolations]; - } - - const since = filter?.since ?? 0; - let filtered = list.filter((v) => v.timestamp >= since); - - if (filter?.tracker) { - filtered = filtered.filter((v) => v.tracker === filter.tracker); - } - if (filter?.ip) { - filtered = filtered.filter((v) => v.ip === filter.ip); - } - if (filter?.userId) { - filtered = filtered.filter((v) => v.userId === filter.userId); - } - if (filter?.route) { - filtered = filtered.filter((v) => v.route.includes(filter.route!)); - } - - const limit = filter?.limit ?? 100; - return filtered.slice(0, limit); + return this.violationsService.getViolations( + filter, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } async getAnalytics(sinceMs = 3600_000): Promise { - const since = Date.now() - sinceMs; - const violations = await this.getViolations({ since, limit: 2000 }); - - const violatorCounts: Record = {}; - const routeCounts: Record = {}; - const tierCounts: Record = {}; - const strategyCounts: Record = {}; - const timeBuckets: Record = {}; - - for (const v of violations) { - const trackerKey = v.tracker || v.ip || "unknown"; - violatorCounts[trackerKey] = (violatorCounts[trackerKey] ?? 0) + 1; - routeCounts[v.route] = (routeCounts[v.route] ?? 0) + 1; - tierCounts[v.tier] = (tierCounts[v.tier] ?? 0) + 1; - strategyCounts[v.strategy] = (strategyCounts[v.strategy] ?? 0) + 1; - - // Group into 5-minute buckets - const bucket = new Date( - Math.floor(v.timestamp / 300_000) * 300_000, - ).toISOString(); - timeBuckets[bucket] = (timeBuckets[bucket] ?? 0) + 1; - } - - const topViolators = Object.entries(violatorCounts) - .map(([tracker, count]) => ({ tracker, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); - - const topRoutes = Object.entries(routeCounts) - .map(([route, count]) => ({ route, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); - - const timeSeries = Object.entries(timeBuckets) - .map(([time, count]) => ({ time, count })) - .sort((a, b) => a.time.localeCompare(b.time)); - - return { - timestamp: new Date().toISOString(), - totalViolations: violations.length, - uniqueViolators: Object.keys(violatorCounts).length, - topViolators, - topRoutes, - violationsByTier: tierCounts, - violationsByStrategy: strategyCounts, - timeSeries, - }; + return this.violationsService.getAnalytics( + sinceMs, + this.keyPrefix, + () => this.isRedisHealthy(), + ); } - // ========================================== - // Endpoint-Level Configuration - // ========================================== - addEndpointRule(rule: EndpointRateLimitRule): void { - this.endpointRules.push(rule); + this.policyService.addEndpointRule(rule); } getEndpointRule( path: string, method?: string, ): EndpointRateLimitRule | undefined { - return this.endpointRules.find((rule) => { - const methodMatch = - !rule.method || - rule.method === "*" || - rule.method.toUpperCase() === method?.toUpperCase(); - const pathMatch = this.matchPathPattern(path, rule.pathPattern); - return methodMatch && pathMatch; - }); + return this.policyService.getEndpointRule(path, method); } - // ========================================== - // Entry & Storage State Queries - // ========================================== - async getEntry(key: string): Promise { const entry = this.registry.get(key); if (!entry) return null; @@ -766,14 +265,14 @@ export class RateLimiterService { try { if (entry.strategy === RateLimitStrategy.SlidingWindow) { const windowStart = Date.now() - entry.windowMs; - const count = await this.redis!.zcount( + const count = await this.redis.zcount( `${this.keyPrefix}${key}`, windowStart, "+inf", ); remaining = Math.max(0, entry.limit - count); } else { - const data = await this.redis!.hgetall(`${this.keyPrefix}${key}`); + const data = await this.redis.hgetall(`${this.keyPrefix}${key}`); const tokens = parseFloat(data.tokens ?? "0"); remaining = Math.max(0, Math.floor(tokens)); } @@ -805,7 +304,7 @@ export class RateLimiterService { async reset(key: string): Promise { if (this.redis && (await this.isRedisHealthy())) { try { - await this.redis!.del(`${this.keyPrefix}${key}`); + await this.redis.del(`${this.keyPrefix}${key}`); this.registry.delete(key); return true; } catch { @@ -813,8 +312,7 @@ export class RateLimiterService { } } - this.memoryBuckets.delete(`${this.keyPrefix}${key}`); - this.memoryWindows.delete(`${this.keyPrefix}${key}`); + this.memoryStrategy.reset(`${this.keyPrefix}${key}`); this.registry.delete(key); return true; } @@ -889,4 +387,4 @@ export class RateLimiterService { lastRemaining: decision.remaining, }); } -} +} \ No newline at end of file diff --git a/src/rate-limiting/rate-limiting.module.ts b/src/rate-limiting/rate-limiting.module.ts index 186c218..115a455 100644 --- a/src/rate-limiting/rate-limiting.module.ts +++ b/src/rate-limiting/rate-limiting.module.ts @@ -6,6 +6,10 @@ import { RateLimitMiddleware } from "./rate-limit.middleware"; import { RateLimitingController } from "./rate-limiting.controller"; import { RATE_LIMIT_CONFIG } from "./rate-limiting.constants"; import { RateLimitStrategy } from "./interfaces"; +import { MemoryRateLimitStrategy } from "./strategies/memory-rate-limit.strategy"; +import { RedisRateLimitStrategy } from "./strategies/redis-rate-limit.strategy"; +import { ViolationsService } from "./violations/violations.service"; +import { PolicyService } from "./policies/policy.service"; @Global() @Module({}) @@ -34,11 +38,19 @@ export class RateLimitingModule implements OnModuleInit { endpointRules: options?.endpointRules, }), }, + MemoryRateLimitStrategy, + RedisRateLimitStrategy, + ViolationsService, + PolicyService, RateLimiterService, DistributedRateLimitGuard, RateLimitMiddleware, ], exports: [ + MemoryRateLimitStrategy, + RedisRateLimitStrategy, + ViolationsService, + PolicyService, RateLimiterService, DistributedRateLimitGuard, RateLimitMiddleware, @@ -53,4 +65,4 @@ export class RateLimitingModule implements OnModuleInit { // Storage check is fire-and-forget during startup }); } -} +} \ No newline at end of file diff --git a/src/rate-limiting/strategies/memory-rate-limit.strategy.ts b/src/rate-limiting/strategies/memory-rate-limit.strategy.ts new file mode 100644 index 0000000..a815d3d --- /dev/null +++ b/src/rate-limiting/strategies/memory-rate-limit.strategy.ts @@ -0,0 +1,113 @@ +import { Injectable } from "@nestjs/common"; +import { + RateLimitDecision, + RateLimitPolicy, + RateLimitStrategy, +} from "../interfaces"; +import { IRateLimitStrategy } from "./rate-limit-strategy.interface"; + +interface MemoryTokenBucketState { + tokens: number; + ts: number; +} + +@Injectable() +export class MemoryRateLimitStrategy implements IRateLimitStrategy { + private readonly memoryBuckets = new Map(); + private readonly memoryWindows = new Map(); + + consume(key: string, policy: RateLimitPolicy): RateLimitDecision { + const now = Date.now(); + if (policy.strategy === RateLimitStrategy.SlidingWindow) { + return this.consumeMemorySlidingWindow(key, policy, now); + } + return this.consumeMemoryTokenBucket(key, policy, now); + } + + private consumeMemoryTokenBucket( + key: string, + policy: RateLimitPolicy, + now: number, + ): RateLimitDecision { + const ratePerSec = policy.limit / (policy.windowMs / 1000); + const state = this.memoryBuckets.get(key); + + let tokens = policy.burst; + let ts = now; + + if (state) { + const elapsed = now - state.ts; + const refill = (elapsed * ratePerSec) / 1000; + tokens = Math.min(policy.burst, state.tokens + refill); + ts = now; + } + + const allowed = tokens >= 1; + if (allowed) { + tokens -= 1; + } + + this.memoryBuckets.set(key, { tokens, ts }); + + const remaining = Math.max(0, Math.floor(tokens)); + let retryAfterMs: number | undefined; + let resetAt = now; + + if (!allowed) { + retryAfterMs = Math.ceil(((1 - tokens) / ratePerSec) * 1000); + resetAt = now + retryAfterMs; + } else { + resetAt = now + Math.ceil(((policy.burst - tokens) / ratePerSec) * 1000); + } + + return { + allowed, + remaining, + resetAt, + retryAfterMs, + }; + } + + private consumeMemorySlidingWindow( + key: string, + policy: RateLimitPolicy, + now: number, + ): RateLimitDecision { + const windowStart = now - policy.windowMs; + let entries = this.memoryWindows.get(key); + if (!entries) { + entries = []; + this.memoryWindows.set(key, entries); + } + + entries = entries.filter((ts) => ts > windowStart); + this.memoryWindows.set(key, entries); + + const count = entries.length; + + if (count < policy.limit) { + entries.push(now); + const remaining = policy.limit - count - 1; + return { + allowed: true, + remaining: Math.max(0, remaining), + resetAt: now + policy.windowMs, + }; + } + + const oldest = entries[0] ?? now; + const retryAfterMs = Math.max(0, oldest + policy.windowMs - now); + return { + allowed: false, + remaining: 0, + resetAt: oldest + policy.windowMs, + retryAfterMs, + }; + } + + reset(key: string): boolean { + this.memoryBuckets.delete(key); + this.memoryWindows.delete(key); + return true; + } +} \ No newline at end of file diff --git a/src/rate-limiting/strategies/rate-limit-strategy.interface.ts b/src/rate-limiting/strategies/rate-limit-strategy.interface.ts new file mode 100644 index 0000000..d1ff73a --- /dev/null +++ b/src/rate-limiting/strategies/rate-limit-strategy.interface.ts @@ -0,0 +1,9 @@ +import { RateLimitDecision, RateLimitPolicy } from "../interfaces"; + +export interface IRateLimitStrategy { + consume( + key: string, + policy: RateLimitPolicy, + ): Promise | RateLimitDecision; + reset(key: string): Promise | boolean; +} \ No newline at end of file diff --git a/src/rate-limiting/strategies/redis-rate-limit.strategy.ts b/src/rate-limiting/strategies/redis-rate-limit.strategy.ts new file mode 100644 index 0000000..f6da9f8 --- /dev/null +++ b/src/rate-limiting/strategies/redis-rate-limit.strategy.ts @@ -0,0 +1,150 @@ +import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; +import Redis from "ioredis"; +import { CACHE_REDIS_CLIENT } from "../../common/cache/cache.constants"; +import { generateTimestampedId } from "src/common/utils/random-id"; +import { + RateLimitDecision, + RateLimitPolicy, + RateLimitStrategy, +} from "../interfaces"; +import { IRateLimitStrategy } from "./rate-limit-strategy.interface"; + +const TOKEN_BUCKET_LUA = ` +local key = KEYS[1] +local capacity = tonumber(ARGV[1]) +local rate_per_sec = tonumber(ARGV[2]) +local now_ms = tonumber(ARGV[3]) +local cost = tonumber(ARGV[4]) + +local data = redis.call('HMGET', key, 'tokens', 'ts') +local tokens = tonumber(data[1]) +local ts = tonumber(data[2]) + +if tokens == nil then + tokens = capacity + ts = now_ms +end + +local elapsed_ms = now_ms - ts +local token_delta = elapsed_ms * rate_per_sec / 1000 +tokens = math.min(capacity, tokens + token_delta) +ts = now_ms + +if tokens >= cost then + tokens = tokens - cost + redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(ts)) + local ttl = math.ceil((capacity - tokens + 1) / rate_per_sec) + if ttl < 1 then ttl = 1 end + redis.call('EXPIRE', key, ttl) + return {1, math.floor(tokens), 0} +else + local wait_ms = math.ceil((cost - tokens) / rate_per_sec * 1000) + redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(ts)) + local ttl = math.ceil((cost - tokens + 1) / rate_per_sec) + if ttl < 1 then ttl = 1 end + redis.call('EXPIRE', key, ttl) + return {0, math.floor(tokens), wait_ms} +end +`; + +const SLIDING_WINDOW_LUA = ` +local key = KEYS[1] +local limit = tonumber(ARGV[1]) +local window_ms = tonumber(ARGV[2]) +local now_ms = tonumber(ARGV[3]) +local request_id = ARGV[4] + +local min_score = now_ms - window_ms +redis.call('ZREMRANGEBYSCORE', key, 0, min_score) + +local count = redis.call('ZCARD', key) + +if count < limit then + redis.call('ZADD', key, now_ms, request_id) + redis.call('PEXPIRE', key, window_ms) + return {1, limit - count - 1, 0} +else + local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') + local reset_at = now_ms + if #oldest > 0 then + reset_at = tonumber(oldest[2]) + window_ms + end + local retry_after = math.max(0, reset_at - now_ms) + return {0, 0, retry_after} +end +`; + +@Injectable() +export class RedisRateLimitStrategy implements IRateLimitStrategy { + private readonly logger = new Logger(RedisRateLimitStrategy.name); + + constructor( + @Optional() + @Inject(CACHE_REDIS_CLIENT) + private readonly redis: Redis | null = null, + ) {} + + async consume( + key: string, + policy: RateLimitPolicy, + ): Promise { + if (!this.redis) { + throw new Error("Redis client unavailable"); + } + + const ratePerSec = policy.limit / (policy.windowMs / 1000); + let result: [number, number, number]; + + if (policy.strategy === RateLimitStrategy.SlidingWindow) { + const requestId = generateTimestampedId(); + result = (await this.redis.eval( + SLIDING_WINDOW_LUA, + 1, + key, + policy.limit, + policy.windowMs, + Date.now(), + requestId, + )) as [number, number, number]; + } else { + result = (await this.redis.eval( + TOKEN_BUCKET_LUA, + 1, + key, + policy.burst, + ratePerSec, + Date.now(), + 1, + )) as [number, number, number]; + } + + const [allowed, remaining, retryAfterMs] = result; + + let resetAt: number; + if (policy.strategy === RateLimitStrategy.SlidingWindow) { + resetAt = + retryAfterMs > 0 + ? Date.now() + retryAfterMs + : Date.now() + policy.windowMs; + } else { + resetAt = + retryAfterMs > 0 + ? Date.now() + retryAfterMs + : Date.now() + + Math.ceil(((policy.burst - remaining) / ratePerSec) * 1000); + } + + return { + allowed: allowed === 1, + remaining: Math.max(0, remaining), + resetAt, + retryAfterMs: retryAfterMs > 0 ? retryAfterMs : undefined, + }; + } + + async reset(key: string): Promise { + if (!this.redis) return false; + await this.redis.del(key); + return true; + } +} \ No newline at end of file diff --git a/src/rate-limiting/violations/violations.service.ts b/src/rate-limiting/violations/violations.service.ts new file mode 100644 index 0000000..d7a5f73 --- /dev/null +++ b/src/rate-limiting/violations/violations.service.ts @@ -0,0 +1,161 @@ +import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; +import Redis from "ioredis"; +import { CACHE_REDIS_CLIENT } from "../../common/cache/cache.constants"; +import { generateTimestampedId } from "src/common/utils/random-id"; +import { RateLimitAnalytics, RateLimitViolation } from "../interfaces"; + +@Injectable() +export class ViolationsService { + private readonly logger = new Logger(ViolationsService.name); + private readonly memoryViolations: RateLimitViolation[] = []; + + constructor( + @Optional() + @Inject(CACHE_REDIS_CLIENT) + private readonly redis: Redis | null = null, + ) {} + + async recordViolation( + violation: Omit, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const record: RateLimitViolation = { + ...violation, + id: generateTimestampedId(), + timestamp: Date.now(), + }; + + this.memoryViolations.unshift(record); + if (this.memoryViolations.length > 2000) { + this.memoryViolations.length = 2000; + } + + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + await this.redis.lpush( + `${keyPrefix}violations`, + JSON.stringify(record), + ); + await this.redis.ltrim(`${keyPrefix}violations`, 0, 1999); + } catch (err: any) { + this.logger.warn( + { error: err?.message }, + "Failed to store violation in Redis", + ); + } + } + + return record; + } + + async getViolations( + filter?: { + tracker?: string; + ip?: string; + userId?: string; + route?: string; + limit?: number; + since?: number; + }, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + let list: RateLimitViolation[] = []; + + const redisOk = + this.redis && (isRedisHealthy ? await isRedisHealthy() : true); + if (redisOk && this.redis) { + try { + const raw = await this.redis.lrange( + `${keyPrefix}violations`, + 0, + filter?.limit ? Math.min(filter.limit * 2, 500) : 500, + ); + list = raw.map((r) => JSON.parse(r)); + } catch { + list = [...this.memoryViolations]; + } + } else { + list = [...this.memoryViolations]; + } + + const since = filter?.since ?? 0; + let filtered = list.filter((v) => v.timestamp >= since); + + if (filter?.tracker) { + filtered = filtered.filter((v) => v.tracker === filter.tracker); + } + if (filter?.ip) { + filtered = filtered.filter((v) => v.ip === filter.ip); + } + if (filter?.userId) { + filtered = filtered.filter((v) => v.userId === filter.userId); + } + if (filter?.route) { + filtered = filtered.filter((v) => v.route.includes(filter.route!)); + } + + const limit = filter?.limit ?? 100; + return filtered.slice(0, limit); + } + + async getAnalytics( + sinceMs = 3600_000, + keyPrefix = "alian:rl:", + isRedisHealthy?: () => Promise, + ): Promise { + const since = Date.now() - sinceMs; + const violations = await this.getViolations( + { since, limit: 2000 }, + keyPrefix, + isRedisHealthy, + ); + + const violatorCounts: Record = {}; + const routeCounts: Record = {}; + const tierCounts: Record = {}; + const strategyCounts: Record = {}; + const timeBuckets: Record = {}; + + for (const v of violations) { + const trackerKey = v.tracker || v.ip || "unknown"; + violatorCounts[trackerKey] = (violatorCounts[trackerKey] ?? 0) + 1; + routeCounts[v.route] = (routeCounts[v.route] ?? 0) + 1; + tierCounts[v.tier] = (tierCounts[v.tier] ?? 0) + 1; + strategyCounts[v.strategy] = (strategyCounts[v.strategy] ?? 0) + 1; + + const bucket = new Date( + Math.floor(v.timestamp / 300_000) * 300_000, + ).toISOString(); + timeBuckets[bucket] = (timeBuckets[bucket] ?? 0) + 1; + } + + const topViolators = Object.entries(violatorCounts) + .map(([tracker, count]) => ({ tracker, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const topRoutes = Object.entries(routeCounts) + .map(([route, count]) => ({ route, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const timeSeries = Object.entries(timeBuckets) + .map(([time, count]) => ({ time, count })) + .sort((a, b) => a.time.localeCompare(b.time)); + + return { + timestamp: new Date().toISOString(), + totalViolations: violations.length, + uniqueViolators: Object.keys(violatorCounts).length, + topViolators, + topRoutes, + violationsByTier: tierCounts, + violationsByStrategy: strategyCounts, + timeSeries, + }; + } +} \ No newline at end of file diff --git a/test/email-linking.spec.ts b/test/email-linking.spec.ts deleted file mode 100644 index 25d0981..0000000 --- a/test/email-linking.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { - BadRequestException, - ConflictException, - NotFoundException, - UnauthorizedException, -} from "@nestjs/common"; -import { EmailLinkingService } from "../src/core/auth/email-linking.service"; -import { EmailService } from "../src/core/auth/email.service"; -import { User } from "../src/core/user/entities/user.entity"; -import { EmailVerification } from "../src/core/auth/entities/email-verification.entity"; - -describe("EmailLinkingService", () => { - let service: EmailLinkingService; - let userRepository: Repository; - let emailVerificationRepository: Repository; - let emailService: EmailService; - - const mockUser: User = { - id: "123", - walletAddress: "0x1234567890123456789012345678901234567890", - email: null, - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const mockEmailService = { - sendVerificationEmail: jest.fn().mockResolvedValue({ - messageId: "test-message-id", - previewUrl: "https://ethereal.email/message/test", - }), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EmailLinkingService, - { - provide: getRepositoryToken(User), - useValue: { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - delete: jest.fn(), - }, - }, - { - provide: getRepositoryToken(EmailVerification), - useValue: { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - delete: jest.fn(), - }, - }, - { - provide: EmailService, - useValue: mockEmailService, - }, - ], - }).compile(); - - service = module.get(EmailLinkingService); - userRepository = module.get>(getRepositoryToken(User)); - emailVerificationRepository = module.get>( - getRepositoryToken(EmailVerification), - ); - emailService = module.get(EmailService); - }); - - describe("initiateEmailLinking", () => { - it("should initiate email linking for new user", async () => { - jest.spyOn(userRepository, "findOne").mockResolvedValue(null); - jest.spyOn(userRepository, "create").mockReturnValue(mockUser); - jest.spyOn(userRepository, "save").mockResolvedValue(mockUser); - jest - .spyOn(emailVerificationRepository, "delete") - .mockResolvedValue(undefined); - jest - .spyOn(emailVerificationRepository, "create") - .mockReturnValue({} as EmailVerification); - jest - .spyOn(emailVerificationRepository, "save") - .mockResolvedValue({} as EmailVerification); - - const result = await service.initiateEmailLinking( - "0x1234567890123456789012345678901234567890", - "test@example.com", - ); - - expect(result.message).toContain("Verification email sent"); - expect(emailService.sendVerificationEmail).toHaveBeenCalled(); - }); - - it("should reject invalid email format", async () => { - await expect( - service.initiateEmailLinking( - "0x1234567890123456789012345678901234567890", - "invalid-email", - ), - ).rejects.toThrow(BadRequestException); - }); - - it("should reject email already linked to another wallet", async () => { - const existingUser = { ...mockUser, walletAddress: "0xdifferent" }; - jest.spyOn(userRepository, "findOne").mockResolvedValue(existingUser); - - await expect( - service.initiateEmailLinking( - "0x1234567890123456789012345678901234567890", - "test@example.com", - ), - ).rejects.toThrow(ConflictException); - }); - - it("should reject if email already verified for this wallet", async () => { - const verifiedUser = { - ...mockUser, - email: "test@example.com", - emailVerified: true, - }; - jest - .spyOn(userRepository, "findOne") - .mockResolvedValueOnce(null) // First call for email check - .mockResolvedValueOnce(verifiedUser); // Second call for wallet check - - await expect( - service.initiateEmailLinking( - "0x1234567890123456789012345678901234567890", - "test@example.com", - ), - ).rejects.toThrow(ConflictException); - }); - }); - - describe("verifyEmailAndLink", () => { - it("should verify email and link to wallet", async () => { - const verification: EmailVerification = { - id: "1", - email: "test@example.com", - token: "a".repeat(64), - walletAddress: "0x1234567890123456789012345678901234567890", - expiresAt: new Date(Date.now() + 10 * 60 * 1000), - createdAt: new Date(), - }; - - jest - .spyOn(emailVerificationRepository, "findOne") - .mockResolvedValue(verification); - jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser); - jest.spyOn(userRepository, "save").mockResolvedValue({ - ...mockUser, - email: "test@example.com", - emailVerified: true, - }); - jest - .spyOn(emailVerificationRepository, "delete") - .mockResolvedValue(undefined); - - const result = await service.verifyEmailAndLink("a".repeat(64)); - - expect(result.message).toContain("successfully verified"); - expect(result.email).toBe("test@example.com"); - }); - - it("should reject invalid token", async () => { - jest - .spyOn(emailVerificationRepository, "findOne") - .mockResolvedValue(null); - - await expect(service.verifyEmailAndLink("invalid-token")).rejects.toThrow( - NotFoundException, - ); - }); - - it("should reject expired token", async () => { - const expiredVerification: EmailVerification = { - id: "1", - email: "test@example.com", - token: "a".repeat(64), - walletAddress: "0x1234567890123456789012345678901234567890", - expiresAt: new Date(Date.now() - 1000), // Expired - createdAt: new Date(), - }; - - jest - .spyOn(emailVerificationRepository, "findOne") - .mockResolvedValue(expiredVerification); - jest - .spyOn(emailVerificationRepository, "delete") - .mockResolvedValue(undefined); - - await expect(service.verifyEmailAndLink("a".repeat(64))).rejects.toThrow( - UnauthorizedException, - ); - }); - }); - - describe("getAccountInfo", () => { - it("should return account info for wallet with linked email", async () => { - const userWithEmail = { - ...mockUser, - email: "test@example.com", - emailVerified: true, - }; - jest.spyOn(userRepository, "findOne").mockResolvedValue(userWithEmail); - - const result = await service.getAccountInfo( - "0x1234567890123456789012345678901234567890", - ); - - expect(result.email).toBe("test@example.com"); - expect(result.emailVerified).toBe(true); - }); - - it("should return null email for wallet without linked email", async () => { - jest.spyOn(userRepository, "findOne").mockResolvedValue(null); - - const result = await service.getAccountInfo( - "0x1234567890123456789012345678901234567890", - ); - - expect(result.email).toBeNull(); - expect(result.emailVerified).toBe(false); - }); - }); - - describe("unlinkEmail", () => { - it("should unlink email from wallet", async () => { - const userWithEmail = { - ...mockUser, - email: "test@example.com", - emailVerified: true, - }; - jest.spyOn(userRepository, "findOne").mockResolvedValue(userWithEmail); - jest.spyOn(userRepository, "save").mockResolvedValue(mockUser); - jest - .spyOn(emailVerificationRepository, "delete") - .mockResolvedValue(undefined); - - const result = await service.unlinkEmail( - "0x1234567890123456789012345678901234567890", - ); - - expect(result.message).toContain("successfully unlinked"); - }); - - it("should throw error if no email linked", async () => { - jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser); - - await expect( - service.unlinkEmail("0x1234567890123456789012345678901234567890"), - ).rejects.toThrow(NotFoundException); - }); - }); -}); diff --git a/test/enhanced-auth.service.spec.ts b/test/enhanced-auth.service.spec.ts deleted file mode 100644 index f4d1dc2..0000000 --- a/test/enhanced-auth.service.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { JwtService } from "@nestjs/jwt"; -import { User } from "../../user/entities/user.entity"; -import { RefreshToken, TwoFactorAuth } from "../../auth/entities/auth.entity"; -import { EnhancedAuthService } from "../../auth/enhanced-auth.service"; -import { EmailService } from "../../auth/email.service"; -import { RegisterDto, LoginDto } from "../../auth/dto/auth.dto"; -import { KycStatus } from "../../user/entities/user.entity"; -import * as bcrypt from "bcrypt"; - -jest.mock("bcrypt"); -jest.mock("speakeasy", () => ({ - generateSecret: jest.fn().mockReturnValue({ - base32: "SECRET", - otpauth_url: "otpauth://totp/test", - }), -})); -jest.mock("qrcode", () => ({ - toDataURL: jest.fn().mockResolvedValue("qr-code-url"), -})); - -describe("EnhancedAuthService", () => { - let service: EnhancedAuthService; - let userRepository: Repository; - let refreshTokenRepository: Repository; - let twoFactorRepository: Repository; - let jwtService: JwtService; - let emailService: EmailService; - - const mockUser = { - id: "user-id", - email: "test@example.com", - username: "testuser", - password: "hashedpassword", - walletAddress: "email_test@example.com", - role: "user", - kycStatus: KycStatus.UNVERIFIED, - isActive: true, - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EnhancedAuthService, - { - provide: getRepositoryToken(User), - useValue: { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - update: jest.fn(), - }, - }, - { - provide: getRepositoryToken(RefreshToken), - useValue: { - create: jest.fn(), - save: jest.fn(), - findOne: jest.fn(), - update: jest.fn(), - }, - }, - { - provide: getRepositoryToken(TwoFactorAuth), - useValue: { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - update: jest.fn(), - }, - }, - { - provide: JwtService, - useValue: { - sign: jest.fn(), - }, - }, - { - provide: EmailService, - useValue: { - sendEmail: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(EnhancedAuthService); - userRepository = module.get>(getRepositoryToken(User)); - refreshTokenRepository = module.get>( - getRepositoryToken(RefreshToken), - ); - twoFactorRepository = module.get>( - getRepositoryToken(TwoFactorAuth), - ); - jwtService = module.get(JwtService); - emailService = module.get(EmailService); - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); - - describe("register", () => { - it("should register a new user successfully", async () => { - const registerDto: RegisterDto = { - email: "test@example.com", - password: "password123", - username: "testuser", - }; - - jest.spyOn(userRepository, "findOne").mockResolvedValue(null); - jest.spyOn(userRepository, "create").mockReturnValue(mockUser as any); - jest.spyOn(userRepository, "save").mockResolvedValue(mockUser as any); - jest.spyOn(jwtService, "sign").mockReturnValue("access-token"); - jest.spyOn(refreshTokenRepository, "create").mockReturnValue({} as any); - jest.spyOn(refreshTokenRepository, "save").mockResolvedValue({} as any); - - const result = await service.register(registerDto, "127.0.0.1", "test-agent");); - - expect(result).toHaveProperty("accessToken"); - expect(result).toHaveProperty("refreshToken"); - expect(result.user.email).toBe(registerDto.email); - }); - - it("should throw error if email already exists", async () => { - const registerDto: RegisterDto = { - email: "test@example.com", - password: "password123", - }; - - jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any); - - await expect( - service.register(registerDto, "127.0.0.1", "test-agent"), - ).rejects.toThrow("Email already registered"); - }); - }); - - describe("login", () => { - it("should login user successfully", async () => { - const loginDto: LoginDto = { - email: "test@example.com", - password: "password123", - }; - - jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any); - jest.spyOn(jwtService, "sign").mockReturnValue("access-token"); - jest.spyOn(refreshTokenRepository, "create").mockReturnValue({} as any); - jest.spyOn(refreshTokenRepository, "save").mockResolvedValue({} as any); - - (bcrypt.compare as jest.Mock).mockResolvedValue(true); - - const result = await service.login(loginDto, "127.0.0.1", "test-agent"); - - expect(result).toHaveProperty("accessToken"); - expect(result).toHaveProperty("refreshToken"); - expect(result.user.email).toBe(loginDto.email); - }); - - it("should throw error for invalid credentials", async () => { - const loginDto: LoginDto = { - email: "test@example.com", - password: "wrongpassword", - }; - - jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any); - - (bcrypt.compare as jest.Mock).mockResolvedValue(false); - - await expect( - service.login(loginDto, "127.0.0.1", "test-agent"), - ).rejects.toThrow("Invalid credentials"); - }); - }); - - describe("setupTwoFactor", () => { - it("should setup 2FA successfully", async () => { - jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any); - jest.spyOn(twoFactorRepository, "findOne").mockResolvedValue(null); - jest.spyOn(twoFactorRepository, "create").mockReturnValue({} as any); - jest.spyOn(twoFactorRepository, "save").mockResolvedValue({} as any); - - const result = await service.setupTwoFactor("user-id", { type: "totp" }); - - expect(result).toHaveProperty("secret"); - expect(result).toHaveProperty("qrCodeUrl"); - expect(result).toHaveProperty("backupCodes"); - }); - }); -}); -/workspaces/alian-structure-api/test/enhanced-auth.service.spec.ts \ No newline at end of file diff --git a/test/oracle-e2e.spec.ts b/test/oracle-e2e.e2e-spec.ts similarity index 100% rename from test/oracle-e2e.spec.ts rename to test/oracle-e2e.e2e-spec.ts diff --git a/test/portfolio/portfolio.service.spec.ts b/test/portfolio/portfolio.service.spec.ts deleted file mode 100644 index 8235514..0000000 --- a/test/portfolio/portfolio.service.spec.ts +++ /dev/null @@ -1,549 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import { PortfolioService } from "../../src/investment/portfolio/services/portfolio.service"; -import { Portfolio } from "../../src/investment/portfolio/entities/portfolio.entity"; -import { - PortfolioAsset, - Chain, - AssetType, -} from "../../src/investment/portfolio/entities/portfolio-asset.entity"; -import { OptimizationHistory } from "../../src/investment/portfolio/entities/optimization-history.entity"; -import { RiskProfile } from "../../src/investment/portfolio/entities/risk-profile.entity"; -import { CreatePortfolioDto } from "../../src/investment/portfolio/dto/portfolio.dto"; -import { OptimizationMethod } from "../../src/investment/portfolio/entities/optimization-history.entity"; -import { - AddHoldingDto, - UpdateHoldingDto, -} from "../../src/investment/portfolio/dto/portfolio-asset.dto"; - -describe("PortfolioService", () => { - let service: PortfolioService; - let portfolioRepository: any; - let assetRepository: any; - let optimizationRepository: any; - let riskProfileRepository: any; - - const mockPortfolio = { - id: "test-portfolio-1", - userId: "test-user-1", - name: "Test Portfolio", - type: "custom", - status: "active", - totalValue: 100000, - currentAllocation: { AAPL: 30, MSFT: 70 }, - targetAllocation: null, - initialAllocation: {}, - allocationStrategy: "manual", - autoRebalanceEnabled: false, - rebalanceThreshold: 5, - assets: [], - deletedAt: null, - save: jest.fn(), - }; - - const mockAsset = { - id: "asset-1", - ticker: "AAPL", - name: "Apple", - chain: Chain.OTHER, - quantity: 100, - currentPrice: 150, - value: 15000, - allocationPercentage: 15, - portfolioId: "test-portfolio-1", - save: jest.fn(), - }; - - const mockOptimization = { - id: "opt-1", - portfolioId: "test-portfolio-1", - method: OptimizationMethod.MEAN_VARIANCE, - status: "completed", - suggestedAllocation: { AAPL: 40, MSFT: 60 }, - expectedReturn: 0.08, - expectedVolatility: 0.15, - expectedSharpeRatio: 0.5, - improvementScore: 10, - currentAllocation: mockPortfolio.currentAllocation, - save: jest.fn(), - }; - - beforeEach(async () => { - portfolioRepository = { - create: jest.fn().mockReturnValue(mockPortfolio), - save: jest.fn().mockResolvedValue(mockPortfolio), - findOne: jest.fn(), - find: jest.fn().mockResolvedValue([mockPortfolio]), - delete: jest.fn(), - }; - - assetRepository = { - create: jest.fn().mockReturnValue(mockAsset), - save: jest.fn().mockResolvedValue(mockAsset), - find: jest.fn().mockResolvedValue([mockAsset]), - findOne: jest.fn(), - }; - - optimizationRepository = { - create: jest.fn().mockReturnValue(mockOptimization), - save: jest.fn(), - find: jest.fn(), - findOne: jest.fn(), - }; - - riskProfileRepository = { - findOne: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PortfolioService, - { - provide: getRepositoryToken(Portfolio), - useValue: portfolioRepository, - }, - { - provide: getRepositoryToken(PortfolioAsset), - useValue: assetRepository, - }, - { - provide: getRepositoryToken(OptimizationHistory), - useValue: optimizationRepository, - }, - { - provide: getRepositoryToken(RiskProfile), - useValue: riskProfileRepository, - }, - ], - }).compile(); - - service = module.get(PortfolioService); - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); - - describe("createPortfolio", () => { - it("should create a new portfolio", async () => { - portfolioRepository.findOne.mockResolvedValue(null); - - const dto: CreatePortfolioDto = { - name: "Test Portfolio", - type: "custom", - }; - - const result = await service.createPortfolio("test-user-1", dto); - - expect(portfolioRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: dto.name, - userId: "test-user-1", - type: "custom", - status: "active", - }), - ); - expect(portfolioRepository.save).toHaveBeenCalled(); - expect(result).toEqual(mockPortfolio); - }); - - it("should throw if portfolio name already exists", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - const dto: CreatePortfolioDto = { - name: "Test Portfolio", - }; - - await expect(service.createPortfolio("test-user-1", dto)).rejects.toThrow( - "Portfolio with this name already exists", - ); - }); - - it("should throw for empty name", async () => { - const dto: CreatePortfolioDto = { - name: " ", - }; - - await expect(service.createPortfolio("test-user-1", dto)).rejects.toThrow( - BadRequestException, - ); - }); - }); - - describe("getPortfolio", () => { - it("should return a portfolio by ID", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - const result = await service.getPortfolio("test-portfolio-1"); - - expect(portfolioRepository.findOne).toHaveBeenCalledWith({ - where: { id: "test-portfolio-1" }, - relations: expect.any(Array), - }); - expect(result).toEqual(mockPortfolio); - }); - - it("should throw NotFoundException if portfolio not found", async () => { - portfolioRepository.findOne.mockResolvedValue(null); - - await expect(service.getPortfolio("non-existent")).rejects.toThrow( - NotFoundException, - ); - }); - - it("should throw NotFoundException if portfolio is deleted", async () => { - const deletedPortfolio = { ...mockPortfolio, deletedAt: new Date() }; - portfolioRepository.findOne.mockResolvedValue(deletedPortfolio); - - await expect(service.getPortfolio("test-portfolio-1")).rejects.toThrow( - NotFoundException, - ); - }); - }); - - describe("getUserPortfolios", () => { - it("should return all portfolios for a user", async () => { - const result = await service.getUserPortfolios("test-user-1"); - - expect(portfolioRepository.find).toHaveBeenCalledWith({ - where: { userId: "test-user-1" }, - relations: expect.any(Array), - order: expect.any(Object), - }); - expect(result).toEqual([mockPortfolio]); - }); - }); - - describe("updatePortfolio", () => { - it("should update portfolio properties", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - const updateDto = { - name: "Updated Portfolio", - description: "Updated description", - }; - - const result = await service.updatePortfolio( - "test-portfolio-1", - updateDto, - ); - - expect(portfolioRepository.save).toHaveBeenCalled(); - expect(result.name).toBe("Updated Portfolio"); - }); - - it("should archive portfolio when status is ARCHIVED", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - const updateDto = { - status: "archived", - }; - - const result = await service.updatePortfolio( - "test-portfolio-1", - updateDto, - ); - - expect(result.status).toBe("archived"); - expect(result.deletedAt).toBeDefined(); - }); - - it("should validate allocation percentages", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - const updateDto = { - currentAllocation: { AAPL: 60, MSFT: 50 }, - }; - - await expect( - service.updatePortfolio("test-portfolio-1", updateDto), - ).rejects.toThrow(BadRequestException); - }); - }); - - describe("deletePortfolio", () => { - it("should soft delete portfolio", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - await service.deletePortfolio("test-portfolio-1"); - - expect(mockPortfolio.status).toBe("archived"); - expect(mockPortfolio.deletedAt).toBeDefined(); - expect(portfolioRepository.save).toHaveBeenCalled(); - }); - - it("should throw NotFoundException for deleted portfolio", async () => { - const deletedPortfolio = { ...mockPortfolio, deletedAt: new Date() }; - portfolioRepository.findOne.mockResolvedValue(deletedPortfolio); - - await expect(service.deletePortfolio("test-portfolio-1")).rejects.toThrow( - NotFoundException, - ); - }); - }); - - describe("restorePortfolio", () => { - it("should restore archived portfolio", async () => { - const archivedPortfolio = { - ...mockPortfolio, - status: "archived", - deletedAt: new Date(), - }; - portfolioRepository.findOne.mockResolvedValue(archivedPortfolio); - - const result = await service.restorePortfolio("test-portfolio-1"); - - expect(result.status).toBe("active"); - expect(result.deletedAt).toBeNull(); - expect(portfolioRepository.save).toHaveBeenCalled(); - }); - - it("should throw NotFoundException when restoring non-archived portfolio", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - await expect( - service.restorePortfolio("test-portfolio-1"), - ).rejects.toThrow(NotFoundException); - }); - }); - - describe("addAsset", () => { - it("should add an asset to portfolio", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - assetRepository.findOne.mockResolvedValue(null); - - const result = await service.addAsset( - "test-portfolio-1", - "AAPL", - "Apple", - 100, - 150, - 0, - ); - - expect(assetRepository.create).toHaveBeenCalled(); - expect(assetRepository.save).toHaveBeenCalled(); - expect(result).toEqual(mockAsset); - }); - - it("should throw for invalid quantity", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - await expect( - service.addAsset("test-portfolio-1", "AAPL", "Apple", -10, 150, 0), - ).rejects.toThrow(BadRequestException); - }); - - it("should throw for negative price", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - await expect( - service.addAsset("test-portfolio-1", "AAPL", "Apple", 100, -150, 0), - ).rejects.toThrow(BadRequestException); - }); - }); - - describe("runOptimization", () => { - it("should run portfolio optimization", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - assetRepository.find.mockResolvedValue([mockAsset]); - optimizationRepository.create.mockReturnValue({ - ...mockOptimization, - status: "in_progress", - save: jest.fn(), - }); - optimizationRepository.save - .mockResolvedValueOnce({ - ...mockOptimization, - status: "in_progress", - }) - .mockResolvedValueOnce(mockOptimization); - - const result = await service.runOptimization("test-portfolio-1", { - method: OptimizationMethod.MEAN_VARIANCE, - }); - - expect(result.status).toBe("completed"); - expect(optimizationRepository.save).toHaveBeenCalled(); - }); - - it("should throw if portfolio has no assets", async () => { - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - assetRepository.find.mockResolvedValue([]); - - await expect( - service.runOptimization("test-portfolio-1", { - method: OptimizationMethod.MEAN_VARIANCE, - }), - ).rejects.toThrow("Portfolio has no assets to optimize"); - }); - }); - - describe("approveOptimization", () => { - it("should approve completed optimization", async () => { - optimizationRepository.findOne.mockResolvedValue(mockOptimization); - - const result = await service.approveOptimization("opt-1", "Looks good"); - - expect(result.status).toBe("approved"); - expect(optimizationRepository.save).toHaveBeenCalled(); - }); - - it("should throw if optimization not found", async () => { - optimizationRepository.findOne.mockResolvedValue(null); - - await expect(service.approveOptimization("non-existent")).rejects.toThrow( - NotFoundException, - ); - }); - - it("should throw if optimization is not completed", async () => { - const pendingOptimization = { - ...mockOptimization, - status: "in_progress", - }; - optimizationRepository.findOne.mockResolvedValue(pendingOptimization); - - await expect(service.approveOptimization("opt-1")).rejects.toThrow( - BadRequestException, - ); - }); - }); - - describe("implementOptimization", () => { - it("should implement approved optimization", async () => { - const approvedOptimization = { - ...mockOptimization, - status: "approved", - }; - optimizationRepository.findOne.mockResolvedValue(approvedOptimization); - portfolioRepository.findOne.mockResolvedValue(mockPortfolio); - - const result = await service.implementOptimization("opt-1"); - - expect(result.targetAllocation).toEqual( - approvedOptimization.suggestedAllocation, - ); - expect(optimizationRepository.save).toHaveBeenCalled(); - }); - - it("should throw if optimization not found", async () => { - optimizationRepository.findOne.mockResolvedValue(null); - - await expect( - service.implementOptimization("non-existent"), - ).rejects.toThrow(NotFoundException); - }); - - it("should throw if optimization is not approved", async () => { - optimizationRepository.findOne.mockResolvedValue(mockOptimization); - - await expect(service.implementOptimization("opt-1")).rejects.toThrow( - BadRequestException, - ); - }); - }); - - describe("addHolding", () => { - it("should add a new holding to portfolio", async () => { - const dto: AddHoldingDto = { - ticker: "ETH", - name: "Ethereum", - chain: Chain.ETHEREUM, - quantity: 10, - currentPrice: 2000, - costBasis: 15000, - }; - - assetRepository.findOne.mockResolvedValue(null); - assetRepository.create.mockReturnValue({ ...mockAsset, ...dto }); - assetRepository.save.mockResolvedValue({ ...mockAsset, ...dto }); - - const result = await service.addHolding("test-portfolio-1", dto); - - expect(assetRepository.findOne).toHaveBeenCalledWith({ - where: { - portfolioId: "test-portfolio-1", - ticker: dto.ticker, - chain: dto.chain, - }, - }); - expect(assetRepository.create).toHaveBeenCalled(); - expect(assetRepository.save).toHaveBeenCalled(); - expect(result.ticker).toBe(dto.ticker); - expect(result.chain).toBe(dto.chain); - }); - - it("should throw error if holding already exists", async () => { - const dto: AddHoldingDto = { - ticker: "ETH", - name: "Ethereum", - chain: Chain.ETHEREUM, - quantity: 10, - currentPrice: 2000, - costBasis: 15000, - }; - - assetRepository.findOne.mockResolvedValue(mockAsset); - - await expect(service.addHolding("test-portfolio-1", dto)).rejects.toThrow( - "Holding with same ticker and chain already exists", - ); - }); - }); - - describe("updateHolding", () => { - it("should update holding in portfolio", async () => { - const dto: UpdateHoldingDto = { - quantity: 20, - currentPrice: 2500, - }; - - const updatedAsset = { ...mockAsset, ...dto, value: 50000 }; - assetRepository.findOne.mockResolvedValue(mockAsset); - assetRepository.save.mockResolvedValue(updatedAsset); - - const result = await service.updateHolding( - "test-portfolio-1", - "asset-1", - dto, - ); - - expect(assetRepository.findOne).toHaveBeenCalledWith({ - where: { id: "asset-1", portfolioId: "test-portfolio-1" }, - }); - expect(assetRepository.save).toHaveBeenCalled(); - }); - - it("should throw error if holding not found", async () => { - const dto: UpdateHoldingDto = { quantity: 20 }; - assetRepository.findOne.mockResolvedValue(null); - - await expect( - service.updateHolding("test-portfolio-1", "non-existent", dto), - ).rejects.toThrow("Holding not found"); - }); - }); - - describe("removeHolding", () => { - it("should remove holding from portfolio", async () => { - assetRepository.findOne.mockResolvedValue(mockAsset); - assetRepository.remove.mockResolvedValue(null); - - await service.removeHolding("test-portfolio-1", "asset-1"); - - expect(assetRepository.findOne).toHaveBeenCalledWith({ - where: { id: "asset-1", portfolioId: "test-portfolio-1" }, - }); - expect(assetRepository.remove).toHaveBeenCalledWith(mockAsset); - }); - - it("should throw error if holding not found", async () => { - assetRepository.findOne.mockResolvedValue(null); - - await expect( - service.removeHolding("test-portfolio-1", "non-existent"), - ).rejects.toThrow("Holding not found"); - }); - }); -}); diff --git a/test/wallet-auth.spec.ts b/test/wallet-auth.spec.ts deleted file mode 100644 index 934f753..0000000 --- a/test/wallet-auth.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { JwtService } from "@nestjs/jwt"; -import { UnauthorizedException } from "@nestjs/common"; -import { WalletAuthService } from "../src/core/auth/wallet-auth.service"; -import { ChallengeService } from "../src/core/auth/challenge.service"; -import { Wallet } from "ethers"; - -describe("Wallet Authentication", () => { - let walletAuthService: WalletAuthService; - let challengeService: ChallengeService; - let jwtService: JwtService; - let testWallet: any; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ChallengeService, - WalletAuthService, - { - provide: JwtService, - useValue: { - sign: (payload) => { - return "test-token-" + JSON.stringify(payload); - }, - verify: (token) => { - if (token.startsWith("test-token-")) { - return JSON.parse(token.replace("test-token-", "")); - } - throw new Error("Invalid token"); - }, - }, - }, - ], - }).compile(); - - walletAuthService = module.get(WalletAuthService); - challengeService = module.get(ChallengeService); - jwtService = module.get(JwtService); - - // Create a test wallet - testWallet = Wallet.createRandom(); - }); - - describe("Challenge Issuance", () => { - it("should issue a valid challenge for an address", () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - - expect(message).toContain("Sign this message to authenticate:"); - expect(message).toBeTruthy(); - }); - - it("should extract challenge ID from message", () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - const challengeId = challengeService.extractChallengeId(message); - - expect(challengeId).toBeTruthy(); - expect(challengeId).toHaveLength(64); // 32 bytes hex - }); - - it("should store challenge with expiration", () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - const challengeId = challengeService.extractChallengeId(message); - - const challenge = challengeService.getChallenge(challengeId); - - expect(challenge).toBeTruthy(); - expect(challenge.address).toBe(address.toLowerCase()); - expect(challenge.expiresAt).toBeGreaterThan(Date.now()); - }); - }); - - describe("Signature Verification", () => { - it("should verify a valid signature and return JWT token", async () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - - // Sign the message with the wallet - const signature = await testWallet.signMessage(message); - - const result = await walletAuthService.verifySignatureAndIssueToken( - message, - signature, - ); - - expect(result.token).toBeTruthy(); - expect(result.address).toBe(address.toLowerCase()); - }); - - it("should reject an invalid signature", async () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - - // Create an invalid signature - const invalidSignature = - "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; - - await expect( - walletAuthService.verifySignatureAndIssueToken( - message, - invalidSignature, - ), - ).rejects.toThrow(UnauthorizedException); - }); - - it("should reject signature from different address", async () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - - // Create a different wallet and sign with it - const differentWallet = Wallet.createRandom(); - const signature = await differentWallet.signMessage(message); - - await expect( - walletAuthService.verifySignatureAndIssueToken(message, signature), - ).rejects.toThrow(UnauthorizedException); - }); - - it("should reject expired challenge", async () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - const challengeId = challengeService.extractChallengeId(message); - - // Consume the challenge - challengeService.consumeChallenge(challengeId); - - const signature = await testWallet.signMessage(message); - - await expect( - walletAuthService.verifySignatureAndIssueToken(message, signature), - ).rejects.toThrow( - new UnauthorizedException( - "Challenge not found or expired. Please request a new challenge.", - ), - ); - }); - - it("should consume challenge after successful verification", async () => { - const address = testWallet.address; - const message = challengeService.issueChallengeForAddress(address); - const challengeId = challengeService.extractChallengeId(message); - - const signature = await testWallet.signMessage(message); - - await walletAuthService.verifySignatureAndIssueToken(message, signature); - - // Challenge should be consumed - const challenge = challengeService.getChallenge(challengeId); - expect(challenge).toBeNull(); - }); - }); - - describe("JWT Token Validation", () => { - it("should validate a correct token", () => { - const payload = { address: testWallet.address.toLowerCase(), iat: 0 }; - const token = jwtService.sign(payload); - - const result = walletAuthService.validateToken(token); - - expect(result.address).toBe(payload.address); - }); - - it("should reject an invalid token", () => { - const invalidToken = "invalid-token"; - - expect(() => { - walletAuthService.validateToken(invalidToken); - }).toThrow(UnauthorizedException); - }); - }); - - describe("End-to-End Authentication Flow", () => { - it("should complete full authentication flow", async () => { - const address = testWallet.address; - - // 1. Request challenge - const message = challengeService.issueChallengeForAddress(address); - expect(message).toContain("Sign this message to authenticate:"); - - // 2. Sign message - const signature = await testWallet.signMessage(message); - expect(signature).toBeTruthy(); - - // 3. Verify signature and get token - const authResult = await walletAuthService.verifySignatureAndIssueToken( - message, - signature, - ); - expect(authResult.token).toBeTruthy(); - expect(authResult.address).toBe(address.toLowerCase()); - - // 4. Validate token - const payload = walletAuthService.validateToken(authResult.token); - expect(payload.address).toBe(address.toLowerCase()); - }); - }); -});