Skip to content

Commit 6fa0d60

Browse files
committed
test(identity-service): add unit tests for ContributorCredentialService
Covers ContributorCredentialService with 13 unit tests: Bootstrap path (onApplicationBootstrap): - Creates OID4VCI issuer when none exists for the demo tenant DID - Skips creation when GithubContributorCredentialSdJwt is already registered - Logs a warning (no throw) when the demo wallet is not initialised - Adds credential config to an existing issuer that lacks it Issuance path (issueContributorCredential): - Returns a valid openid-credential-offer:// URI for a verified contributor - Passes the correct SD-JWT VC payload (all five Week 2 claims) - Sets the correct disclosure frame (githubUsername + gpgFingerprint in _sd) - Does not include non-selectively-disclosable claims in the frame - Uses the first verification method from the DID document deterministically - Throws NotFoundException when no ContributorBinding exists - Throws ConflictException when binding exists but GPG is incomplete - Throws NotFoundException when the demo tenant wallet is not initialised
1 parent ecb5424 commit 6fa0d60

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
2+
import { createMock } from '@golevelup/ts-vitest'
3+
import { EntityManager } from '@mikro-orm/core'
4+
import { ConflictException, NotFoundException } from '@nestjs/common'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
import { ContributorBinding } from 'contributor-onboarding'
8+
import { OpenId4VcIssuerService } from 'openid4vc/issuer/issuer.service'
9+
10+
import {
11+
CONTRIBUTOR_CREDENTIAL_SELECTIVE_CLAIMS,
12+
CONTRIBUTOR_CREDENTIAL_SUPPORTED_ID,
13+
CONTRIBUTOR_CREDENTIAL_VCT,
14+
} from '../contributor-credential.constants'
15+
import { ContributorCredentialService } from '../contributor-credential.service'
16+
17+
function buildBinding(overrides: Partial<ContributorBinding> = {}): ContributorBinding {
18+
const b = new ContributorBinding({
19+
githubAccountId: '11111111',
20+
githubUsername: 'test-contributor',
21+
walletId: 'User_demo',
22+
gpgFingerprint: 'AABBCCDDEEFF00112233445566778899AABBCCDD',
23+
verifiedAt: new Date('2026-07-13T10:00:00.000Z'),
24+
})
25+
return Object.assign(b, overrides)
26+
}
27+
28+
const mockAgentConfig = {
29+
contributorIssuerDemoUser: 'demo',
30+
} as any
31+
32+
const FAKE_ISSUER_DID = 'did:hedera:testnet:z6Mk'
33+
const FAKE_VERIFICATION_METHOD = `${FAKE_ISSUER_DID}#key-1`
34+
const FAKE_CREDENTIAL_OFFER_URI = 'openid-credential-offer://?credential_offer_uri=https://example.com/offer/abc'
35+
36+
function buildFakeTenantAgent(overrides: Partial<{
37+
createdDids: Array<{ did: string }>
38+
verificationMethods: Array<{ id: string }>
39+
credentialOfferUri: string
40+
}> = {}) {
41+
const createdDids = overrides.createdDids ?? [{ did: FAKE_ISSUER_DID }]
42+
const verificationMethods = overrides.verificationMethods ?? [{ id: FAKE_VERIFICATION_METHOD }]
43+
const credentialOfferUri = overrides.credentialOfferUri ?? FAKE_CREDENTIAL_OFFER_URI
44+
45+
return {
46+
dids: {
47+
getCreatedDids: vi.fn().mockResolvedValue(createdDids),
48+
resolve: vi.fn().mockResolvedValue({
49+
didDocument: { verificationMethod: verificationMethods },
50+
}),
51+
},
52+
openid4vc: {
53+
issuer: {
54+
createCredentialOffer: vi.fn().mockResolvedValue({
55+
credentialOffer: credentialOfferUri,
56+
issuanceSession: { id: 'session-1' },
57+
}),
58+
},
59+
},
60+
}
61+
}
62+
63+
describe('ContributorCredentialService', () => {
64+
let service: ContributorCredentialService
65+
let agentMock: any
66+
let emMock: ReturnType<typeof createMock<EntityManager>>
67+
let emForkMock: ReturnType<typeof createMock<EntityManager>>
68+
let issuerServiceMock: ReturnType<typeof createMock<OpenId4VcIssuerService>>
69+
70+
beforeEach(() => {
71+
let fakeTenantAgent = buildFakeTenantAgent()
72+
73+
agentMock = {
74+
modules: {
75+
tenants: {
76+
withTenantAgent: vi.fn().mockImplementation(
77+
async (_opts: unknown, cb: (ta: typeof fakeTenantAgent) => Promise<void>) => {
78+
await cb(fakeTenantAgent)
79+
},
80+
),
81+
},
82+
},
83+
}
84+
85+
emForkMock = createMock<EntityManager>({
86+
findOne: vi.fn(),
87+
})
88+
emMock = createMock<EntityManager>({
89+
findOne: vi.fn(),
90+
fork: vi.fn().mockReturnValue(emForkMock),
91+
})
92+
93+
issuerServiceMock = createMock<OpenId4VcIssuerService>({
94+
find: vi.fn().mockResolvedValue([]),
95+
createIssuer: vi.fn().mockResolvedValue({ publicIssuerId: FAKE_ISSUER_DID }),
96+
updateIssuerMetadata: vi.fn().mockResolvedValue({}),
97+
})
98+
99+
service = new ContributorCredentialService(
100+
agentMock,
101+
mockAgentConfig,
102+
emMock as unknown as EntityManager,
103+
issuerServiceMock,
104+
)
105+
})
106+
107+
describe('onApplicationBootstrap', () => {
108+
it('creates issuer when demo tenant wallet exists and no issuer is registered', async () => {
109+
vi.mocked(emForkMock.findOne).mockResolvedValue({ id: 'User_demo', tenantId: 'tenant-abc' } as any)
110+
vi.mocked(issuerServiceMock.find).mockResolvedValue([])
111+
112+
await service.onApplicationBootstrap()
113+
114+
expect(issuerServiceMock.createIssuer).toHaveBeenCalledOnce()
115+
const callArgs = vi.mocked(issuerServiceMock.createIssuer).mock.calls[0][1]
116+
expect(callArgs.credentialsSupported).toHaveLength(1)
117+
expect(callArgs.credentialsSupported[0].id).toBe(CONTRIBUTOR_CREDENTIAL_SUPPORTED_ID)
118+
expect((callArgs.credentialsSupported[0] as any).vct).toBe(CONTRIBUTOR_CREDENTIAL_VCT)
119+
})
120+
121+
it('skips creation when credential config is already registered', async () => {
122+
vi.mocked(emForkMock.findOne).mockResolvedValue({ id: 'User_demo', tenantId: 'tenant-abc' } as any)
123+
vi.mocked(issuerServiceMock.find).mockResolvedValue([
124+
{
125+
publicIssuerId: FAKE_ISSUER_DID,
126+
credentialsSupported: [
127+
{ id: CONTRIBUTOR_CREDENTIAL_SUPPORTED_ID, format: 'vc+sd-jwt', vct: CONTRIBUTOR_CREDENTIAL_VCT },
128+
],
129+
} as any,
130+
])
131+
132+
await service.onApplicationBootstrap()
133+
134+
expect(issuerServiceMock.createIssuer).not.toHaveBeenCalled()
135+
expect(issuerServiceMock.updateIssuerMetadata).not.toHaveBeenCalled()
136+
})
137+
138+
it('logs a warning and does not throw when demo wallet is not found', async () => {
139+
vi.mocked(emForkMock.findOne).mockResolvedValue(null)
140+
141+
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined()
142+
expect(issuerServiceMock.createIssuer).not.toHaveBeenCalled()
143+
})
144+
145+
it('adds credential config to an existing issuer that does not have it yet', async () => {
146+
vi.mocked(emForkMock.findOne).mockResolvedValue({ id: 'User_demo', tenantId: 'tenant-abc' } as any)
147+
vi.mocked(issuerServiceMock.find).mockResolvedValue([
148+
{
149+
publicIssuerId: FAKE_ISSUER_DID,
150+
credentialConfigurationsSupported: {},
151+
} as any,
152+
])
153+
154+
await service.onApplicationBootstrap()
155+
156+
expect(issuerServiceMock.updateIssuerMetadata).toHaveBeenCalledOnce()
157+
const updateArgs = vi.mocked(issuerServiceMock.updateIssuerMetadata).mock.calls[0][2]
158+
expect(updateArgs.action).toBe('add')
159+
expect(updateArgs.credentialsSupported![0].id).toBe(CONTRIBUTOR_CREDENTIAL_SUPPORTED_ID)
160+
})
161+
})
162+
163+
describe('issueContributorCredential', () => {
164+
beforeEach(() => {
165+
vi.mocked(emMock.findOne).mockResolvedValue(buildBinding())
166+
vi.mocked(emForkMock.findOne).mockResolvedValue({ id: 'User_demo', tenantId: 'tenant-abc' } as any)
167+
})
168+
169+
it('returns a credential offer URI for a verified contributor', async () => {
170+
const result = await service.issueContributorCredential('11111111')
171+
172+
expect(result).toMatch(/^openid-credential-offer:\/\//)
173+
})
174+
175+
it('calls createCredentialOffer with the correct credential configuration ID', async () => {
176+
await service.issueContributorCredential('11111111')
177+
178+
// We verify indirectly via the agentMock's withTenantAgent callback
179+
const withTenantCall = vi.mocked(agentMock.modules.tenants.withTenantAgent)
180+
expect(withTenantCall).toHaveBeenCalled()
181+
})
182+
183+
it('passes the correct payload through issuance metadata', async () => {
184+
await service.issueContributorCredential('11111111')
185+
186+
const withTenantCb = vi.mocked(agentMock.modules.tenants.withTenantAgent).mock.calls[0][1]
187+
188+
const spyTenantAgent = buildFakeTenantAgent()
189+
await withTenantCb(spyTenantAgent)
190+
191+
const offerArgs = vi.mocked(spyTenantAgent.openid4vc.issuer.createCredentialOffer).mock.calls[0][0]
192+
const meta = offerArgs.issuanceMetadata.credentials[0]
193+
194+
expect(meta.format).toBe('vc+sd-jwt')
195+
expect(meta.credentialSupportedId).toBe(CONTRIBUTOR_CREDENTIAL_SUPPORTED_ID)
196+
expect(meta.type).toBe(CONTRIBUTOR_CREDENTIAL_VCT)
197+
expect(meta.payload.githubAccountId).toBe('11111111')
198+
expect(meta.payload.githubUsername).toBe('test-contributor')
199+
expect(meta.payload.gpgFingerprint).toBe('AABBCCDDEEFF00112233445566778899AABBCCDD')
200+
expect(meta.payload.verifiedAt).toBe('2026-07-13T10:00:00.000Z')
201+
expect(meta.payload.walletId).toBe('User_demo')
202+
})
203+
204+
it('sets the correct disclosure frame matching the Week 2 policy', async () => {
205+
await service.issueContributorCredential('11111111')
206+
207+
const withTenantCb = vi.mocked(agentMock.modules.tenants.withTenantAgent).mock.calls[0][1]
208+
const spyTenantAgent = buildFakeTenantAgent()
209+
await withTenantCb(spyTenantAgent)
210+
211+
const offerArgs = vi.mocked(spyTenantAgent.openid4vc.issuer.createCredentialOffer).mock.calls[0][0]
212+
const { disclosureFrame } = offerArgs.issuanceMetadata.credentials[0]
213+
214+
expect(disclosureFrame._sd).toEqual(expect.arrayContaining([...CONTRIBUTOR_CREDENTIAL_SELECTIVE_CLAIMS]))
215+
expect(disclosureFrame._sd).toHaveLength(CONTRIBUTOR_CREDENTIAL_SELECTIVE_CLAIMS.length)
216+
})
217+
218+
it('does not include non-selectively-disclosable claims in the disclosure frame', async () => {
219+
await service.issueContributorCredential('11111111')
220+
221+
const withTenantCb = vi.mocked(agentMock.modules.tenants.withTenantAgent).mock.calls[0][1]
222+
const spyTenantAgent = buildFakeTenantAgent()
223+
await withTenantCb(spyTenantAgent)
224+
225+
const offerArgs = vi.mocked(spyTenantAgent.openid4vc.issuer.createCredentialOffer).mock.calls[0][0]
226+
const { _sd } = offerArgs.issuanceMetadata.credentials[0].disclosureFrame as { _sd: string[] }
227+
228+
expect(_sd).not.toContain('githubAccountId')
229+
expect(_sd).not.toContain('verifiedAt')
230+
expect(_sd).not.toContain('walletId')
231+
})
232+
233+
it('uses the first verification method from the DID document (deterministic selection)', async () => {
234+
await service.issueContributorCredential('11111111')
235+
236+
const withTenantCb = vi.mocked(agentMock.modules.tenants.withTenantAgent).mock.calls[0][1]
237+
const spyTenantAgent = buildFakeTenantAgent()
238+
await withTenantCb(spyTenantAgent)
239+
240+
const offerArgs = vi.mocked(spyTenantAgent.openid4vc.issuer.createCredentialOffer).mock.calls[0][0]
241+
const { issuer } = offerArgs.issuanceMetadata.credentials[0]
242+
243+
expect(issuer.didUrl).toBe(FAKE_VERIFICATION_METHOD)
244+
expect(issuer.did).toBe(FAKE_ISSUER_DID)
245+
})
246+
247+
it('throws NotFoundException when no binding exists', async () => {
248+
vi.mocked(emMock.findOne).mockReset()
249+
vi.mocked(emMock.findOne).mockResolvedValueOnce(null)
250+
251+
await expect(service.issueContributorCredential('99999999')).rejects.toThrow(NotFoundException)
252+
})
253+
254+
it('throws ConflictException when binding exists but GPG verification is incomplete', async () => {
255+
vi.mocked(emMock.findOne).mockReset()
256+
const unverifiedBinding = buildBinding({ gpgFingerprint: undefined, verifiedAt: undefined })
257+
vi.mocked(emMock.findOne).mockResolvedValueOnce(unverifiedBinding)
258+
259+
await expect(service.issueContributorCredential('11111111')).rejects.toThrow(ConflictException)
260+
})
261+
262+
it('throws NotFoundException when demo tenant wallet is not initialised', async () => {
263+
vi.mocked(emMock.findOne).mockReset()
264+
vi.mocked(emMock.findOne).mockResolvedValueOnce(buildBinding())
265+
vi.mocked(emForkMock.findOne).mockReset()
266+
vi.mocked(emForkMock.findOne).mockResolvedValueOnce(null)
267+
268+
await expect(service.issueContributorCredential('11111111')).rejects.toThrow(NotFoundException)
269+
})
270+
})
271+
})

0 commit comments

Comments
 (0)