Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(api): Coverage for send/s3 module #4055

Merged
merged 4 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 281 additions & 71 deletions api.planx.uk/modules/send/s3/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,47 @@
import supertest from "supertest";
import type planxCore from "@opensystemslab/planx-core";
import app from "../../../server.js";
import { expectedPlanningPermissionPayload } from "../../../tests/mocks/digitalPlanningDataMocks.js";
import { queryMock } from "../../../tests/graphqlQueryMock.js";
import { $api } from "../../../client/index.js";
import { mockLowcalSession } from "../../../tests/mocks/saveAndReturnMocks.js";
import axios, { type AxiosRequestConfig } from "axios";
import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils.js";

const sessionId = "3188f052-a032-4755-be63-72b0ba497eb6";
const mockPowerAutomateWebhookURL = "http://www.example.com";
const mockPowerAutomateAPIKey = "my-power-automate-api-key";

vi.mock("../../saveAndReturn/service/utils", () => ({
markSessionAsSubmitted: vi.fn(),
}));

vi.mock("@opensystemslab/planx-core", async (importOriginal) => {
const actualCore = await importOriginal<typeof planxCore>();
const actualCoreDomainClient = actualCore.CoreDomainClient;

return {
CoreDomainClient: class extends actualCoreDomainClient {
constructor() {
super();
this.export.digitalPlanningDataPayload = async () =>
vi.fn().mockResolvedValue({
exportData: expectedPlanningPermissionPayload,
});
}
},
};
});
vi.mock("../../file/service/uploadFile.js", () => ({
uploadPrivateFile: vi
.fn()
.mockResolvedValue({ fileUrl: "https://my-file-url.com" }),
}));

vi.mock("../../client/index.js");

vi.mock("axios", () => ({
default: vi.fn(),
}));
const mockedAxios = vi.mocked(axios, true);

describe(`uploading an application to S3`, () => {
describe("uploading an application to S3", () => {
beforeEach(() => {
queryMock.mockQuery({
name: "GetStagingIntegrations",
data: {
teams: [
{
integrations: {
powerAutomateWebhookURL: "test.azure.com/whatever",
powerAutomateAPIKey: "secret",
},
},
],
},
variables: {
slug: "barnet",
},
$api.team.getIntegrations = vi.fn().mockResolvedValue({
powerAutomateWebhookURL: mockPowerAutomateWebhookURL,
powerAutomateAPIKey: mockPowerAutomateAPIKey,
});

queryMock.mockQuery({
name: "GetStagingIntegrations",
data: {
teams: [],
},
variables: {
slug: "unsupported-team",
},
});
$api.session.find = vi.fn().mockResolvedValue(mockLowcalSession);

$api.export.digitalPlanningDataPayload = vi
.fn()
.mockResolvedValue(expectedPlanningPermissionPayload);

mockedAxios.mockResolvedValue({ data: { success: true }, status: 200 });

queryMock.mockQuery({
name: "CreateS3Application",
Expand All @@ -63,39 +52,260 @@ describe(`uploading an application to S3`, () => {
});
});

it("requires auth", async () => {
await supertest(app)
.post("/upload-submission/barnet")
.send({ payload: { sessionId: "123" } })
.expect(401);
});
describe("request validation", () => {
it("requires auth", async () => {
await supertest(app)
.post("/upload-submission/barnet")
.send({ payload: { sessionId: "123" } })
.expect(401);
});

it("throws an error if payload is missing", async () => {
await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({ payload: null })
.expect(400)
.then((res) => {
expect(res.body).toHaveProperty("issues");
expect(res.body).toHaveProperty("name", "ZodError");
it("throws an error if payload is missing", async () => {
await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({ payload: null })
.expect(400)
.then((res) => {
expect(res.body).toHaveProperty("issues");
expect(res.body).toHaveProperty("name", "ZodError");
});
});

it("throws an error if powerAutomateWebhookURL is not set", async () => {
$api.team.getIntegrations = vi.fn().mockResolvedValueOnce({
powerAutomateWebhookURL: null,
powerAutomateAPIKey: "some-key",
});
});

it("throws an error if team is unsupported", async () => {
await supertest(app)
.post("/upload-submission/unsupported-team")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId: "3188f052-a032-4755-be63-72b0ba497eb6" },
})
.expect(500)
.then((res) => {
expect(res.body.error).toMatch(
/No team matching "unsupported-team" found/,
);
await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
})
.expect(400)
.then((res) => {
expect(res.body.error).toMatch(
/Upload to S3 is not enabled for this local authority/,
);
});
});

it("throws an error if powerAutomateAPIKey is not set", async () => {
$api.team.getIntegrations = vi.fn().mockResolvedValueOnce({
powerAutomateWebhookURL: "https://www.example.com",
powerAutomateAPIKey: null,
});

await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
})
.expect(400)
.then((res) => {
expect(res.body.error).toMatch(
/Upload to S3 is not enabled for this local authority/,
);
});
});
});

describe("payload validation", () => {
it("validates statutory payloads", async () => {
await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
});

// Verify mock session is a statutory application
expect(
mockLowcalSession.data.passport.data?.["application.type"]?.[0],
).toBe("ldc.proposed");

expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledTimes(1);
expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledWith(
sessionId,
false, // Validation not skipped
);

// Validation status passed to webhook
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
payload: "Validated ODP Schema",
}),
}),
);
});

it("does not validate discretionary payloads", async () => {
// Set up mock discretionary payload
const mockDiscretionarySession = structuredClone(mockLowcalSession);
mockDiscretionarySession.data.passport.data["application.type"][0] =
"reportAPlanningBreach";
$api.session.find = vi
.fn()
.mockResolvedValueOnce(mockDiscretionarySession);

await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
});

expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledTimes(1);
expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledWith(
sessionId,
true, // Validation skipped
);

// Validation status passed to webhook
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
payload: "Discretionary",
}),
}),
);
});
});

it.todo("succeeds"); // mock uploadPrivateFile ??
describe("error handling", () => {
it("throws an error if the webhook request fails", async () => {
mockedAxios.mockRejectedValueOnce(
new Error(
"Something went wrong with the webhook request to Power Automate",
),
);

await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
})
.expect(500)
.then((res) => {
expect(res.body.error).toMatch(
/Failed to send submission notification/,
);
});
});

it("throws an error if an internal process fails", async () => {
$api.session.find = vi
.fn()
.mockRejectedValueOnce(new Error("Something went wrong!"));

await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
})
.expect(500)
.then((res) => {
expect(res.body.error).toMatch(/Failed to upload submission to S3/);
});
});
});

describe("success", () => {
beforeEach(() => {
vi.clearAllMocks();
});

const callAPI = async () =>
await supertest(app)
.post("/upload-submission/barnet")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY! })
.send({
payload: { sessionId },
});

it("makes a request to the configured Power Automate webhook", async () => {
await callAPI();

expect(mockedAxios).toHaveBeenCalledOnce();
const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig;
expect(request.url).toEqual(mockPowerAutomateWebhookURL);
});

it("sets Power Automate API key in request header", async () => {
await callAPI();

expect(mockedAxios).toHaveBeenCalledOnce();
const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig;
expect(request.headers).toHaveProperty("apiKey", mockPowerAutomateAPIKey);
});

it("generates the expected body for the Power Automate webhook", async () => {
await callAPI();

expect(mockedAxios).toHaveBeenCalledOnce();
const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig;
expect(request.data).toEqual({
message: "New submission from PlanX",
environment: "staging",
file: "https://my-file-url.com",
payload: "Validated ODP Schema",
service: "Apply for a Lawful Development Certificate",
});
});

it("passes along the correct application environment details", async () => {
vi.stubEnv("APP_ENVIRONMENT", "production");

await callAPI();

expect(mockedAxios).toHaveBeenCalledOnce();
const request = mockedAxios.mock.calls[0][0] as AxiosRequestConfig;
expect(request.data.environment).toEqual("production");
});

it("marks a session as submitted", async () => {
await callAPI();

expect(markSessionAsSubmitted).toHaveBeenCalledOnce();
expect(markSessionAsSubmitted).toHaveBeenCalledWith(sessionId);
});

it("writes an audit record the the s3_applications table", async () => {
await callAPI();

const graphQLCalls = queryMock.getCalls();

expect(graphQLCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "CreateS3Application",
}),
]),
);
});

it("returns a success message upon completion", async () => {
const res = await callAPI();

expect($api.export.digitalPlanningDataPayload).toHaveBeenCalledWith(
sessionId,
false,
);

expect(res.status).toBe(200);
expect(res.body).toEqual({
message:
"Successfully uploaded submission to S3: https://my-file-url.com",
payload: "Validated ODP Schema",
webhookResponse: 200,
auditEntryId: 1,
});
});
});
});
Loading
Loading