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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@
"testEnvironment": "node",
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^.+/generated/prisma/client$": "<rootDir>/__mocks__/generated/prisma/client.ts"
"^src/(.*)$": "<rootDir>/$1",
"^.+/generated/prisma/client$": "<rootDir>/__mocks__/generated/prisma/client.ts",
"^.+/generated/prisma$": "<rootDir>/__mocks__/generated/prisma/client.ts"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- Migration: add UserStatus enum and migrate User.status from String to the new enum.
--
-- The existing values stored in production ('ACTIVE', 'INACTIVE', 'SUSPENDED', 'DISABLED')
-- must all be valid enum members before ALTER COLUMN executes. Any row with an
-- unrecognised value will cause the migration to fail fast, preventing silent data loss.
--
-- Safe migration order:
-- 1. Create the enum type.
-- 2. Coerce any legacy 'INACTIVE' values to 'DISABLED' (INACTIVE is not in the new enum;
-- the domain entity never used it, but old seeds/manual writes may have stored it).
-- 3. Alter the column to use the new type with explicit USING cast.
-- 4. Restore the DEFAULT.

-- Step 1: Create the enum.
CREATE TYPE "UserStatus" AS ENUM (
'PROVISIONING',
'ACTIVE',
'RECOVERY_PENDING',
'SUSPENDED',
'DISABLED'
);

-- Step 2: Normalise any legacy 'INACTIVE' rows → 'DISABLED' before the ALTER.
-- 'INACTIVE' was an undocumented legacy value that behaved identically to
-- 'DISABLED' (both rejected authentication with 403 Forbidden).
UPDATE "User"
SET status = 'DISABLED'
WHERE status = 'INACTIVE';

-- Fail fast: abort if any unrecognised status values remain that cannot be cast.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "User"
WHERE status NOT IN ('PROVISIONING', 'ACTIVE', 'RECOVERY_PENDING', 'SUSPENDED', 'DISABLED')
) THEN
RAISE EXCEPTION
'Migration 20260831000001: "User".status contains values outside the UserStatus enum. '
'Inspect and coerce them manually before re-running this migration.';
END IF;
END $$;

-- Step 3: Alter the column type with an explicit USING cast.
ALTER TABLE "User"
ALTER COLUMN "status" TYPE "UserStatus"
USING status::"UserStatus";

-- Step 4: Restore the default now that the column type is known.
ALTER TABLE "User"
ALTER COLUMN "status" SET DEFAULT 'ACTIVE'::"UserStatus";
19 changes: 17 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ enum WalletNetwork {
TESTNET
}

/// Internal lifecycle states for a domain user.
/// Keep in sync with UserStatus in src/users/entities/user.entity.ts.
enum UserStatus {
/// Created but not yet usable (e.g. awaiting first wallet provisioning).
PROVISIONING
/// Fully operational; the default for healthy accounts.
ACTIVE
/// Account is undergoing recovery (e.g. lost device, wallet rotation).
RECOVERY_PENDING
/// Operationally paused (e.g. risk hold). Reversible.
SUSPENDED
/// Permanently disabled. No further use.
DISABLED
}

/// User account for authentication and wallet ownership.
model User {
id String @id @default(uuid())
Expand All @@ -103,8 +118,8 @@ model User {
/// User's display name (optional)
displayName String?

/// Account status
status String @default("ACTIVE")
/// Account status — enforced by the UserStatus enum; defaults to ACTIVE.
status UserStatus @default(ACTIVE)

/// Authentication provider type
authProvider String @default("UNKNOWN")
Expand Down
168 changes: 166 additions & 2 deletions src/__mocks__/generated/prisma/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,171 @@
// Manual mock for the generated Prisma client
// Used in unit tests to avoid requiring a real database connection
// Manual mock for the generated Prisma client.
// Used in unit tests to avoid requiring a real database connection.
//
// PrismaClient is mocked so tests never open a DB connection.
// Enum values are inlined here so that runtime comparisons
// (e.g. status === RefreshTokenStatus.ACTIVE) work in tests without
// requiring a real database or a circular import from the generated folder.

export const PrismaClient = jest.fn().mockImplementation(() => ({
$connect: jest.fn(),
$disconnect: jest.fn(),
$transaction: jest.fn(),
}));

// ---------------------------------------------------------------------------
// Inlined enums — keep in sync with prisma/schema.prisma
// ---------------------------------------------------------------------------

export const RefreshTokenStatus = {
ACTIVE: 'ACTIVE',
ROTATED: 'ROTATED',
REVOKED: 'REVOKED',
EXPIRED: 'EXPIRED',
} as const;
export type RefreshTokenStatus =
(typeof RefreshTokenStatus)[keyof typeof RefreshTokenStatus];

export const ApiKeyStatus = {
ACTIVE: 'ACTIVE',
REVOKED: 'REVOKED',
EXPIRED: 'EXPIRED',
SUSPENDED: 'SUSPENDED',
} as const;
export type ApiKeyStatus = (typeof ApiKeyStatus)[keyof typeof ApiKeyStatus];

export const WalletStatus = {
PROVISIONING: 'PROVISIONING',
ACTIVE: 'ACTIVE',
ROTATING: 'ROTATING',
SUSPENDED: 'SUSPENDED',
DISABLED: 'DISABLED',
COMPROMISED: 'COMPROMISED',
ARCHIVED: 'ARCHIVED',
} as const;
export type WalletStatus = (typeof WalletStatus)[keyof typeof WalletStatus];

export const WalletNetwork = {
MAINNET: 'MAINNET',
TESTNET: 'TESTNET',
} as const;
export type WalletNetwork = (typeof WalletNetwork)[keyof typeof WalletNetwork];

export const UserStatus = {
PROVISIONING: 'PROVISIONING',
ACTIVE: 'ACTIVE',
RECOVERY_PENDING: 'RECOVERY_PENDING',
SUSPENDED: 'SUSPENDED',
DISABLED: 'DISABLED',
} as const;
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];

export const PaymentStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
FAILED: 'FAILED',
} as const;
export type PaymentStatus = (typeof PaymentStatus)[keyof typeof PaymentStatus];

export const RecoveryStatus = {
PENDING: 'PENDING',
IN_REVIEW: 'IN_REVIEW',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
COMPLETED: 'COMPLETED',
CANCELLED: 'CANCELLED',
} as const;
export type RecoveryStatus =
(typeof RecoveryStatus)[keyof typeof RecoveryStatus];

export const TransactionStatus = {
PENDING: 'PENDING',
SUBMITTED: 'SUBMITTED',
CONFIRMED: 'CONFIRMED',
FAILED: 'FAILED',
} as const;
export type TransactionStatus =
(typeof TransactionStatus)[keyof typeof TransactionStatus];

export const AssetType = {
NATIVE: 'NATIVE',
CREDIT_ALPHANUM4: 'CREDIT_ALPHANUM4',
CREDIT_ALPHANUM12: 'CREDIT_ALPHANUM12',
LIQUIDITY_POOL_SHARES: 'LIQUIDITY_POOL_SHARES',
} as const;
export type AssetType = (typeof AssetType)[keyof typeof AssetType];

export const BalanceSyncStatus = {
SYNCED: 'SYNCED',
SYNCING: 'SYNCING',
STALE: 'STALE',
MISMATCH: 'MISMATCH',
FAILED: 'FAILED',
} as const;
export type BalanceSyncStatus =
(typeof BalanceSyncStatus)[keyof typeof BalanceSyncStatus];

export const LimitPeriod = {
DAILY: 'DAILY',
WEEKLY: 'WEEKLY',
MONTHLY: 'MONTHLY',
} as const;
export type LimitPeriod = (typeof LimitPeriod)[keyof typeof LimitPeriod];

export const KeyOperation = {
GENERATE: 'GENERATE',
SIGN: 'SIGN',
ROTATE: 'ROTATE',
REVOKE: 'REVOKE',
ACCESS: 'ACCESS',
} as const;
export type KeyOperation = (typeof KeyOperation)[keyof typeof KeyOperation];

export const HorizonImportStatus = {
PENDING: 'PENDING',
RUNNING: 'RUNNING',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
} as const;
export type HorizonImportStatus =
(typeof HorizonImportStatus)[keyof typeof HorizonImportStatus];

export const ChangeType = {
ADDED: 'ADDED',
CHANGED: 'CHANGED',
DEPRECATED: 'DEPRECATED',
REMOVED: 'REMOVED',
FIXED: 'FIXED',
SECURITY: 'SECURITY',
} as const;
export type ChangeType = (typeof ChangeType)[keyof typeof ChangeType];

export const ChangeCategory = {
WALLETS: 'WALLETS',
PAYMENTS: 'PAYMENTS',
LIMITS: 'LIMITS',
RECOVERY: 'RECOVERY',
AUTHENTICATION: 'AUTHENTICATION',
WEBHOOKS: 'WEBHOOKS',
GENERAL: 'GENERAL',
} as const;
export type ChangeCategory =
(typeof ChangeCategory)[keyof typeof ChangeCategory];

// $Enums namespace (mirrors the generated client's barrel export)
export const $Enums = {
RefreshTokenStatus,
ApiKeyStatus,
WalletStatus,
WalletNetwork,
UserStatus,
PaymentStatus,
RecoveryStatus,
TransactionStatus,
AssetType,
BalanceSyncStatus,
LimitPeriod,
KeyOperation,
HorizonImportStatus,
ChangeType,
ChangeCategory,
};
3 changes: 2 additions & 1 deletion src/api-keys/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '../generated/prisma/client';
import type { ApiKey as PrismaApiKey } from '../generated/prisma/client';
import * as crypto from 'crypto';
import { SafeLogger } from '../common/safe-logger';
import {
Expand Down Expand Up @@ -436,7 +437,7 @@ export class ApiKeyService implements OnModuleDestroy {
/**
* Maps Prisma ApiKey to domain model
*/
private mapPrismaApiKeyToDomain(prismaApiKey: any): ApiKey {
private mapPrismaApiKeyToDomain(prismaApiKey: PrismaApiKey): ApiKey {
return {
id: prismaApiKey.id,
name: prismaApiKey.name,
Expand Down
74 changes: 55 additions & 19 deletions src/auth/auth.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,67 @@
import { Test, TestingModule } from '@nestjs/testing';
/**
* @file auth.module.spec.ts
*
* Regression guard: AuthModule must list PrismaModule in its @Module({imports})
* so that PrismaService is available for providers like RefreshTokenService.
*
* Without PrismaModule in imports, NestJS cannot inject PrismaService into
* RefreshTokenService, and the module fails to compile at runtime.
*
* Design note: We inspect decorator metadata directly rather than compiling
* the full module graph. The full graph requires real infra (DB, event-emitter)
* and has its own setup in integration specs. The goal here is a fast,
* hermetic regression guard that fails immediately if PrismaModule is removed
* from the imports array.
*/

import 'reflect-metadata';
import { AuthModule } from './auth.module';
import { RefreshTokenService } from './refresh-token.service';
import { AuthOrchestrator } from './auth-orchestrator.service';
import { PrismaModule } from '../prisma/prisma.module';
import { IdempotentUserModule } from '../users/idempotent-user.module';
import { WalletsModule } from '../wallets/wallets.module';

describe('AuthModule - @Module metadata regression guards', () => {
/**
* NestJS stores the @Module({ imports }) array under the
* 'imports' metadata key on the class. Reading it here is faster
* and more hermetic than compiling the full module tree.
*/
let imports: unknown[];

beforeAll(() => {
imports =
(Reflect.getMetadata('imports', AuthModule) as unknown[]) ?? [];
});

describe('AuthModule - DI Resolution', () => {
let module: TestingModule;
// Issue #1 regression guard -------------------------------------------------

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [AuthModule],
}).compile();
it('should include PrismaModule in @Module imports (was omitted, causing DI failure)', () => {
// This is the primary fix: the import was declared at the top of the file
// but missing from the @Module({ imports }) array.
expect(imports).toContain(PrismaModule);
});

it('should compile and resolve RefreshTokenService', () => {
const refreshTokenService = module.get(RefreshTokenService);
expect(refreshTokenService).toBeDefined();
expect(refreshTokenService).toBeInstanceOf(RefreshTokenService);
it('should include IdempotentUserModule in @Module imports', () => {
expect(imports).toContain(IdempotentUserModule);
});

it('should compile and resolve AuthOrchestrator', () => {
const authOrchestrator = module.get(AuthOrchestrator);
expect(authOrchestrator).toBeDefined();
expect(authOrchestrator).toBeInstanceOf(AuthOrchestrator);
it('should include WalletsModule in @Module imports', () => {
expect(imports).toContain(WalletsModule);
});

// Provider registration guard -----------------------------------------------

it('should list RefreshTokenService in providers', () => {
const { RefreshTokenService } = require('./refresh-token.service');
const providers =
(Reflect.getMetadata('providers', AuthModule) as unknown[]) ?? [];
expect(providers).toContain(RefreshTokenService);
});

it('should export RefreshTokenService', () => {
const refreshTokenService = module.get(RefreshTokenService);
expect(refreshTokenService).toBeDefined();
const { RefreshTokenService } = require('./refresh-token.service');
const exports =
(Reflect.getMetadata('exports', AuthModule) as unknown[]) ?? [];
expect(exports).toContain(RefreshTokenService);
});
});
2 changes: 1 addition & 1 deletion src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { FeatureFlagService } from '../common/feature-flags/feature-flag.service
import { FeatureFlagGuard } from '../common/feature-flags/feature-flag.guard';

@Module({
imports: [IdempotentUserModule, WalletsModule],
imports: [IdempotentUserModule, WalletsModule, PrismaModule],
controllers: [AuthOrchestratorController, AuthMetricsController],
providers: [
AuthOrchestrator,
Expand Down
Loading