Skip to content

Commit 47a8184

Browse files
Address CodeRabbit comments
Signed-off-by: Alexander Shenshin <alexander.shenshin@dsr-corporation.com>
1 parent f15d701 commit 47a8184

4 files changed

Lines changed: 108 additions & 9 deletions

File tree

heka-identity-service/src/openid4vc/issuance-sessions/__tests__/issuance-session.service.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc'
22
import { createMock } from '@golevelup/ts-vitest'
3-
import { UnprocessableEntityException } from '@nestjs/common'
3+
import { BadRequestException, UnprocessableEntityException } from '@nestjs/common'
44
import { ConfigType } from '@nestjs/config'
55

66
import { TenantAgent } from 'common/agent'
@@ -348,6 +348,15 @@ describe('OpenId4VcIssuanceSessionService', () => {
348348
)
349349
expect(result.credentialOffer).toBe('openid-credential-offer://jwt')
350350
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-1', [5])
351+
// The index reserved in the status list must be the one the issued credential carries,
352+
// otherwise the credential points at a bit that is never set on revocation.
353+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).toHaveBeenCalledWith(
354+
expect.objectContaining({
355+
issuanceMetadata: expect.objectContaining({
356+
credentials: [expect.objectContaining({ credentialStatus: expect.objectContaining({ index: 5 }) })],
357+
}),
358+
}),
359+
)
351360
})
352361

353362
test('should create issuance session for JwtVcJsonLd format WITH credentialStatus', async () => {
@@ -402,6 +411,13 @@ describe('OpenId4VcIssuanceSessionService', () => {
402411
await service.offer(authInfo, tenantAgent, req)
403412

404413
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-2', [10])
414+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).toHaveBeenCalledWith(
415+
expect.objectContaining({
416+
issuanceMetadata: expect.objectContaining({
417+
credentials: [expect.objectContaining({ credentialStatus: expect.objectContaining({ index: 10 }) })],
418+
}),
419+
}),
420+
)
405421
})
406422

407423
test('should create issuance session for LdpVc format WITH credentialStatus', async () => {
@@ -456,6 +472,61 @@ describe('OpenId4VcIssuanceSessionService', () => {
456472
await service.offer(authInfo, tenantAgent, req)
457473

458474
expect(statusListService.addItems).toHaveBeenCalledWith(authInfo, 'sl-3', [0])
475+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).toHaveBeenCalledWith(
476+
expect.objectContaining({
477+
issuanceMetadata: expect.objectContaining({
478+
credentials: [expect.objectContaining({ credentialStatus: expect.objectContaining({ index: 0 }) })],
479+
}),
480+
}),
481+
)
482+
})
483+
484+
test('should reject an over-capacity batch before creating the credential offer', async () => {
485+
const mockIssuer = issuerRecordStub({
486+
issuerId: 'issuer-1',
487+
credentialConfigurationsSupported: {
488+
'cred-jwt-1': {
489+
format: 'jwt_vc_json',
490+
credential_definition: { type: ['VerifiableCredential'] },
491+
},
492+
},
493+
})
494+
495+
vi.mocked(tenantAgent.openid4vc.issuer.getIssuerByIssuerId).mockResolvedValue(mockIssuer)
496+
// One free slot (lastIndex 99 of size 100) but two revocable credentials requested
497+
vi.mocked(statusListService.getOrCreate).mockResolvedValue({ id: 'sl-4', lastIndex: 99, size: 100 } as any)
498+
vi.mocked(statusListService.location).mockReturnValue('https://example.com/status-lists/sl-4')
499+
vi.mocked(tenantAgent.dids.resolve).mockResolvedValue(
500+
didResolutionResultStub({
501+
didDocument: { verificationMethod: [{ id: 'did:key:z6MkJwt#key-1' }] },
502+
}),
503+
)
504+
vi.mocked(statusListService.assertHasFreeIndexes).mockImplementation(() => {
505+
throw new BadRequestException('Status list does not have enough free indexes')
506+
})
507+
508+
const req = {
509+
publicIssuerId: 'issuer-1',
510+
credentials: [
511+
{
512+
credentialSupportedId: 'cred-jwt-1',
513+
format: OpenId4VciCredentialFormatProfile.JwtVcJson,
514+
issuer: { did: 'did:key:z6MkJwt' },
515+
},
516+
{
517+
credentialSupportedId: 'cred-jwt-1',
518+
format: OpenId4VciCredentialFormatProfile.JwtVcJson,
519+
issuer: { did: 'did:key:z6MkJwt' },
520+
},
521+
],
522+
baseUri: 'https://example.com',
523+
} as any
524+
525+
await expect(service.offer(authInfo, tenantAgent, req)).rejects.toThrow(BadRequestException)
526+
527+
// The point of the preflight: nothing irreversible may happen once capacity is known to be short
528+
expect(tenantAgent.openid4vc.issuer.createCredentialOffer).not.toHaveBeenCalled()
529+
expect(statusListService.addItems).not.toHaveBeenCalled()
459530
})
460531

461532
test('should create issuance session for MsoMdoc format without DID resolution or credentialStatus', async () => {

heka-identity-service/src/openid4vc/issuance-sessions/issuance-session.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ export class OpenId4VcIssuanceSessionService {
129129
mappedCredentials.push(credentialIssuanceMeta)
130130
}
131131

132+
// Preflight status list capacity check before the offer is created
133+
if (credentialIndexes.length) {
134+
this.statusListService.assertHasFreeIndexes(statusList, credentialIndexes.length)
135+
}
136+
132137
const { credentialOffer, issuanceSession } = await tenantAgent.openid4vc.issuer.createCredentialOffer({
133138
baseUri: req.baseUri,
134139
credentialConfigurationIds: req.credentials.map((c) => c.credentialSupportedId),

heka-identity-service/src/revocation/status-list/__tests__/status-list.service.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createMock } from '@golevelup/ts-vitest'
22
import { EntityManager } from '@mikro-orm/core'
3-
import { BadRequestException } from '@nestjs/common'
3+
import { BadRequestException, InternalServerErrorException } from '@nestjs/common'
44
import { ConfigType } from '@nestjs/config'
55

66
import { entityStub } from '../../../../test/helpers/mock-records'
@@ -260,8 +260,27 @@ describe('StatusListService', () => {
260260

261261
vi.mocked(em.findOneOrFail).mockResolvedValue(statusListEntity)
262262

263-
// Only 1 free slot remains (lastIndex 99 of size 100), so adding 2 items must be rejected
264-
await expect(service.addItems(authInfo, id, [100, 101])).rejects.toThrow(BadRequestException)
263+
// Only 1 free slot remains (lastIndex 99 of size 100), so adding 2 items must be rejected.
264+
// The indexes are deliberately in-range so that only the capacity guard can reject them —
265+
// out-of-range indexes would also trip the per-index bound and mask its removal.
266+
await expect(service.addItems(authInfo, id, [0, 99])).rejects.toThrow(BadRequestException)
267+
expect(mockSet).not.toHaveBeenCalled()
268+
expect(em.flush).not.toHaveBeenCalled()
269+
})
270+
271+
test('should fail as a server error when the stored list is not Multibase-encoded', async () => {
272+
const id = 'status-list-1'
273+
const statusListEntity = entityStub<CredentialStatusList>({
274+
id,
275+
encodedList: 'H4sIunprefixed-legacy-value',
276+
lastIndex: 5,
277+
size: 100,
278+
owner: mockUser,
279+
})
280+
281+
vi.mocked(em.findOneOrFail).mockResolvedValue(statusListEntity)
282+
283+
await expect(service.addItems(authInfo, id, [10])).rejects.toThrow(InternalServerErrorException)
265284
expect(mockSet).not.toHaveBeenCalled()
266285
expect(em.flush).not.toHaveBeenCalled()
267286
})

heka-identity-service/src/revocation/status-list/status-list.service.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Bitstring } from '@digitalcredentials/bitstring'
22
import { EntityManager } from '@mikro-orm/core'
3-
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
3+
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'
44
import { ConfigType } from '@nestjs/config'
55

66
import { CredentialStatusList } from 'common/entities'
@@ -26,7 +26,7 @@ function toMultibaseBase64url(base64url: string): string {
2626

2727
function fromMultibaseBase64url(encodedList: string): string {
2828
if (!encodedList.startsWith(MULTIBASE_BASE64URL_PREFIX)) {
29-
throw new BadRequestException('Status list encodedList is not Multibase base64url-encoded')
29+
throw new InternalServerErrorException('Stored status list is not Multibase base64url-encoded')
3030
}
3131
return encodedList.slice(MULTIBASE_BASE64URL_PREFIX.length)
3232
}
@@ -90,12 +90,16 @@ export class StatusListService {
9090
return list ?? (await this.create(authInfo, { issuer }))
9191
}
9292

93+
public assertHasFreeIndexes(statusList: CredentialStatusList, count: number): void {
94+
if (statusList.lastIndex + count > statusList.size) {
95+
throw new BadRequestException('Status list does not have enough free indexes')
96+
}
97+
}
98+
9399
public async addItems(authInfo: AuthInfo, id: string, indexes: Array<number>): Promise<void> {
94100
const statusList = await this.em.findOneOrFail(CredentialStatusList, { id, owner: authInfo.user })
95101

96-
if (statusList.lastIndex + indexes.length > statusList.size) {
97-
throw new BadRequestException('Status list does not have enough free indexes')
98-
}
102+
this.assertHasFreeIndexes(statusList, indexes.length)
99103

100104
statusList.encodedList = await this.updatedBitstring(statusList.encodedList, statusList.size, indexes, false)
101105
statusList.lastIndex += indexes.length

0 commit comments

Comments
 (0)