Skip to content

Commit

Permalink
feat(API): Implement Member Id Bundle endpoint (#224)
Browse files Browse the repository at this point in the history
  • Loading branch information
gnarea authored Sep 5, 2023
1 parent 12e4ab4 commit fe5978b
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 34 deletions.
2 changes: 1 addition & 1 deletion docs/awala.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ Your app MUST process incoming member bundle messages, which is a JSON document
- `memberPublicKeyId`: The id of the public key that your app MUST use when requesting member bundles in the future. This is unique for every public key and never changes, so you only need to store it the first time you receive this message.
- `memberBundle`: The base64-encoded member bundle.

This service message uses the content type `application/vnd.veraid-authority.member-bundle`.
This service message uses the content type `application/vnd.veraid.member-bundle`.
70 changes: 70 additions & 0 deletions src/api/routes/memberPublicKey.routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import type { MemberPublicKeySchema } from '../../schemas/memberPublicKey.schema
import { generateKeyPair } from '../../testUtils/webcrypto.js';
import { derSerialisePublicKey } from '../../utilities/webcrypto.js';
import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js';
import { bufferToArrayBuffer } from '../../utilities/buffer.js';
import { VeraidContentType } from '../../utilities/veraid.js';
import type { BundleCreationFailure } from '../../memberBundle.js';

const mockCreateMemberPublicKey = mockSpy(
jest.fn<() => Promise<Result<MemberPublicKeyCreationResult, MemberPublicKeyProblemType>>>(),
Expand All @@ -32,6 +35,16 @@ jest.unstable_mockModule('../../memberPublicKey.js', () => ({
getMemberPublicKey: mockGetMemberPublicKey,
deleteMemberPublicKey: mockDeleteMemberPublicKey,
}));

const CERTIFICATE_EXPIRY_DAYS = 90;
const mockGenerateMemberBundle = mockSpy(
jest.fn<() => Promise<Result<ArrayBuffer, BundleCreationFailure>>>(),
);
jest.unstable_mockModule('../../memberBundle.js', () => ({
generateMemberBundle: mockGenerateMemberBundle,
CERTIFICATE_EXPIRY_DAYS,
}));

const { makeTestApiServer, testOrgRouteAuth } = await import('../../testUtils/apiServer.js');
const { publicKey } = await generateKeyPair();
const publicKeyBuffer = await derSerialisePublicKey(publicKey);
Expand Down Expand Up @@ -82,6 +95,7 @@ describe('member public keys routes', () => {
expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.OK);
expect(response.json()).toStrictEqual({
self: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}/public-keys/${PUBLIC_KEY_ID}`,
bundle: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}/public-keys/${PUBLIC_KEY_ID}/bundle`,
});
});

Expand Down Expand Up @@ -180,4 +194,60 @@ describe('member public keys routes', () => {
);
});
});

describe('bundle', () => {
const injectOptions: InjectOptions = {
method: 'GET',
url: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}/public-keys/${PUBLIC_KEY_ID}/bundle`,
};

const bundleSerialised = Buffer.from('bundle');
beforeEach(() => {
mockGenerateMemberBundle.mockResolvedValue({
didSucceed: true,
result: bufferToArrayBuffer(bundleSerialised),
});
});

test('Bundle should be generated for the specified public key', async () => {
await serverInstance.inject(injectOptions);

expect(mockGenerateMemberBundle).toHaveBeenCalledWith(PUBLIC_KEY_ID, expect.anything());
});

test('Response body should be generated bundle', async () => {
const response = await serverInstance.inject(injectOptions);

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.OK);
expect(response.rawPayload).toMatchObject(bundleSerialised);
});

test('Content type should be that of a bundle', async () => {
const response = await serverInstance.inject(injectOptions);

expect(response.headers).toHaveProperty('content-type', VeraidContentType.MEMBER_BUNDLE);
});

test('HTTP Not Found should be returned if DB records do not exist', async () => {
mockGenerateMemberBundle.mockResolvedValue({
didSucceed: false,
context: { chainRetrievalFailed: false },
});

const response = await serverInstance.inject(injectOptions);

expect(response.statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND);
});

test('HTTP Service Unavailable should be returned if bundle generation fails', async () => {
mockGenerateMemberBundle.mockResolvedValue({
didSucceed: false,
context: { chainRetrievalFailed: true },
});

const response = await serverInstance.inject(injectOptions);

expect(response.statusCode).toBe(HTTP_STATUS_CODES.SERVICE_UNAVAILABLE);
});
});
});
36 changes: 34 additions & 2 deletions src/api/routes/memberPublicKey.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RouteOptions } from 'fastify';
import type { FastifyReply, RouteOptions } from 'fastify';

import { HTTP_STATUS_CODES } from '../../utilities/http.js';
import type { PluginDone } from '../../utilities/fastify/PluginDone.js';
Expand All @@ -10,6 +10,8 @@ import {
} from '../../memberPublicKey.js';
import { MEMBER_PUBLIC_KEY_SCHEMA } from '../../schemas/memberPublicKey.schema.js';
import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js';
import { generateMemberBundle } from '../../memberBundle.js';
import { VeraidContentType } from '../../utilities/veraid.js';

const RESPONSE_CODE_BY_PROBLEM: {
[key in MemberPublicKeyProblemType]: (typeof HTTP_STATUS_CODES)[keyof typeof HTTP_STATUS_CODES];
Expand Down Expand Up @@ -44,15 +46,18 @@ const MEMBER_PUBLIC_KEY_PARAMS = {

interface MemberPublicKeyUrls {
self: string;
bundle: string;
}

function makeUrls(
orgName: string,
memberId: string,
memberPublicKeyId: string,
): MemberPublicKeyUrls {
const self = `/orgs/${orgName}/members/${memberId}/public-keys/${memberPublicKeyId}`;
return {
self: `/orgs/${orgName}/members/${memberId}/public-keys/${memberPublicKeyId}`,
self,
bundle: `${self}/bundle`,
};
}
export default function registerRoutes(
Expand Down Expand Up @@ -115,5 +120,32 @@ export default function registerRoutes(
},
});

fastify.route({
method: ['GET'],
url: '/:memberPublicKeyId/bundle',

schema: {
params: MEMBER_PUBLIC_KEY_PARAMS,
},

async handler(request, reply): Promise<FastifyReply> {
const result = await generateMemberBundle(request.params.memberPublicKeyId, {
logger: request.log,
dbConnection: this.mongoose,
});
if (result.didSucceed) {
return reply
.code(HTTP_STATUS_CODES.OK)
.header('Content-Type', VeraidContentType.MEMBER_BUNDLE)
.send(Buffer.from(result.result));
}

if (result.context.chainRetrievalFailed) {
return reply.code(HTTP_STATUS_CODES.SERVICE_UNAVAILABLE).send();
}
return reply.code(HTTP_STATUS_CODES.NOT_FOUND).send();
},
});

done();
}
17 changes: 4 additions & 13 deletions src/backgroundQueue/sinks/memberBundleRequest.sink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,12 @@ import {
} from '../../events/outgoingServiceMessage.event.js';
import { VeraidContentType } from '../../utilities/veraid.js';
import { EmitterChannel } from '../../utilities/eventing/EmitterChannel.js';
import type { BundleCreationFailure } from '../../memberBundle.js';

const CERTIFICATE_EXPIRY_DAYS = 90;
const mockGenerateMemberBundle = mockSpy(
jest.fn<
() => Promise<
Result<
ArrayBuffer,
{
shouldRetry: boolean;
}
>
>
>(),
jest.fn<() => Promise<Result<ArrayBuffer, BundleCreationFailure>>>(),
);

jest.unstable_mockModule('../../memberBundle.js', () => ({
generateMemberBundle: mockGenerateMemberBundle,
CERTIFICATE_EXPIRY_DAYS,
Expand Down Expand Up @@ -264,7 +255,7 @@ describe('memberBundleIssuance', () => {
didSucceed: false,

context: {
shouldRetry: true,
chainRetrievalFailed: true,
},
});
});
Expand Down Expand Up @@ -299,7 +290,7 @@ describe('memberBundleIssuance', () => {
didSucceed: false,

context: {
shouldRetry: false,
chainRetrievalFailed: false,
},
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/backgroundQueue/sinks/memberBundleRequest.sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default async function memberBundleIssuance(
}

const memberBundle = await generateMemberBundle(publicKeyId, options);
if (!memberBundle.didSucceed && memberBundle.context.shouldRetry) {
if (!memberBundle.didSucceed && memberBundle.context.chainRetrievalFailed) {
return;
}

Expand Down
8 changes: 4 additions & 4 deletions src/memberBundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ describe('memberBundle', () => {
const result = await generateMemberBundle(MEMBER_PUBLIC_KEY_MONGO_ID, serviceOptions);

requireFailureResult(result);
expect(result.context.shouldRetry).not.toBeTrue();
expect(result.context.chainRetrievalFailed).not.toBeTrue();
expect(mockLogging.logs).toContainEqual(
partialPinoLog('info', 'Member public key not found', {
memberPublicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID,
Expand All @@ -414,7 +414,7 @@ describe('memberBundle', () => {
const result = await generateMemberBundle(memberPublicKey._id.toString(), serviceOptions);

requireFailureResult(result);
expect(result.context.shouldRetry).not.toBeTrue();
expect(result.context.chainRetrievalFailed).not.toBeTrue();
expect(mockLogging.logs).toContainEqual(
partialPinoLog('info', 'Member not found', { memberId: MEMBER_MONGO_ID }),
);
Expand All @@ -434,7 +434,7 @@ describe('memberBundle', () => {
const result = await generateMemberBundle(memberPublicKey._id.toString(), serviceOptions);

requireFailureResult(result);
expect(result.context.shouldRetry).not.toBeTrue();
expect(result.context.chainRetrievalFailed).not.toBeTrue();
expect(mockLogging.logs).toContainEqual(
partialPinoLog('info', 'Org not found', { orgName: ORG_NAME }),
);
Expand Down Expand Up @@ -468,7 +468,7 @@ describe('memberBundle', () => {
const result = await generateMemberBundle(memberPublicKeyId, serviceOptions);

requireFailureResult(result);
expect(result.context.shouldRetry).toBeTrue();
expect(result.context.chainRetrievalFailed).toBeTrue();
expect(mockLogging.logs).toContainEqual(
partialPinoLog('warn', 'Failed to retrieve DNSSEC chain', {
memberPublicKeyId,
Expand Down
21 changes: 9 additions & 12 deletions src/memberBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ async function generateBundle(

export const CERTIFICATE_EXPIRY_DAYS = 90;

export interface BundleCreationFailure {
chainRetrievalFailed: boolean;
}

export async function createMemberBundleRequest(
requestData: MemberBundleRequest,
options: ServiceOptions,
Expand Down Expand Up @@ -131,14 +135,7 @@ export async function createMemberBundleRequest(
export async function generateMemberBundle(
publicKeyId: string,
options: ServiceOptions,
): Promise<
Result<
ArrayBuffer,
{
shouldRetry: boolean;
}
>
> {
): Promise<Result<ArrayBuffer, BundleCreationFailure>> {
const memberPublicKeyModel = getModelForClass(MemberPublicKeyModelSchema, {
existingConnection: options.dbConnection,
});
Expand All @@ -163,7 +160,7 @@ export async function generateMemberBundle(
didSucceed: false,

context: {
shouldRetry: false,
chainRetrievalFailed: false,
},
};
}
Expand All @@ -179,7 +176,7 @@ export async function generateMemberBundle(
didSucceed: false,

context: {
shouldRetry: false,
chainRetrievalFailed: false,
},
};
}
Expand All @@ -196,7 +193,7 @@ export async function generateMemberBundle(
didSucceed: false,

context: {
shouldRetry: false,
chainRetrievalFailed: false,
},
};
}
Expand All @@ -219,7 +216,7 @@ export async function generateMemberBundle(
didSucceed: false,

context: {
shouldRetry: true,
chainRetrievalFailed: true,
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/utilities/veraid.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export enum VeraidContentType {
MEMBER_BUNDLE = 'application/vnd.veraid-authority.member-bundle',
MEMBER_BUNDLE = 'application/vnd.veraid.member-bundle',
}

0 comments on commit fe5978b

Please sign in to comment.