Skip to content
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
42 changes: 18 additions & 24 deletions website/routeMocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type OrganismsConfig } from './src/config';
import type { CollectionRaw } from './src/covspectrum/types.ts';
import type { LapisInfo } from './src/lapis/getLastUpdatedDate.ts';
import type { ParsedQueryResult } from './src/lapis/parseQuery.ts';
import type { Collection } from './src/types/Collection.ts';
import type { ProblemDetail } from './src/types/ProblemDetail.ts';
import type {
SubscriptionPutRequest,
Expand Down Expand Up @@ -164,17 +165,11 @@ export class LapisRouteMocker {
export class BackendRouteMocker {
constructor(private workerOrServer: MSWWorkerOrServer) {}

mockGetSubscriptions(requestParam: { userId: string }, response: SubscriptionResponse[], statusCode = 200) {
this.workerOrServer.use(
http.get(`${DUMMY_BACKEND_URL}/subscriptions`, resolver([{ statusCode, response, requestParam }])),
);
mockGetSubscriptions(response: SubscriptionResponse[], statusCode = 200) {
this.workerOrServer.use(http.get(`${DUMMY_BACKEND_URL}/subscriptions`, resolver([{ statusCode, response }])));
}

mockGetEvaluateTrigger(
requestParam: { userId: string; id: string },
response: TriggerEvaluationResponse,
statusCode = 200,
) {
mockGetEvaluateTrigger(requestParam: { id: string }, response: TriggerEvaluationResponse, statusCode = 200) {
this.workerOrServer.use(
http.get(
`${DUMMY_BACKEND_URL}/subscriptions/evaluateTrigger`,
Expand All @@ -183,41 +178,31 @@ export class BackendRouteMocker {
);
}

mockPostSubscription(
body: SubscriptionRequest,
requestParam: { userId: string },
response: SubscriptionResponse,
statusCode = 200,
) {
mockPostSubscription(body: SubscriptionRequest, response: SubscriptionResponse, statusCode = 200) {
this.workerOrServer.use(
http.post(`${DUMMY_BACKEND_URL}/subscriptions`, resolver([{ statusCode, body, response, requestParam }])),
http.post(`${DUMMY_BACKEND_URL}/subscriptions`, resolver([{ statusCode, body, response }])),
);
}

mockPutSubscription(
body: SubscriptionPutRequest,
requestParam: { userId: string },
pathVariables: { subscriptionId: string },
response: SubscriptionResponse,
statusCode = 200,
) {
this.workerOrServer.use(
http.put(
`${DUMMY_BACKEND_URL}/subscriptions/${pathVariables.subscriptionId}`,
resolver([{ statusCode, body, response, requestParam }]),
resolver([{ statusCode, body, response }]),
),
);
}

mockDeleteSubscription(
requestParam: { userId: string },
pathVariables: { subscriptionId: string },
statusCode = 204,
) {
mockDeleteSubscription(pathVariables: { subscriptionId: string }, statusCode = 204) {
this.workerOrServer.use(
http.delete(
`${DUMMY_BACKEND_URL}/subscriptions/${pathVariables.subscriptionId}`,
resolver([{ statusCode, requestParam }]),
resolver([{ statusCode }]),
),
);
}
Expand All @@ -229,6 +214,15 @@ export class BackendRouteMocker {
}),
);
}

mockGetCollections(response: Collection[], organism?: string, statusCode = 200) {
this.workerOrServer.use(
http.get(
`${DUMMY_BACKEND_URL}/collections`,
resolver([{ statusCode, response, requestParam: organism !== undefined ? { organism } : undefined }]),
),
);
}
}

function resolver(cases: MockCase[]) {
Expand Down
97 changes: 97 additions & 0 deletions website/src/backendApi/backendProxy.ts
Comment thread
fhennig marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { getSession } from 'auth-astro/server';

import { getBackendHost } from '../config.ts';
import { getInstanceLogger } from '../logger.ts';
import type { ProblemDetail } from '../types/ProblemDetail.ts';
import { getErrorLogMessage } from '../util/getErrorLogMessage.ts';

const logger = getInstanceLogger('BackendProxy');

const API_PATHNAME_LENGTH = '/api'.length;

/**
* Calls the backend. If the user is logged in, the user ID is added from the session.
* This proxying through the frontend server is used, so we do the user login handling
* in here, instead of in the backend.
*/
export async function proxyToBackend({ request }: { request: Request }): Promise<Response> {
const session = await getSession(request);

if (session?.user?.id === undefined) {
return getUnauthorizedResponse(request.url);
}
Comment thread
fengelniederhammer marked this conversation as resolved.

return proxyRequest(request, session.user.id);
}

/**
* Proxies the request to the backend without any user ID, regardless of login state.
*/
export async function proxyToBackendNoAuth({ request }: { request: Request }): Promise<Response> {
return proxyRequest(request, undefined);
}

async function proxyRequest(request: Request, userId: string | undefined): Promise<Response> {
const backendUrl = getBackendUrl(request, userId);

try {
const response = await fetch(backendUrl, request);

return new Response(response.body, {
status: response.status,
headers: response.headers,
});
} catch (error) {
logger.error(getErrorLogMessage(error));
return getInternalErrorResponse(request.url);
}
}

function getBackendUrl(request: Request, userId: string | undefined) {
const backendEndpoint = new URL(request.url).pathname.slice(API_PATHNAME_LENGTH);
const backendUrl = new URL(backendEndpoint, getBackendHost());

new URL(request.url).searchParams.forEach((value, key) => {
backendUrl.searchParams.set(key, value);
});

if (userId !== undefined) {
backendUrl.searchParams.set('userId', userId);
}

return backendUrl;
}

const getUnauthorizedResponse = (requestUrl: string) => {
const response: ProblemDetail = {
title: 'Unauthorized',
detail: "You're not authorized to access this resource",
status: 401,
instance: requestUrl,
};

return Response.json(response, {
status: 401,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/json',
Comment thread
fhennig marked this conversation as resolved.
},
});
};

const getInternalErrorResponse = (requestUrl: string) => {
const response: ProblemDetail = {
title: 'Internal Server Error',
detail: 'Failed to connect the backend service',
status: 500,
instance: requestUrl,
};

return Response.json(response, {
status: 500,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/json',
},
});
};
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { describe, expect, test } from 'vitest';

import { BackendError, BackendService, UnknownBackendError } from './backendService.ts';
import { DUMMY_BACKEND_URL } from '../../../../routeMocker.ts';
import { backendRouteMocker } from '../../../../vitest.setup.ts';
import type {
SubscriptionRequest,
SubscriptionResponse,
TriggerEvaluationResponse,
} from '../../../types/Subscription.ts';
import { DUMMY_BACKEND_URL } from '../../routeMocker.ts';
import { backendRouteMocker } from '../../vitest.setup.ts';
import type { Collection } from '../types/Collection.ts';
import type { SubscriptionRequest, SubscriptionResponse, TriggerEvaluationResponse } from '../types/Subscription.ts';

describe('backendService', () => {
const backendService = new BackendService(DUMMY_BACKEND_URL);

test('should GET subscriptions', async () => {
const userId = '123';

const subscriptions: SubscriptionResponse[] = [
{
id: '1',
Expand Down Expand Up @@ -45,9 +40,9 @@ describe('backendService', () => {
},
];

backendRouteMocker.mockGetSubscriptions({ userId }, subscriptions);
backendRouteMocker.mockGetSubscriptions(subscriptions);

await expect(backendService.getSubscriptions({ userId })).resolves.to.deep.equal(subscriptions);
await expect(backendService.getSubscriptions()).resolves.to.deep.equal(subscriptions);
});

const evaluateTriggerResponses: { description: string; evaluationResult: TriggerEvaluationResponse }[] = [
Expand Down Expand Up @@ -88,19 +83,17 @@ describe('backendService', () => {
test.each(evaluateTriggerResponses)(
'should GET evaluate trigger for type $description',
async ({ evaluationResult }) => {
const userId = '123';
const subscriptionId = '1';

backendRouteMocker.mockGetEvaluateTrigger({ userId, id: subscriptionId }, evaluationResult);
backendRouteMocker.mockGetEvaluateTrigger({ id: subscriptionId }, evaluationResult);

await expect(backendService.getEvaluateTrigger({ subscriptionId, userId })).resolves.to.deep.equal(
await expect(backendService.getEvaluateTrigger({ subscriptionId })).resolves.to.deep.equal(
evaluationResult,
);
},
);

test('should POST subscription', async () => {
const userId = '123';
const subscription: SubscriptionRequest = {
name: 'Subscription 1',
active: true,
Expand All @@ -119,12 +112,11 @@ describe('backendService', () => {
...subscription,
};

backendRouteMocker.mockPostSubscription(subscription, { userId }, response);
await expect(backendService.postSubscription({ subscription, userId })).resolves.to.deep.equal(response);
backendRouteMocker.mockPostSubscription(subscription, response);
await expect(backendService.postSubscription({ subscription })).resolves.to.deep.equal(response);
});

test('should PUT subscription', async () => {
const userId = '123';
const subscriptionId = '1';
const subscription = {
name: 'Subscription 1',
Expand All @@ -144,30 +136,28 @@ describe('backendService', () => {
...subscription,
};

backendRouteMocker.mockPutSubscription(subscription, { userId }, { subscriptionId }, response);
backendRouteMocker.mockPutSubscription(subscription, { subscriptionId }, response);
await expect(
backendService.putSubscription({
subscription,
userId,
subscriptionId,
}),
).resolves.to.deep.equal(response);
});

test('should DELETE subscription', async () => {
const userId = '123';
const subscriptionId = '1';

backendRouteMocker.mockDeleteSubscription({ userId }, { subscriptionId });
await expect(backendService.deleteSubscription({ subscriptionId, userId })).resolves.to.deep.equal('');
backendRouteMocker.mockDeleteSubscription({ subscriptionId });
await expect(backendService.deleteSubscription({ subscriptionId })).resolves.to.deep.equal('');
});

test('should pass backend error response from GET subscriptions', async () => {
const errorResponse = { detail: 'Some error detail' };

backendRouteMocker.mockGetSubscriptionsBackendError(errorResponse, 400);

await expect(backendService.getSubscriptions({ userId: '123' })).rejects.to.deep.equal(
await expect(backendService.getSubscriptions()).rejects.to.deep.equal(
new BackendError('Some error detail', 400, errorResponse, '/subscriptions', undefined),
);
});
Expand All @@ -177,8 +167,29 @@ describe('backendService', () => {

backendRouteMocker.mockGetSubscriptionsBackendError(errorResponse, 400);

await expect(backendService.getSubscriptions({ userId: '123' })).rejects.to.deep.equal(
await expect(backendService.getSubscriptions()).rejects.to.deep.equal(
new UnknownBackendError('Bad Request', 400, '/subscriptions'),
);
});

const aCollection: Collection = {
id: 1,
name: 'Test collection',
ownedBy: 'user123',
organism: 'covid',
description: 'A test collection',
variants: [],
};

test('should GET collections without organism filter', async () => {
backendRouteMocker.mockGetCollections([aCollection]);

await expect(backendService.getCollections()).resolves.to.deep.equal([aCollection]);
});

test('should GET collections filtered by organism', async () => {
backendRouteMocker.mockGetCollections([aCollection], 'covid');

await expect(backendService.getCollections({ organism: 'covid' })).resolves.to.deep.equal([aCollection]);
});
});
Loading
Loading