diff --git a/context/progress-tracker.md b/context/progress-tracker.md index eac5d06..86f83a2 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -1,10 +1,15 @@ # Progress Tracker — StepFi-API Format: date, commit hash, what changed, why. -Update this file in every PR that changes functionality (not needed for -pure chore/docs commits). Direct pushes to main must also be logged here. +Update this file in every PR that changes functionality. ---- +## 2026-08-25 + +- Commit hash: pending. +- Added a server-side `AdminGuard` for the `/admin` module tree that reads the current `users` row from Supabase on every request, instead of trusting the JWT role claim alone. +- Applied the guard to `GET /admin/audit-logs`, added explicit 401/403 responses, and logged denied admin attempts with the same audit log shape used by the audit interceptor. +- Expanded the `users.role` constraint to allow a real server-side `admin` role and updated the user profile DTO to reflect that role. +- Added unit coverage for admin, blocked admin, unauthenticated, and stale-token authorization branches. ## 2026-07-23 @@ -23,193 +28,45 @@ pure chore/docs commits). Direct pushes to main must also be logged here. ## 2026-07-18 -- Added scheduled state reconciliation across indexed on-chain loan, - liquidity, reputation, and transaction state. The idempotent Cron job - resolves provisional loan IDs, repairs stale database state, backfills - missed transaction records, marks orphaned pending transactions, exports - drift metrics, and logs a structured report without making on-chain writes. - Cron is used instead of BullMQ per the API's post-Upstash architecture. +- Added scheduled state reconciliation across indexed on-chain loan, liquidity, reputation, and transaction state. The idempotent Cron job resolves provisional loan IDs, repairs stale database state, backfills missed transaction records, marks orphaned pending transactions, exports drift metrics, and logs a structured report without making on-chain writes. ## 2026-07-16 -- Added wallet-bound user roles (sponsor/vendor/mentor): `role` column - migration, one-time `POST /users/me/role` (409 once set), role claim - in JWT, `RolesGuard` on vendor/liquidity endpoints (direct push, - parallel session) -- Enforced `@typescript-eslint/no-explicit-any` at error via new - `.eslintrc.js` (repo previously had a lint script but no ESLint - config); replaced all 32 explicit `any` usages in src/ with real - types (direct push, documented here per new git standards) -- Fixed liquidity overview endpoint to expose `lockedLiquidity`, - `availableLiquidity`, `totalShares`, `sharePrice` — the web sponsor - dashboard needed the locked vs available breakdown (commit 3961123) +- Added wallet-bound user roles (sponsor/vendor/mentor): `role` column migration, one-time `POST /users/me/role` (409 once set), role claim in JWT, `RolesGuard` on vendor/liquidity endpoints +- Enforced `@typescript-eslint/no-explicit-any` at error via new `.eslintrc.js` +- Replaced all 32 explicit `any` usages in `src/` with real types +- Fixed liquidity overview endpoint to expose `lockedLiquidity`, `availableLiquidity`, `totalShares`, `sharePrice` - Fixed indexer cursor not persisting after the BullMQ→Cron migration - (commit c74a05f) - Removed BullMQ entirely; all jobs now use `@Cron` or `setInterval` - (commit c9cfbd0) -- Migrated to a new Upstash Redis database after hitting the 500k - free-tier request limit (ops change, no commit) ## 2026-07-15 -- Replaced BullMQ polling jobs with `@Cron` and `setInterval`, cutting - Redis usage by ~90% (commit d908c52) +- Replaced BullMQ polling jobs with `@Cron` and `setInterval`, cutting Redis usage by ~90% ## 2026-07-02 -- Added Supabase keep-alive cron job to prevent free-tier project - pausing (commit 0ff9928) -- Indexer: self-healing reset of stale ledger checkpoint (commit b7f418c) - and jump-to-recent-ledger instead of incrementing by 1 (commit 4130db2) -- Fixed learner profile migration — quoted the reserved word - `current_role` (commit 422992b) -- Resolved npm audit vulnerabilities (commit 1bf7372) -- Repo hygiene: PR template + CODEOWNERS (b2bdfa4), issue template with - test requirements (78b76bb) +- Added Supabase keep-alive cron job to prevent free-tier project pausing +- Indexer: self-healing reset of stale ledger checkpoint and jump-to-recent-ledger instead of incrementing by 1 +- Fixed learner profile migration — quoted the reserved word `current_role` +- Resolved npm audit vulnerabilities +- Repo hygiene: PR template + CODEOWNERS, issue template with test requirements ## 2026-06-27 -- API Key authentication system for vendor service-to-service - integration (#60, commit 7524888) -- Extended learner onboarding profile endpoint — richer profile fields, - completion tracking (#59, commit be6ac8b) +- API Key authentication system for vendor service-to-service integration (#60, commit 7524888) +- Extended learner onboarding profile endpoint — richer profile fields, completion tracking (#59, commit be6ac8b) ## 2026-06-21 -- Two-step repayment flow: `buildRepaymentXdr()` + submit endpoint so - wallets sign server-built XDR (#51, commit 4224bb6) +- Two-step repayment flow: `buildRepaymentXdr()` + submit endpoint so wallets sign server-built XDR (#51, commit 4224bb6) ## 2026-06-19 -- Integrated Sentry for error tracking; fixed MetricsModule dependency - injection startup error (#55, commit 58848c1) -- Added `GET /api/v1/vouching/requests` for incoming vouch requests - (#54, commit cb60433) -- End-to-end loan lifecycle test with in-memory mock infrastructure - (#52, commit 5a4efa0) +- Integrated Sentry for error tracking; fixed MetricsModule dependency injection startup error (#55, commit 58848c1) +- Added `GET /api/v1/vouching/requests` for incoming vouch requests (#54, commit cb60433) +- End-to-end loan lifecycle test with in-memory mock infrastructure (#52, commit 5a4efa0) ## 2026-06-18 - Audit log for admin operations (#49, commit 760d459) -- SponsorsService: `getPool()`, `buildDepositXdr()`, `buildWithdrawXdr()` - with unit tests (#48, commit 1793032) - ---- - -<<<<<<< Updated upstream -> Note (2026-07-16): this file previously contained StepFi-Contracts -> content copied from the wrong repo. Replaced with real StepFi-API -> history backfilled from `git log`. Entries older than 2026-06-18 are -> in git history but were never tracked here. -======= -## Completed - -### Workspace Cleanup -- Removed dead code: `lp-contract` (superseded by `liquidity-pool-contract`) -- Removed empty placeholder: `adapter-trustless-contract` -- Updated `Cargo.toml` workspace members to reflect 5 active contracts -- Removed `[profile]` sections from individual contract `Cargo.toml` files (profiles belong in workspace root only) - -### Renaming -- Renamed `merchant-registry-contract` → `vendor-registry-contract` -- Updated all Rust source references: `merchant_registry_contract` → `vendor_registry_contract` -- Updated all struct names: `MerchantRegistry*` → `VendorRegistry*` -- Updated `Cargo.toml` dependency paths in `creditline-contract` - -### Critical Fixes -- Added TTL constants (`PERSISTENT_TTL_THRESHOLD`, `PERSISTENT_TTL_EXTEND_TO`) to `creditline-contract/src/storage.rs` -- Added `upgrade()` function to all 5 contracts: reputation, creditline, liquidity-pool, vendor-registry, parameters -- All 5 contracts build cleanly: `cargo build` passes with zero errors (3 minor unused constant warnings — acceptable) - -### Deployment -- Created `scripts/deploy-testnet.sh` — full deployment script covering all 5 contracts in correct dependency order -- Script outputs contract IDs and saves to `.env.contracts` -- StepFi-API deployed on Render ✅ -- Supabase project created, 24 migrations applied ✅ -- Upstash Redis connected ✅ -- Swagger docs live ✅ - -### Documentation -- `README.md` fully rewritten as StepFi-Contracts - -### CI Pipeline -- Created `.github/workflows/ci.yml` — runs on push/PR to `main` -- Steps: checkout → setup Node 20 → `npm ci` → `npm run build` → `npm test` -- `node_modules` cached via `actions/cache@v4` keyed on `package-lock.json` hash -- CI status badge added to `README.md` pointing at the workflow - -### Vendor Approval Lifecycle -- Created database migration `20260817000001_add_vendor_status.sql` adding `status` column constrained to `pending`, `approved`, `suspended`, `rejected`, defaulting to `pending` and backfilling existing rows. -- Added `buildApproveVendorXdr` and `buildSuspendVendorXdr` methods to `VendorRegistryContractClient` and `IVendorRegistryClient` to construct unsigned Soroban transaction XDRs. -- Created `AdminGuard` to enforce allowlisted wallet access via `ADMIN_WALLETS` (401 for unauthenticated, 403 for non-admin). -- Created `AuditAction` decorator and `AuditInterceptor` for audit-logging privileged admin operations. -- Added `POST /vendors/:id/approve` and `POST /vendors/:id/suspend` endpoints returning unsigned XDRs, guarded with `JwtAuthGuard` and `AdminGuard`, decorated with full Swagger annotations and returning HTTP 409 Conflict for invalid vendor status transitions (`VENDOR_NOT_PENDING`, `VENDOR_NOT_APPROVED`). -- Integrated status updates into `TransactionStatusCheckerProcessor` to update local Supabase `vendors` status only after on-chain transaction confirmation. -### Learner Profile Auto-Creation -- Added automatic creation of `learner_profiles` records upon first sign-in in `AuthService.findOrCreateUser()`, ensuring `GET /learners/me` resolves immediately after authentication. -- Updated `auth.service.spec.ts` unit tests to cover table query and insertion handling for `learner_profiles`. - - ---- - -## In Progress - -- None currently. - ---- - -## Next Up (In Order) - -1. **LoanType enum** — Add `LoanType::LearnerInstallment` variant to `creditline-contract/src/types.rs` -2. **Per-installment tracking** — Add `paid: bool` and `paid_at: u64` fields to `RepaymentInstallment` struct -3. **repay_installment()** — New function targeting a specific installment by index (instead of just reducing remaining balance) -4. **Learner grace period** — Make `grace_period_seconds` per-loan (not just global via parameters) -5. **Vouching contract** — New `vouching-contract` crate: `vouch()`, `revoke_vouch()`, `get_vouches()`, `get_vouch_count()` -6. **Reputation rules** — Update `creditline-contract` to call different reputation adjustments for `LoanType::LearnerInstallment` -7. **Testnet deployment** — Deploy all contracts, capture IDs, add to StepFi-API `.env` -8. **End-to-end validation** — Verify loan lifecycle on testnet via Stellar CLI - ---- - -## Open Questions - -- What token is used for loans — native XLM or a USDC anchor? (Affects token contract address in `initialize()`) -- Should the vouching contract be a standalone crate or logic added to `creditline-contract`? (Leaning toward standalone for modularity) -- What is the correct `grace_period_seconds` for learner installment loans? (Longer than standard BNPL — possibly 7-14 days per installment) -- Should sponsor pool deposits go through `liquidity-pool-contract` or a new `sponsor-pool-contract`? - ---- - -## Architecture Decisions - -- **5 contracts, not 6** — `lp-contract` was dead code, removed. `liquidity-pool-contract` is the canonical LP implementation. -- **Vendor over Merchant** — Renamed to reflect StepFi's learning-focused domain. -- **TTL approach** — Using 60-day threshold / 120-day extension constants. Off-chain indexer is responsible for bumping TTL on active loan entries. -- **Upgrade pattern** — All contracts have `upgrade()` gated by admin `require_auth()`. Admin address is set at `initialize()` and transferable via `set_admin()`. -- **Loan sharding** — 32 shards (`loan_id % 32`) in creditline-contract to distribute persistent storage keys and avoid hot-key contention. -- **Reentrancy** — Boolean `LOCKED` flag in instance storage. Cheaper than mutex, sufficient for Soroban's single-threaded execution model. - ---- - -## Contract Deployment Status - -| Contract | Testnet Deployed | Contract ID | Last Deployed | -|---|---|---|---| -| `reputation-contract` | ❌ No | — | — | -| `parameters-contract` | ❌ No | — | — | -| `vendor-registry-contract` | ❌ No | — | — | -| `liquidity-pool-contract` | ❌ No | — | — | -| `creditline-contract` | ❌ No | — | — | - -> Update this table after running `scripts/deploy-testnet.sh` - ---- - -## Session Notes - -- Always run `cargo build` after any contract change before committing. -- Always run `cargo test` before marking any contract feature complete. -- Never modify storage key structures of a contract that has been deployed — it breaks existing data. Use a migration pattern or deploy a new contract. -- The `creditline-contract` depends on all other contracts — it must be initialized last. -- Do not add new workspace members to `Cargo.toml` without creating the full contract file structure first. ->>>>>>> Stashed changes +- SponsorsService: `getPool()`, `buildDepositXdr()`, `buildWithdrawXdr()` with unit tests (#48, commit 1793032) diff --git a/src/common/interceptors/audit.interceptor.ts b/src/common/interceptors/audit.interceptor.ts index 4d73ed4..a011c83 100644 --- a/src/common/interceptors/audit.interceptor.ts +++ b/src/common/interceptors/audit.interceptor.ts @@ -10,28 +10,37 @@ import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { AUDIT_ACTION_KEY } from '../decorators/audit-action.decorator'; +export function formatAuditAction(actionMeta: unknown): string { + if (!actionMeta) { + return 'unknown'; + } + + if (typeof actionMeta === 'string') { + return actionMeta; + } + + const meta = actionMeta as { resource?: string; action?: string }; + return `${meta.resource ?? ''}.${meta.action ?? ''}`; +} + @Injectable() export class AuditInterceptor implements NestInterceptor { private readonly logger = new Logger(AuditInterceptor.name); constructor(private readonly reflector: Reflector) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { - const actionMeta = this.reflector.get(AUDIT_ACTION_KEY, context.getHandler()); - - if (!actionMeta) { - return next.handle(); - } - - const action = - typeof actionMeta === 'string' - ? actionMeta - : `${(actionMeta as Record).resource ?? ''}.${(actionMeta as Record).action ?? ''}`; + intercept(context: ExecutionContext, next: CallHandler): Observable { + const actionMeta = this.reflector.get(AUDIT_ACTION_KEY, context.getHandler()); + const action = formatAuditAction(actionMeta); const request = context.switchToHttp().getRequest(); const userWallet = request.user?.wallet ?? 'anonymous'; const params = request.params; + if (action === 'unknown') { + return next.handle(); + } + this.logger.log({ event: 'AUDIT_ACTION_INITIATED', action, diff --git a/src/database/repositories/users.repository.ts b/src/database/repositories/users.repository.ts index 235b23a..09df36d 100644 --- a/src/database/repositories/users.repository.ts +++ b/src/database/repositories/users.repository.ts @@ -16,7 +16,7 @@ export interface UserPreferencesRecord { theme: string; } -export type UserRole = 'sponsor' | 'vendor' | 'mentor'; +export type UserRole = 'sponsor' | 'vendor' | 'mentor' | 'admin'; export interface UserRecord { id: string; diff --git a/src/modules/admin/admin.guard.ts b/src/modules/admin/admin.guard.ts new file mode 100644 index 0000000..2ae5833 --- /dev/null +++ b/src/modules/admin/admin.guard.ts @@ -0,0 +1,133 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + InternalServerErrorException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { SupabaseService } from '../../database/supabase.client'; +import { AUDIT_ACTION_KEY } from '../../common/decorators/audit-action.decorator'; +import { formatAuditAction } from '../../common/interceptors/audit.interceptor'; + +type AuthenticatedRequest = { + user?: { + wallet?: string; + role?: string | null; + }; + method?: string; + url?: string; + params?: Record; +}; + +@Injectable() +export class AdminGuard implements CanActivate { + private readonly logger = new Logger('AuditInterceptor'); + + constructor( + private readonly supabaseService: SupabaseService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const action = this.resolveAction(context, request); + const wallet = request.user?.wallet; + + if (!wallet) { + this.logDeniedAttempt({ + action, + wallet: 'anonymous', + reason: 'UNAUTHENTICATED', + request, + }); + throw new UnauthorizedException({ + code: 'AUTH_TOKEN_INVALID', + message: 'Invalid or missing access token.', + }); + } + + const client = this.supabaseService.getServiceRoleClient(); + const { data: user, error } = await client + .from('users') + .select('wallet_address, role, status') + .eq('wallet_address', wallet) + .maybeSingle(); + + if (error) { + this.logger.error({ + event: 'ADMIN_AUTH_LOOKUP_FAILED', + action, + actor: wallet, + error: error.message, + timestamp: new Date().toISOString(), + }); + throw new InternalServerErrorException({ + code: 'ADMIN_AUTH_LOOKUP_FAILED', + message: 'Failed to verify admin access.', + }); + } + + const role = user?.role ?? null; + const status = user?.status ?? null; + + if (!user || role !== 'admin' || status === 'blocked') { + this.logDeniedAttempt({ + action, + wallet, + reason: !user ? 'USER_NOT_FOUND' : status === 'blocked' ? 'BLOCKED' : 'NOT_ADMIN', + request, + tokenRole: request.user?.role ?? null, + dbRole: role, + dbStatus: status, + }); + + throw new ForbiddenException({ + code: 'ADMIN_FORBIDDEN', + message: 'Admin access required.', + }); + } + + return true; + } + + private resolveAction(context: ExecutionContext, request: AuthenticatedRequest): string { + const actionMeta = this.reflector.getAllAndOverride(AUDIT_ACTION_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + const action = formatAuditAction(actionMeta); + if (action !== 'unknown') { + return action; + } + + const method = request.method ?? 'UNKNOWN'; + const url = request.url ?? 'unknown'; + return `${method} ${url}`; + } + + private logDeniedAttempt(params: { + action: string; + wallet: string; + reason: string; + request: AuthenticatedRequest; + tokenRole?: string | null; + dbRole?: string | null; + dbStatus?: string | null; + }): void { + this.logger.warn({ + event: 'AUDIT_ACTION_DENIED', + action: params.action, + actor: params.wallet, + reason: params.reason, + tokenRole: params.tokenRole ?? null, + dbRole: params.dbRole ?? null, + dbStatus: params.dbStatus ?? null, + params: params.request.params ?? {}, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 566323e..e8481d8 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; import { SupabaseService } from '../../database/supabase.client'; +import { AdminGuard } from './admin.guard'; @Module({ controllers: [AuditController], - providers: [AuditService, SupabaseService], + providers: [AuditService, SupabaseService, AdminGuard], exports: [AuditService], }) export class AdminModule {} diff --git a/src/modules/admin/audit.controller.ts b/src/modules/admin/audit.controller.ts index a415ee2..c09209b 100644 --- a/src/modules/admin/audit.controller.ts +++ b/src/modules/admin/audit.controller.ts @@ -4,7 +4,7 @@ import { AuditService } from './audit.service'; import { AuditLogQueryDto } from './dto/audit-log-query.dto'; import { AuditLogListResponseDto } from './dto/audit-log-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import { AdminGuard } from '../../auth/guards/admin.guard'; +import { AdminGuard } from './admin.guard'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { AuditAction } from '../../common/decorators/audit-action.decorator'; @@ -35,7 +35,8 @@ export class AuditController { description: 'Audit logs retrieved successfully', type: AuditLogListResponseDto, }) - @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid admin JWT' }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid access token' }) + @ApiResponse({ status: 403, description: 'Forbidden - admin access required' }) async getAuditLogs(@Query() query: AuditLogQueryDto) { return this.auditService.findMany(query); } diff --git a/src/modules/users/dto/user-response.dto.ts b/src/modules/users/dto/user-response.dto.ts index bdbc565..bb36860 100644 --- a/src/modules/users/dto/user-response.dto.ts +++ b/src/modules/users/dto/user-response.dto.ts @@ -32,10 +32,10 @@ export class UserProfileDto { @ApiProperty({ example: 'sponsor', nullable: true, - enum: ['sponsor', 'vendor', 'mentor'], + enum: ['sponsor', 'vendor', 'mentor', 'admin'], description: 'Permanent role chosen once via PATCH /users/me/role, null until chosen', }) - role: 'sponsor' | 'vendor' | 'mentor' | null; + role: 'sponsor' | 'vendor' | 'mentor' | 'admin' | null; @ApiProperty({ type: UserPreferencesDto }) preferences: UserPreferencesDto; diff --git a/supabase/migrations/20260825000000_allow_admin_user_role.sql b/supabase/migrations/20260825000000_allow_admin_user_role.sql new file mode 100644 index 0000000..48d1b23 --- /dev/null +++ b/supabase/migrations/20260825000000_allow_admin_user_role.sql @@ -0,0 +1,10 @@ +-- Expand the users.role constraint so server-side admin records can exist. +ALTER TABLE public.users + DROP CONSTRAINT IF EXISTS users_role_check; + +ALTER TABLE public.users + ADD CONSTRAINT users_role_check + CHECK (role IN ('sponsor', 'vendor', 'mentor', 'admin')); + +COMMENT ON COLUMN public.users.role IS + 'Permanent user role chosen once after registration: sponsor | vendor | mentor | admin. NULL = not yet chosen.'; diff --git a/test/unit/modules/admin/admin.guard.spec.ts b/test/unit/modules/admin/admin.guard.spec.ts new file mode 100644 index 0000000..f225293 --- /dev/null +++ b/test/unit/modules/admin/admin.guard.spec.ts @@ -0,0 +1,133 @@ +import { Logger, UnauthorizedException, InternalServerErrorException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AdminGuard } from '../../../../src/modules/admin/admin.guard'; +import { SupabaseService } from '../../../../src/database/supabase.client'; + +describe('AdminGuard', () => { + let guard: AdminGuard; + let warnSpy: jest.SpyInstance; + + const allowedWallet = 'GAQWQJJBC2D5YCR6WUFFZSL6DIFJ5CA4774QB6QWPRNHSUUVRNQ2BHXJ'; + const nonAdminWallet = 'GBXH6BL5Z7R5Y6RJSJRMJQH4YZVMYTCX2L4X2L4X2L4X2L4X2L4X2L4X'; + + const mockMaybeSingle = jest.fn(); + const mockEq = jest.fn().mockReturnThis(); + const mockSelect = jest.fn().mockReturnThis(); + const mockFrom = jest.fn().mockReturnValue({ + select: mockSelect, + eq: mockEq, + maybeSingle: mockMaybeSingle, + }); + const mockSupabaseService = { + getServiceRoleClient: jest.fn().mockReturnValue({ + from: mockFrom, + }), + } as unknown as SupabaseService; + + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue('audit_logs.VIEW_AUDIT_LOGS'), + } as unknown as Reflector; + + function createContext(user?: { wallet?: string; role?: string | null }, url = '/admin/audit-logs') { + const request = { + user, + url, + method: 'GET', + params: {}, + }; + + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => jest.fn(), + getClass: () => ({ name: 'AuditController' }), + } as never; + } + + beforeEach(() => { + guard = new AdminGuard(mockSupabaseService, reflector); + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('allows a wallet whose latest Supabase row is admin and active', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: { wallet_address: allowedWallet, role: 'admin', status: 'active' }, + error: null, + }); + + await expect(guard.canActivate(createContext({ wallet: allowedWallet, role: 'vendor' }))).resolves.toBe(true); + expect(mockSupabaseService.getServiceRoleClient).toHaveBeenCalled(); + expect(mockFrom).toHaveBeenCalledWith('users'); + expect(mockSelect).toHaveBeenCalledWith('wallet_address, role, status'); + expect(mockEq).toHaveBeenCalledWith('wallet_address', allowedWallet); + }); + + it('denies a non-admin wallet even if the JWT role claim says admin', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: { wallet_address: nonAdminWallet, role: 'vendor', status: 'active' }, + error: null, + }); + + const ctx = createContext({ wallet: nonAdminWallet, role: 'admin' }); + + await expect(guard.canActivate(ctx)).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'ADMIN_FORBIDDEN' }), + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'AUDIT_ACTION_DENIED', + actor: nonAdminWallet, + reason: 'NOT_ADMIN', + tokenRole: 'admin', + dbRole: 'vendor', + dbStatus: 'active', + }), + ); + }); + + it('denies a blocked admin', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: { wallet_address: allowedWallet, role: 'admin', status: 'blocked' }, + error: null, + }); + + await expect(guard.canActivate(createContext({ wallet: allowedWallet, role: 'admin' }))).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'ADMIN_FORBIDDEN' }), + }); + }); + + it('denies a stale token whose role claim says admin after the user was revoked in Supabase', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: { wallet_address: allowedWallet, role: 'vendor', status: 'active' }, + error: null, + }); + + await expect(guard.canActivate(createContext({ wallet: allowedWallet, role: 'admin' }))).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'ADMIN_FORBIDDEN' }), + }); + }); + + it('denies unauthenticated requests with a 401', async () => { + await expect(guard.canActivate(createContext(undefined))).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'AUTH_TOKEN_INVALID' }), + }); + }); + + it('surfaces a Supabase lookup failure as a server error', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: null, + error: { message: 'database unavailable' }, + }); + + await expect(guard.canActivate(createContext({ wallet: allowedWallet, role: 'admin' }))).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + }); +});