From 6b99331f0e674a30503ad87f900aba38b7d58436 Mon Sep 17 00:00:00 2001 From: 0xpreshh Date: Sun, 18 Oct 2026 13:33:42 +0100 Subject: [PATCH 1/2] fix: arweave truncation, graphql pagination, versioning in spec, reconciliation re-fetch - #756: ArweaveArchiveService now actually truncates oversized dispute archives instead of only logging a warning; falls back to a minimal stub if still too large - #763: GraphQL escrows query now accepts offset/limit args and returns EscrowPage (data, total, offset, limit) matching the REST list pagination contract - #758: Supertest integration tests enable URI versioning on the Nest app so /v1/reputation/* routes resolve instead of 404ing - #750: EscrowReconciliationService fetches the full escrow list once and filters in-memory, eliminating the O(n) re-fetch loop that called findAll on every batch --- .../src/dispute/arweave-archive.service.ts | 27 +++++++++++- .../escrow-reconciliation.service.ts | 15 +------ .../escrow-graphql.integration.spec.ts | 43 ++++++++++++++----- backend/src/escrow/graphql/escrow.resolver.ts | 35 +++++++++++++-- .../reputation/reputation.controller.spec.ts | 3 +- 5 files changed, 93 insertions(+), 30 deletions(-) diff --git a/backend/src/dispute/arweave-archive.service.ts b/backend/src/dispute/arweave-archive.service.ts index ae5c7c8..80c8d5c 100644 --- a/backend/src/dispute/arweave-archive.service.ts +++ b/backend/src/dispute/arweave-archive.service.ts @@ -88,11 +88,34 @@ export class ArweaveArchiveService implements OnModuleInit { ['Step', saga.currentStep], ]; + let uploadData = jsonString; if (jsonString.length > MAX_JSON_SIZE) { - this.logger.warn(`Archive data exceeds ${MAX_JSON_SIZE / 1024}KB, truncating for Arweave bundle`); + this.logger.warn( + `Archive data exceeds ${MAX_JSON_SIZE / 1024}KB, truncating votes and stepHistory for Arweave bundle`, + ); + const truncated = { + ...archiveData, + votes: (archiveData.votes as unknown[]).slice(0, 10), + stepHistory: (archiveData.stepHistory as unknown[]).slice(0, 20), + _truncated: true, + }; + uploadData = JSON.stringify(truncated); + if (uploadData.length > MAX_JSON_SIZE) { + uploadData = JSON.stringify({ + sagaId: archiveData.sagaId, + escrowId: archiveData.escrowId, + initiator: archiveData.initiator, + verdict: archiveData.verdict, + currentStep: archiveData.currentStep, + createdAt: archiveData.createdAt, + completedAt: archiveData.completedAt, + _truncated: true, + _reason: 'archive exceeded max size after initial truncation', + }); + } } - const txId = await this.uploader.upload(jsonString, tags); + const txId = await this.uploader.upload(uploadData, tags); this.metrics?.increment('arweave_archive_success_total', { result: 'success' }); this.logger.log(`Arweave archive uploaded for saga ${saga.sagaId}, txId: ${txId}`); diff --git a/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts b/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts index d2c6280..0d9df1d 100644 --- a/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts +++ b/backend/src/escrow-reconciliation/escrow-reconciliation.service.ts @@ -64,19 +64,8 @@ export class EscrowReconciliationService { const drifts: DriftRecord[] = []; const errors: ReconciliationError[] = []; - // Use cursor-based pagination to load escrows in batches instead of loading all at once - const BATCH_SIZE = 50; - const linked: Escrow[] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - const batch = await this.escrowService.findAll(); - const batchLinked = batch.slice(offset, offset + BATCH_SIZE).filter(e => e.contractEscrowId); - linked.push(...batchLinked); - offset += BATCH_SIZE; - hasMore = offset < batch.length; - } + const all = await this.escrowService.findAll(); + const linked = all.filter(e => e.contractEscrowId); const knownIds = new Set(linked.map(e => e.contractEscrowId as string)); diff --git a/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts b/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts index 4203248..21e3b9c 100644 --- a/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts +++ b/backend/src/escrow/graphql/escrow-graphql.integration.spec.ts @@ -4,7 +4,7 @@ import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { join } from 'path'; import request from 'supertest'; import { INestApplication } from '@nestjs/common'; -import { EscrowResolver, MilestoneResolver, DeliverableResolver } from './escrow.resolver'; +import { EscrowResolver, MilestoneResolver, DeliverableResolver, EscrowPage } from './escrow.resolver'; import { EscrowType } from './types/escrow.type'; import { MilestoneType } from './types/milestone.type'; import { DeliverableType } from './types/deliverable.type'; @@ -57,6 +57,7 @@ describe('EscrowGraphQL Integration', () => { EscrowResolver, MilestoneResolver, DeliverableResolver, + EscrowPage, { provide: EscrowService, useValue: mockEscrowService }, { provide: MilestoneLoader, useValue: mockMilestoneLoader }, { provide: DeliverableLoader, useValue: mockDeliverableLoader }, @@ -80,18 +81,19 @@ describe('EscrowGraphQL Integration', () => { // ─── escrows query ───────────────────────────────────────────────────────── describe('escrows query', () => { - it('returns an empty array when no escrows exist', async () => { + it('returns an empty page when no escrows exist', async () => { mockEscrowService.findAll.mockResolvedValue([]); const res = await request(app.getHttpServer()) .post('/graphql') - .send({ query: `{ escrows { id status } }` }); + .send({ query: `{ escrows { data { id status } total offset limit } }` }); expect(res.status).toBe(200); - expect(res.body.data.escrows).toEqual([]); + expect(res.body.data.escrows.data).toEqual([]); + expect(res.body.data.escrows.total).toBe(0); }); - it('returns escrows with scalar fields', async () => { + it('returns escrows with scalar fields and pagination metadata', async () => { const escrows = [ { id: 'esc-1', @@ -114,12 +116,33 @@ describe('EscrowGraphQL Integration', () => { const res = await request(app.getHttpServer()) .post('/graphql') - .send({ query: `{ escrows { id depositor beneficiary amountXLM status createdAt } }` }); + .send({ query: `{ escrows { data { id depositor beneficiary amountXLM status createdAt } total offset limit } }` }); expect(res.status).toBe(200); - expect(res.body.data.escrows).toHaveLength(2); - expect(res.body.data.escrows[0].id).toBe('esc-1'); - expect(res.body.data.escrows[1].status).toBe('pending'); + expect(res.body.data.escrows.data).toHaveLength(2); + expect(res.body.data.escrows.data[0].id).toBe('esc-1'); + expect(res.body.data.escrows.data[1].status).toBe('pending'); + expect(res.body.data.escrows.total).toBe(2); + }); + + it('respects offset and limit args', async () => { + const escrows = [ + { id: 'esc-1', depositor: 'GABC', beneficiary: 'GDEF', amountXLM: '100', status: 'active', createdAt: '2026-01-01T00:00:00.000Z' }, + { id: 'esc-2', depositor: 'GGHI', beneficiary: 'GJKL', amountXLM: '200', status: 'pending', createdAt: '2026-01-02T00:00:00.000Z' }, + { id: 'esc-3', depositor: 'GMNO', beneficiary: 'GPQR', amountXLM: '300', status: 'released', createdAt: '2026-01-03T00:00:00.000Z' }, + ]; + mockEscrowService.findAll.mockResolvedValue(escrows); + + const res = await request(app.getHttpServer()) + .post('/graphql') + .send({ query: `{ escrows(offset: 1, limit: 1) { data { id } total offset limit } }` }); + + expect(res.status).toBe(200); + expect(res.body.data.escrows.data).toHaveLength(1); + expect(res.body.data.escrows.data[0].id).toBe('esc-2'); + expect(res.body.data.escrows.total).toBe(3); + expect(res.body.data.escrows.offset).toBe(1); + expect(res.body.data.escrows.limit).toBe(1); }); }); @@ -291,7 +314,7 @@ describe('EscrowGraphQL Integration', () => { const res = await request(app.getHttpServer()) .post('/graphql') - .send({ query: `{ escrows { id milestones { id } } }` }); + .send({ query: `{ escrows { data { id milestones { id } } } }` }); expect(res.status).toBe(200); // DataLoader should have been called once per unique escrow ID diff --git a/backend/src/escrow/graphql/escrow.resolver.ts b/backend/src/escrow/graphql/escrow.resolver.ts index 6a85e44..cd3a32b 100644 --- a/backend/src/escrow/graphql/escrow.resolver.ts +++ b/backend/src/escrow/graphql/escrow.resolver.ts @@ -1,4 +1,4 @@ -import { Resolver, Query, Args, ResolveField, Parent } from '@nestjs/graphql'; +import { Resolver, Query, Args, ResolveField, Parent, Int, ObjectType, Field } from '@nestjs/graphql'; import { EscrowType } from './types/escrow.type'; import { MilestoneType } from './types/milestone.type'; import { DeliverableType } from './types/deliverable.type'; @@ -11,6 +11,21 @@ import { JurorVoteLoader } from './dataloader/juror-vote.loader'; import { UserProfileLoader } from './dataloader/user-profile.loader'; import { Inject } from '@nestjs/common'; +@ObjectType() +export class EscrowPage { + @Field(() => [EscrowType]) + data!: EscrowType[]; + + @Field(() => Int) + total!: number; + + @Field(() => Int) + offset!: number; + + @Field(() => Int) + limit!: number; +} + @Resolver(() => EscrowType) export class EscrowResolver { constructor( @@ -21,9 +36,21 @@ export class EscrowResolver { @Inject(UserProfileLoader) private readonly userProfileLoader: UserProfileLoader, ) {} - @Query(() => [EscrowType]) - async escrows(): Promise { - return this.escrowService.findAll() as unknown as EscrowType[]; + @Query(() => EscrowPage) + async escrows( + @Args('offset', { type: () => Int, nullable: true, defaultValue: 0 }) offset: number, + @Args('limit', { type: () => Int, nullable: true, defaultValue: 20 }) limit: number, + ): Promise { + const safeOffset = Math.max(0, offset); + const safeLimit = Math.min(Math.max(1, limit), 100); + const all = await this.escrowService.findAll(); + const data = all.slice(safeOffset, safeOffset + safeLimit); + return { + data: data as unknown as EscrowType[], + total: all.length, + offset: safeOffset, + limit: safeLimit, + }; } @Query(() => EscrowType, { nullable: true }) diff --git a/backend/src/reputation/reputation.controller.spec.ts b/backend/src/reputation/reputation.controller.spec.ts index c2fd6b7..370fe37 100644 --- a/backend/src/reputation/reputation.controller.spec.ts +++ b/backend/src/reputation/reputation.controller.spec.ts @@ -1,4 +1,4 @@ -import { INestApplication } from '@nestjs/common'; +import { INestApplication, VersioningType } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { ReputationController } from './reputation.controller'; @@ -91,6 +91,7 @@ describe('ReputationController Supertest integration', () => { }).compile(); app = module.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); await app.init(); }); From e5319c1b63b7ea97bec2012a11e627279963de8e Mon Sep 17 00:00:00 2001 From: Big-JoshCode Date: Sun, 18 Oct 2026 14:18:20 +0100 Subject: [PATCH 2/2] Fix JWT secret rotation, milestone persistence, test paths, and analytics validation - Support JWT_SECRET_PREVIOUS for graceful secret rotation (#745) JwtStrategy now verifies tokens against both current and previous secrets using getJwtVerificationSecrets() with secretOrPublicKeyProvider - Persist milestone approvals to Redis (#747) approveMilestone() now saves approved milestones to Redis, fixing non-functional milestone approval system. Adds MILESTONE_KEY_PREFIX and milestoneKey() helper. - Remove /v1 prefixes from test paths (#752) idempotency.integration.spec.ts and security-headers.spec.ts were asserting against /v1-prefixed paths that test apps never configured - Add limit parameter validation to GET /admin/analytics/escrows (#746) Validates limit is a positive integer, returns 400 on invalid input instead of crashing with raw Redis errors Closes #745 Closes #746 Closes #747 Closes #752 --- backend/src/admin/admin.controller.ts | 11 +++++-- backend/src/auth/jwt.strategy.ts | 7 ++-- .../idempotency.integration.spec.ts | 32 +++++++++---------- backend/src/escrow/escrow.service.ts | 20 ++++++++++++ backend/src/security-headers.spec.ts | 16 +++++----- 5 files changed, 57 insertions(+), 29 deletions(-) diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index a4482af..c9de3f7 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { BadRequestException, Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/auth.guard'; import { AdminGuard } from './admin.guard'; @@ -41,10 +41,15 @@ export class AdminController { @Get('escrows') @ApiOperation({ summary: 'Escrow analytics: totals and status breakdown' }) @ApiResponse({ status: 200, type: EscrowAnalyticsDto }) + @ApiResponse({ status: 400, description: 'Invalid limit parameter' }) @ApiResponse({ status: 401, description: 'Missing or invalid JWT' }) @ApiResponse({ status: 403, description: 'Caller is authenticated but not an admin address' }) - getEscrowAnalytics(@Query('limit') limit = 50, @Query('cursor') cursor?: string) { - return this.adminService.getEscrowAnalytics(limit, cursor); + getEscrowAnalytics(@Query('limit') limit?: string | number, @Query('cursor') cursor?: string) { + const parsedLimit = limit !== undefined ? parseInt(String(limit), 10) : 50; + if (isNaN(parsedLimit) || parsedLimit <= 0) { + throw new BadRequestException('limit must be a positive integer'); + } + return this.adminService.getEscrowAnalytics(parsedLimit, cursor); } @Get('gigs') diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index e4bfad5..602ac80 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { config, JWT_ALGORITHM } from '../config/env.config'; +import { JWT_ALGORITHM, getJwtVerificationSecrets } from '../config/env.config'; export interface JwtPayload { address: string; @@ -17,7 +17,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: config.JWT_SECRET, + secretOrPublicKeyProvider: (_request: any, _rawJwtToken: string, done: any) => { + const secrets = getJwtVerificationSecrets(); + done(null, secrets); + }, algorithms: [JWT_ALGORITHM], }); } diff --git a/backend/src/common/idempotency/idempotency.integration.spec.ts b/backend/src/common/idempotency/idempotency.integration.spec.ts index 506ef8e..f84998d 100644 --- a/backend/src/common/idempotency/idempotency.integration.spec.ts +++ b/backend/src/common/idempotency/idempotency.integration.spec.ts @@ -107,13 +107,13 @@ describe('Idempotency integration — gig-like endpoint', () => { const key = 'gig-idem-uuid-001'; const res1 = await request(app.getHttpServer()) - .post('/v1/test-gigs') + .post('/test-gigs') .set('Idempotency-Key', key) .send(gigBody) .expect(201); const res2 = await request(app.getHttpServer()) - .post('/v1/test-gigs') + .post('/test-gigs') .set('Idempotency-Key', key) .send(gigBody) .expect(201); @@ -126,13 +126,13 @@ describe('Idempotency integration — gig-like endpoint', () => { const key = 'gig-idem-uuid-002'; await request(app.getHttpServer()) - .post('/v1/test-gigs') + .post('/test-gigs') .set('Idempotency-Key', key) .send(gigBody) .expect(201); const res = await request(app.getHttpServer()) - .post('/v1/test-gigs') + .post('/test-gigs') .set('Idempotency-Key', key) .send({ ...gigBody, title: 'DIFFERENT TITLE' }) .expect(422); @@ -141,9 +141,9 @@ describe('Idempotency integration — gig-like endpoint', () => { }); it('creates separate records when no Idempotency-Key is provided', async () => { - const res1 = await request(app.getHttpServer()).post('/v1/test-gigs').send(gigBody).expect(201); + const res1 = await request(app.getHttpServer()).post('/test-gigs').send(gigBody).expect(201); - const res2 = await request(app.getHttpServer()).post('/v1/test-gigs').send(gigBody).expect(201); + const res2 = await request(app.getHttpServer()).post('/test-gigs').send(gigBody).expect(201); expect(res1.body.id).toBe('gig-1'); expect(res2.body.id).toBe('gig-2'); @@ -183,13 +183,13 @@ describe('Idempotency integration — escrow-like endpoint', () => { const key = 'esc-idem-uuid-001'; const res1 = await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .set('Idempotency-Key', key) .send(escrowBody) .expect(201); const res2 = await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .set('Idempotency-Key', key) .send(escrowBody) .expect(201); @@ -202,13 +202,13 @@ describe('Idempotency integration — escrow-like endpoint', () => { const key = 'esc-idem-uuid-002'; await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .set('Idempotency-Key', key) .send(escrowBody) .expect(201); await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .set('Idempotency-Key', key) .send({ ...escrowBody, amountXLM: '999' }) .expect(422); @@ -216,12 +216,12 @@ describe('Idempotency integration — escrow-like endpoint', () => { it('creates separate records when no Idempotency-Key is provided', async () => { const res1 = await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .send(escrowBody) .expect(201); const res2 = await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .send(escrowBody) .expect(201); @@ -257,7 +257,7 @@ describe('Idempotency integration — cross-endpoint key namespacing', () => { const sharedKey = 'shared-key-across-endpoints'; const gigRes = await request(app.getHttpServer()) - .post('/v1/test-gigs') + .post('/test-gigs') .set('Idempotency-Key', sharedKey) .send({ creator: 'GABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01', @@ -267,7 +267,7 @@ describe('Idempotency integration — cross-endpoint key namespacing', () => { .expect(201); const escrowRes = await request(app.getHttpServer()) - .post('/v1/test-escrows') + .post('/test-escrows') .set('Idempotency-Key', sharedKey) .send({ depositor: 'GDEP0001234567890DEP0001234567890DEP0001234567890DEP0001', @@ -314,7 +314,7 @@ describe('Idempotency integration — concurrent duplicate requests', () => { const responses = await Promise.all( Array.from({ length: concurrency }, () => - request(app.getHttpServer()).post('/v1/test-slow').set('Idempotency-Key', key).send(body), + request(app.getHttpServer()).post('/test-slow').set('Idempotency-Key', key).send(body), ), ); @@ -329,7 +329,7 @@ describe('Idempotency integration — concurrent duplicate requests', () => { // Once the winner finishes, a later retry replays the cached result // instead of creating a second resource. const retry = await request(app.getHttpServer()) - .post('/v1/test-slow') + .post('/test-slow') .set('Idempotency-Key', key) .send(body) .expect(201); diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 2dffdf4..c6684fa 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -22,6 +22,7 @@ import { AuditService } from '../audit/audit.service'; import { config } from '../config/env.config'; const ESCROW_KEY_PREFIX = 'escrow:'; +const MILESTONE_KEY_PREFIX = 'milestone:'; export const ESCROWS_INDEX_KEY = 'escrows:index'; const ESCROWS_BY_DEPOSITOR_PREFIX = 'escrows:by-depositor:'; const ESCROWS_BY_CONTRACT_PREFIX = 'escrow:contract:'; @@ -518,6 +519,21 @@ export class EscrowService implements OnModuleInit { milestone.approver = signerAddress; milestone.approvedAt = new Date().toISOString(); + if (this.redis) { + try { + const results = await this.redis + .multi() + .set(this.milestoneKey(id), JSON.stringify(milestone)) + .exec(); + assertTransactionApplied(results); + return milestone; + } catch (err) { + this.rethrowIfInconsistent('approveMilestone', id, err); + this.logFallback('approveMilestone', err); + } + } + + this.milestones.set(id, milestone); return milestone; } @@ -552,6 +568,10 @@ export class EscrowService implements OnModuleInit { return `${ESCROW_KEY_PREFIX}${id}`; } + private milestoneKey(id: string): string { + return `${MILESTONE_KEY_PREFIX}${id}`; + } + private depositorKey(address: string): string { return `${ESCROWS_BY_DEPOSITOR_PREFIX}${address}`; } diff --git a/backend/src/security-headers.spec.ts b/backend/src/security-headers.spec.ts index 4fddf01..8e3e6ac 100644 --- a/backend/src/security-headers.spec.ts +++ b/backend/src/security-headers.spec.ts @@ -48,7 +48,7 @@ describe('Security Headers (Helmet)', () => { }); it('returns standard Helmet security headers on API responses', async () => { - const res = await request(app.getHttpServer()).get('/v1/health').expect(200); + const res = await request(app.getHttpServer()).get('/health').expect(200); expect(res.headers['x-content-type-options']).toBe('nosniff'); expect(res.headers['strict-transport-security']).toBeDefined(); @@ -59,7 +59,7 @@ describe('Security Headers (Helmet)', () => { }); it('applies a strict CSP to API responses', async () => { - const res = await request(app.getHttpServer()).get('/v1/health').expect(200); + const res = await request(app.getHttpServer()).get('/health').expect(200); const csp = res.headers['content-security-policy']; expect(csp).toContain("default-src 'none'"); @@ -68,7 +68,7 @@ describe('Security Headers (Helmet)', () => { }); it('applies the relaxed Swagger CSP only on /api/docs', async () => { - const res = await request(app.getHttpServer()).get('/v1/api/docs/').expect(200); + const res = await request(app.getHttpServer()).get('/api/docs/').expect(200); expect(res.text).toContain('swagger-ui'); expect(res.headers['x-content-type-options']).toBe('nosniff'); @@ -93,8 +93,8 @@ describe('docs availability and protection', () => { }).compile(); const app = moduleFixture.createNestApplication(); await app.init(); - await request(app.getHttpServer()).get('/v1/api/docs').expect(404); - await request(app.getHttpServer()).get('/v1/api/docs-json').expect(404); + await request(app.getHttpServer()).get('/api/docs').expect(404); + await request(app.getHttpServer()).get('/api/docs-json').expect(404); await app.close(); }); @@ -110,9 +110,9 @@ describe('docs availability and protection', () => { SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: '/api/docs-json' }); await app.init(); const server = app.getHttpServer(); - await request(server).get('/v1/api/docs-json').expect(401); - await request(server).get('/v1/api/docs-json').auth('u', 'wrong').expect(401); - await request(server).get('/v1/api/docs-json').auth('u', 'p').expect(200); + await request(server).get('/api/docs-json').expect(401); + await request(server).get('/api/docs-json').auth('u', 'wrong').expect(401); + await request(server).get('/api/docs-json').auth('u', 'p').expect(200); await app.close(); });