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

#1830 - Institution Student Authorization Part 2 #1915

Merged
merged 16 commits into from
May 3, 2023
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ jobs:
API_PORT: 3000
QUEUE_CONSUMERS_PORT: 3001
DB_SCHEMA: sims
DISABLE_ORM_CACHE: "true"
KEYCLOAK_REALM: "aestsims"
KEYCLOAK_AUTH_URL: "https://dev.loginproxy.gov.bc.ca/auth/"
BCeID_WEB_SERVICE_WSDL: "https://gws1.test.bceid.ca/webservices/Client/V10/BCeIDService.asmx?wsdl"
@@ -75,6 +76,7 @@ jobs:
REDIS_STANDALONE_MODE: "true"
QUEUE_PREFIX: "{sims-local}"
DB_SCHEMA: sims
DISABLE_ORM_CACHE: "true"
steps:
# Checkout the PR branch.
- name: Checkout Target Branch
@@ -129,6 +131,7 @@ jobs:
QUEUE_PREFIX: "{sims-local}"
QUEUE_CONSUMERS_PORT: 3001
DB_SCHEMA: sims
DISABLE_ORM_CACHE: "true"
ESDC_REQUEST_FOLDER: MSFT-Request
ESDC_RESPONSE_FOLDER: MSFT-Response
ESDC_ENVIRONMENT_CODE: D
1 change: 1 addition & 0 deletions configs/env-example
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_PORT=5432
DB_SCHEMA=sims
DISABLE_ORM_CACHE=

# KeyCloak
KEYCLOAK_AUTH_URL=
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ import {
InstitutionLocation,
} from "@sims/sims-db";
import { FieldSortOrder } from "@sims/utilities";
import { saveStudentApplicationForCollegeC } from "./student.institutions.utils";

describe("StudentInstitutionsController(e2e)-getStudentApplicationSummary", () => {
let app: INestApplication;
@@ -265,6 +266,58 @@ describe("StudentInstitutionsController(e2e)-getStudentApplicationSummary", () =
},
);

it("Should throw forbidden error when the institution type is not BC Public.", async () => {
// Arrange
// Student submitting an application to College C.
const { student, collegeCApplication } =
await saveStudentApplicationForCollegeC(appDataSource);

await authorizeUserTokenForLocation(
appDataSource,
InstitutionTokenTypes.CollegeCUser,
collegeCApplication.location,
);

// College C is not a BC Public institution.
const collegeCInstitutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeCUser,
);

const endpoint = `/institutions/student/${student.id}/application-summary?page=0&pageLimit=10&sortField=applicationNumber&sortOrder=${FieldSortOrder.DESC}`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(collegeCInstitutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

it("Should throw forbidden error when student does not have at least one application submitted for the institution.", async () => {
// Arrange
const student = await saveFakeStudent(appDataSource);

// College F is a BC Public institution.
const institutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeFUser,
);

const endpoint = `/institutions/student/${student.id}/application-summary?page=0&pageLimit=10&sortField=applicationNumber&sortOrder=${FieldSortOrder.DESC}`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(institutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

afterAll(async () => {
await app?.close();
});
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ import {
Institution,
InstitutionLocation,
} from "@sims/sims-db";
import { saveStudentApplicationForCollegeC } from "./student.institutions.utils";

describe("StudentInstitutionsController(e2e)-getStudentFileUploads", () => {
let app: INestApplication;
@@ -123,6 +124,68 @@ describe("StudentInstitutionsController(e2e)-getStudentFileUploads", () => {
.expect([]);
});

it("Should throw forbidden error when the institution type is not BC Public.", async () => {
// Arrange
// Student submitting an application to College C.
const { student, collegeCApplication } =
await saveStudentApplicationForCollegeC(appDataSource);

// Save fake file upload for the student.
await saveFakeStudentFileUpload(
appDataSource,
{
student,
creator: student.user,
},
{ fileOrigin: FileOriginType.Temporary },
);

await authorizeUserTokenForLocation(
appDataSource,
InstitutionTokenTypes.CollegeCUser,
collegeCApplication.location,
);

// College C is not a BC Public institution.
const collegeCInstitutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeCUser,
);

const endpoint = `/institutions/student/${student.id}/documents`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(collegeCInstitutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

it("Should throw forbidden error when student does not have at least one application submitted for the institution.", async () => {
// Arrange
const student = await saveFakeStudent(appDataSource);

// College F is a BC Public institution.
const institutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeFUser,
);

const endpoint = `/institutions/student/${student.id}/documents`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(institutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

afterAll(async () => {
await app?.close();
});
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import {
import { Application, Institution, InstitutionLocation } from "@sims/sims-db";
import { determinePDStatus, getUserFullName } from "../../../../utilities";
import { getISODateOnlyString } from "@sims/utilities";
import { saveStudentApplicationForCollegeC } from "./student.institutions.utils";

describe("StudentInstitutionsController(e2e)-getStudentProfile", () => {
let app: INestApplication;
@@ -89,6 +90,88 @@ describe("StudentInstitutionsController(e2e)-getStudentProfile", () => {
});
});

it("Should throw forbidden error when the institution type is not BC Public.", async () => {
// Arrange

const { student, collegeCApplication } =
await saveStudentApplicationForCollegeC(appDataSource);

await authorizeUserTokenForLocation(
appDataSource,
InstitutionTokenTypes.CollegeCUser,
collegeCApplication.location,
);

// College C is not a BC Public institution.
const collegeCInstitutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeCUser,
);

const endpoint = `/institutions/student/${student.id}`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(collegeCInstitutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

it("Should throw forbidden error when the institution type is not BC Public.", async () => {
// Arrange
// Student submitting an application to College C.
const { student, collegeCApplication } =
await saveStudentApplicationForCollegeC(appDataSource);

await authorizeUserTokenForLocation(
appDataSource,
InstitutionTokenTypes.CollegeCUser,
collegeCApplication.location,
);

// College C is not a BC Public institution.
const collegeCInstitutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeCUser,
);

const endpoint = `/institutions/student/${student.id}`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(collegeCInstitutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

it("Should throw forbidden error when student does not have at least one application submitted for the institution.", async () => {
// Arrange
const student = await saveFakeStudent(appDataSource);

// College F is a BC Public institution.
const institutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeFUser,
);

const endpoint = `/institutions/student/${student.id}`;

// Act/Assert
await request(app.getHttpServer())
.get(endpoint)
.auth(institutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

afterAll(async () => {
await app?.close();
});
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ import {
InstitutionLocation,
Student,
} from "@sims/sims-db";
import { saveStudentApplicationForCollegeC } from "./student.institutions.utils";

describe("StudentInstitutionsController(e2e)-searchStudents", () => {
let app: INestApplication;
@@ -393,6 +394,44 @@ describe("StudentInstitutionsController(e2e)-searchStudents", () => {
};
};

it("Should throw forbidden error when the institution type is not BC Public.", async () => {
// Arrange

// Student submitting an application to College C.
const { collegeCApplication } = await saveStudentApplicationForCollegeC(
appDataSource,
);

await authorizeUserTokenForLocation(
appDataSource,
InstitutionTokenTypes.CollegeCUser,
collegeCApplication.location,
);

const searchPayload = {
appNumber: collegeCApplication.applicationNumber,
firstName: "",
lastName: "",
sin: "",
};

// College C is not a BC Public institution.
const collegeCInstitutionUserToken = await getInstitutionToken(
InstitutionTokenTypes.CollegeCUser,
);

// Act/Assert
await request(app.getHttpServer())
.post(endpoint)
.send(searchPayload)
.auth(collegeCInstitutionUserToken, BEARER_AUTH_TYPE)
.expect({
statusCode: HttpStatus.FORBIDDEN,
message: "Forbidden resource",
error: "Forbidden",
});
});

afterAll(async () => {
await app?.close();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
createFakeInstitutionLocation,
saveFakeApplication,
saveFakeStudent,
} from "@sims/test-utils";
import {
InstitutionTokenTypes,
getAuthRelatedEntities,
} from "../../../../testHelpers";
import { DataSource } from "typeorm";

/**
* Save student application for College C
* with a College C location.
* @param dataSource
* @returns student and application details with application location.
*/
export async function saveStudentApplicationForCollegeC(
dataSource: DataSource,
) {
const student = await saveFakeStudent(dataSource);
const { institution } = await getAuthRelatedEntities(
dataSource,
InstitutionTokenTypes.CollegeCUser,
);
const collegeCLocation = createFakeInstitutionLocation(institution);
const application = await saveFakeApplication(dataSource, {
student,
institutionLocation: collegeCLocation,
});
return {
student,
collegeCApplication: application,
};
}
Original file line number Diff line number Diff line change
@@ -73,7 +73,6 @@ export class StudentInstitutionsController extends BaseController {

/**
* Get the student information that represents the profile.
* TODO: Authorization must be enabled to validate if the student has submitted
* at least one application for the institution of the user.
* @param studentId student.
* @returns student profile details.
@@ -89,11 +88,11 @@ export class StudentInstitutionsController extends BaseController {

/**
* Get the list of applications that belongs to a student for the institution.
* TODO: Authorization must be enabled to validate if the student has submitted
* at least one application for the institution of the user.
* @param studentId student.
* @returns list of applications that belongs to a student for the institution.
*/
@HasStudentDataAccess("studentId")
@Get(":studentId/application-summary")
@ApiNotFoundResponse({ description: "Student not found." })
async getStudentApplicationSummary(
Original file line number Diff line number Diff line change
@@ -717,6 +717,7 @@ export class InstitutionService extends RecordDataModelService<Institution> {
where: {
id: institutionId,
},
cache: true,
});
}

@@ -966,12 +967,15 @@ export class InstitutionService extends RecordDataModelService<Institution> {
institutionId: number,
studentId: number,
): Promise<boolean> {
return this.applicationRepo.exist({
const institutionStudentDataAccess = await this.applicationRepo.findOne({
select: { id: true },
where: {
student: { id: studentId },
location: { institution: { id: institutionId } },
applicationStatus: Not(ApplicationStatus.Overwritten),
},
cache: true,
});
return !!institutionStudentDataAccess;
}
}
51 changes: 51 additions & 0 deletions sources/packages/backend/libs/sims-db/src/data-source.ts
Original file line number Diff line number Diff line change
@@ -52,6 +52,22 @@ import {
DisbursementOveraward,
QueueConfiguration,
} from "./entities";
import { ClusterNode, ClusterOptions, RedisOptions } from "ioredis";
import { ORM_CACHE_LIFETIME } from "@sims/utilities";
import { ConfigService } from "@sims/utilities/config";

interface ORMCacheConfig {
type: "database" | "redis" | "ioredis/cluster";
options?:
| RedisOptions
| {
startupNodes: ClusterNode[];
options?: ClusterOptions;
};
tableName?: string;
duration?: number;
ignoreErrors?: boolean;
}

export const ormConfig: PostgresConnectionOptions = {
type: "postgres",
@@ -61,9 +77,44 @@ export const ormConfig: PostgresConnectionOptions = {
username: process.env.POSTGRES_USER || "admin",
password: process.env.POSTGRES_PASSWORD,
schema: process.env.DB_SCHEMA || "sims",
cache: getORMCacheConfig(),
synchronize: false,
};

function getORMCacheConfig(): ORMCacheConfig | false {
const config = new ConfigService();

if (config.database.isORMCacheDisabled) {
return false;
}

if (config.redis.redisStandaloneMode) {
return {
type: "redis",
options: {
host: config.redis.redisHost,
port: config.redis.redisPort,
password: config.redis.redisPassword,
},
duration: ORM_CACHE_LIFETIME,
};
}
return {
type: "ioredis/cluster",
options: {
startupNodes: [
{ host: config.redis.redisHost, port: config.redis.redisPort },
],
options: {
redisOptions: {
password: config.redis.redisPassword,
},
},
},
duration: ORM_CACHE_LIFETIME,
};
}

export const simsDataSource = new DataSource({
...ormConfig,
logging: ["error", "warn", "info"],
Original file line number Diff line number Diff line change
@@ -101,6 +101,7 @@ export interface InstitutionIntegrationConfig {

export interface DatabaseConfiguration {
databaseName: string;
isORMCacheDisabled: boolean;
}

export interface RedisConfiguration {
Original file line number Diff line number Diff line change
@@ -192,6 +192,7 @@ export class ConfigService {
get database(): DatabaseConfiguration {
return this.getCachedConfig("databaseConfiguration", {
databaseName: process.env.POSTGRES_DB,
isORMCacheDisabled: process.env.DISABLE_ORM_CACHE === "true",
});
}

Original file line number Diff line number Diff line change
@@ -19,3 +19,8 @@ export const SERVICE_ACCOUNT_DEFAULT_USER_EMAIL = "dev_sabc@gov.bc.ca";
* Minimum value amount to generate an overaward for a federal loan.
*/
export const MIN_CANADA_LOAN_OVERAWARD = 250;

/**
* Lifetime of ORM cache in milliseconds.
*/
export const ORM_CACHE_LIFETIME = 10 * 60 * 1000;
133 changes: 133 additions & 0 deletions sources/packages/backend/package-lock.json
1 change: 1 addition & 0 deletions sources/packages/backend/package.json
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@
"passport-jwt": "^4.0.1",
"pg": "^8.5.1",
"qs": "^6.9.6",
"redis": "^4.6.6",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.4",
3 changes: 3 additions & 0 deletions sources/tests/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -63,6 +63,7 @@ services:
- E2E_TEST_AEST_MOF_OPERATIONS_USER=${E2E_TEST_AEST_MOF_OPERATIONS_USER}
- E2E_TEST_AEST_READ_ONLY_USER=${E2E_TEST_AEST_READ_ONLY_USER}
- E2E_TEST_PASSWORD=${E2E_TEST_PASSWORD}
- DISABLE_ORM_CACHE=${DISABLE_ORM_CACHE}
volumes:
- ./coverage/api:/app/apps/api/coverage
networks:
@@ -94,6 +95,7 @@ services:
- REDIS_PORT=${REDIS_PORT}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_STANDALONE_MODE=${REDIS_STANDALONE_MODE}
- DISABLE_ORM_CACHE=${DISABLE_ORM_CACHE}
volumes:
- ./coverage/workers:/app/apps/workers/coverage
networks:
@@ -151,6 +153,7 @@ services:
- ESDC_ENVIRONMENT_CODE=${ESDC_ENVIRONMENT_CODE}
- APPLICATION_ARCHIVE_DAYS=${APPLICATION_ARCHIVE_DAYS}
- QUEUE_CONSUMERS_PORT=${QUEUE_CONSUMERS_PORT}
- DISABLE_ORM_CACHE=${DISABLE_ORM_CACHE}
volumes:
- ./coverage/queue-consumers:/app/apps/queue-consumers/coverage
networks: