Skip to content

Commit

Permalink
feat(auth): can login, redirect after login or be redirected to login…
Browse files Browse the repository at this point in the history
… if needed
  • Loading branch information
Mendes Hugo committed Sep 6, 2023
1 parent 17e259b commit e7df474
Show file tree
Hide file tree
Showing 23 changed files with 630 additions and 66 deletions.
27 changes: 23 additions & 4 deletions apps/backend/src/app/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Post, Req, Res, UseGuards } from "@nestjs/common";
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { CookieOptions } from "express-serve-static-core";
import { AuthLoginDto, AuthRefreshDto } from "~/lib/common/app/auth/dtos";
import { AuthSuccessDto } from "~/lib/common/app/auth/dtos/auth.success.dto";
import { AUTH_ENDPOINT_PREFIX, AuthEndpoint, AuthEndpoints } from "~/lib/common/app/auth/endpoints";
Expand All @@ -26,6 +27,15 @@ declare global {
@ApiTags("Auth")
@Controller(AUTH_ENDPOINT_PREFIX)
export class AuthController implements AuthEndpoint {
/**
* Default cookie options
*/
private readonly cookieOptions: CookieOptions = {
httpOnly: true,
sameSite: "none",
secure: true
};

/**
* Constructor with "dependency injection"
*
Expand Down Expand Up @@ -59,6 +69,17 @@ export class AuthController implements AuthEndpoint {
return this.loginOrRefresh(req!.user!, body, res!);
}

/**
* @inheritDoc
*/
@ApiOkResponse()
@Post(AuthEndpoints.LOGOUT)
public logout(@Res({ passthrough: true }) res?: Response) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- From guards and decorators (optional for the interface)
res!.clearCookie(authOptions.cookies.name, this.cookieOptions);
return Promise.resolve();
}

/**
* @inheritDoc
*/
Expand All @@ -78,10 +99,8 @@ export class AuthController implements AuthEndpoint {
return this.service.login(user).then(token => {
if (body.cookie) {
res.cookie(authOptions.cookies.name, token.access_token, {
expires: new Date(token.expires_at),
httpOnly: true,
sameSite: "none",
secure: true
...this.cookieOptions,
expires: new Date(token.expires_at)
});
}

Expand Down
3 changes: 2 additions & 1 deletion apps/backend/test/support/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const configTest = {
username: "_db-test"
},
host: {
cors: { origin: "127.0.0.1" },
// The e2e instance can be used in frontend:e2e:watch mode
cors: { origin: /\/\/localhost(:[0-9]{1,5})+/ },
globalPrefix: "/e2e/api",
name: "127.0.0.1",
port: 32300
Expand Down
112 changes: 77 additions & 35 deletions apps/frontend-e2e/src/e2e/auth/login.cy.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,96 @@
import { DbE2eHelper } from "~/app/backend/e2e/db-e2e/db-e2e.helper";
import { BASE_SEED } from "~/lib/common/seeds";

describe("Auth login", () => {
describe("Auth", () => {
const dbHelper = DbE2eHelper.getHelper("base");
const db = dbHelper.db as typeof BASE_SEED;

const [workflow] = db.workflows;

const pathLogin = "/auth/login";
/** a path to a protected content */
const pathProtected = `/workflows/${workflow._id}?param1=1&param2=2`;

before(() => dbHelper.refresh());

beforeEach(() => {
cy.clearCookies();
cy.visit("/auth/login");
cy.authDisconnect();
cy.visit(pathLogin);
});

/* ==== Test Created with Cypress Studio ==== */
it("should fail with unknown user", () => {
/* ==== Generated with Cypress Studio ==== */
cy.get("#mat-input-0").type("not@existing.user");
cy.get("#mat-input-1").type("password");
cy.get(".mdc-button").click();
cy.get("#mat-mdc-error-2 > span").should("be.visible");
/* ==== End Cypress Studio ==== */
});
describe("Login", () => {
/* ==== Test Created with Cypress Studio ==== */
it("should fail with unknown user", () => {
/* ==== Generated with Cypress Studio ==== */
cy.get("#mat-input-0").type("not@existing.user");
cy.get("#mat-input-1").type("password");
cy.get(".mdc-button").click();
cy.get("#mat-mdc-error-2 > span").should("be.visible");
/* ==== End Cypress Studio ==== */
});

/* ==== Test Created with Cypress Studio ==== */
it("should fail with wrong password", () => {
const [{ email, password }] = db.users;

/* ==== Generated with Cypress Studio ==== */
cy.get("#mat-input-0").type(email);
cy.get("#mat-input-1").type(`${password}abc`);
cy.get(".mdc-button").click();
cy.get("#mat-mdc-error-2 > span").should("be.visible");
/* ==== End Cypress Studio ==== */
});

/* ==== Test Created with Cypress Studio ==== */
it("should login and redirect to default", () => {
const [{ email, password }] = db.users;

/* ==== Test Created with Cypress Studio ==== */
it("should fail with wrong password", () => {
/* ==== Generated with Cypress Studio ==== */
const [{ email, password }] = db.users;

/* ==== Generated with Cypress Studio ==== */
cy.get("#mat-input-0").type(email);
cy.get("#mat-input-1").type(`${password}abc`);
cy.get(".mdc-button").click();
cy.get("#mat-mdc-error-2 > span").should("be.visible");
/* ==== End Cypress Studio ==== */
/* ==== Generated with Cypress Studio ==== */
cy.get("#mat-input-0").type(email);
cy.get("#mat-input-1").type(password);

cy.get(".mdc-button").click();
cy.get(".mat-mdc-card-title").should("contain.text", `Hello ${email}!`);
// /* ==== End Cypress Studio ==== */

cy.location("pathname").should("eq", "/");
});
});

/* ==== Test Created with Cypress Studio ==== */
it("should login and redirect to default", () => {
/* ==== Generated with Cypress Studio ==== */
const [{ email, password }] = db.users;
describe("Auth guard", () => {
it("should redirect to the login page when accessing protected content and redirect to this page after login", () => {
const [{ email, password }] = db.users;

cy.visit(pathProtected);

cy.get("#mat-input-0").type(email);
cy.get("#mat-input-1").type(password);
cy.location("pathname").should("eq", pathLogin);
cy.location("search").should("eq", `?redirectUrl=${encodeURIComponent(pathProtected)}`);

cy.get(".mdc-button").click();
cy.get(".mat-mdc-card-title").should("contain.text", `Hello ${email}!`);
// /* ==== End Cypress Studio ==== */
cy.get("#mat-input-0").type(email);
cy.get("#mat-input-1").type(password);
cy.get("#mat-input-1").type("{enter}");

// eslint-disable-next-line cypress/no-unnecessary-waiting -- Wait for redirection
cy.wait(1000);
cy.location("pathname").should("eq", "/");
cy.url().should("contains", pathProtected);
});

it("should load the protected page when already connected", () => {
const [{ email, password }] = db.users;

cy.authConnectAs(email, password);
cy.visit(pathProtected);
cy.url().should("contains", pathProtected);
});
});

// describe("Auth interceptor", () => {
// it.only("should redirect when a request has a 401 error", () => {
// const [{ email, password }] = db.users;
// cy.authConnectAs(email, password);
// cy.visit(pathProtected);
//
// // TODO: something that makes a request
// cy.authDisconnect();
// // TODO: something that makes a request and redirect
// });
// });
});
31 changes: 30 additions & 1 deletion apps/frontend-e2e/src/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,33 @@
// https://on.cypress.io/custom-commands
// ***********************************************

void 0;
import { AuthLoginDto } from "~/lib/common/app/auth/dtos";
import { AUTH_ENDPOINT_PREFIX, AuthEndpoints } from "~/lib/common/app/auth/endpoints";

// TODO: a way to get this from configuration?
const e2eApi = "http://127.0.0.1:32300/e2e/api";

const authConnectAs = (email: string, password: string) =>
cy.request("post", `${e2eApi}${AUTH_ENDPOINT_PREFIX}/${AuthEndpoints.LOGIN}`, {
cookie: true,
email,
password
} satisfies AuthLoginDto);

const authDisconnect = () =>
cy
.request("post", `${e2eApi}${AUTH_ENDPOINT_PREFIX}/${AuthEndpoints.LOGOUT}`)
.then(() => cy.clearCookies());

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace -- same as above
namespace Cypress {
interface Chainable {
authConnectAs: typeof authConnectAs;
authDisconnect: typeof authDisconnect;
}
}
}

Cypress.Commands.add("authConnectAs", authConnectAs);
Cypress.Commands.add("authDisconnect", authDisconnect);
4 changes: 4 additions & 0 deletions apps/frontend-e2e/src/support/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@
// https://on.cypress.io/configuration
// ***********************************************************

// import plugins
import "@4tw/cypress-drag-drop";
import "@testing-library/cypress/add-commands";

// Import commands.js using ES2015 syntax:
import "./commands";
2 changes: 1 addition & 1 deletion apps/frontend-e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"target": "ES6",
"types": ["cypress", "node"]
"types": ["cypress", "node", "@4tw/cypress-drag-drop", "@testing-library/cypress"]
},
"extends": "../../tsconfig.e2e.json",
"include": ["src/**/*.ts", "src/**/*.js", "cypress.config.ts"]
Expand Down
23 changes: 21 additions & 2 deletions apps/frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
import { ApplicationRef, DoBootstrap, NgModule } from "@angular/core";
import { APP_INITIALIZER, ApplicationRef, DoBootstrap, NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ApiModule } from "~/lib/ng/lib/api";

import { AppComponent } from "./app.component";
import { AuthInterceptor } from "./auth/auth.interceptor";
import { AuthModule } from "./auth/auth.module";
import { AuthService } from "./auth/auth.service";
import { environment } from "../environments/environment";
import { AppRouterModule } from "../lib/router";
import { AppTranslationModule } from "../lib/translation";

@NgModule({
imports: [
AppComponent,
ApiModule.forRoot({ client: environment.backend }),
AppRouterModule,
ApiModule.forRoot({ client: environment.backend }),
AppTranslationModule,
AuthModule,
BrowserAnimationsModule,
BrowserModule
],
providers: [
{
deps: [AuthInterceptor, AuthService],
multi: true,
provide: APP_INITIALIZER,
useFactory: (interceptor: AuthInterceptor, service: AuthService) => () =>
interceptor.runUnprotected(() =>
service.refresh().catch((error: unknown) => {
if (!AuthService.isAnUnauthorizedError(error)) {
throw error;
}
})
)
}
]
})
export class AppModule implements DoBootstrap {
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { Routes } from "@angular/router";

import { authGuard } from "./auth/auth.guard";

export const APP_ROUTES: Routes = [
{
loadChildren: () =>
import("./auth/views/auth-routing.module").then(m => m.AuthRoutingModule),
path: "auth"
},
{
canActivate: [authGuard],
loadChildren: () =>
import("./workflow/views/workflow-routing.module").then(m => m.WorkflowRoutingModule),
path: "workflows"
Expand Down
17 changes: 17 additions & 0 deletions apps/frontend/src/app/auth/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestBed } from "@angular/core/testing";
import { CanActivateFn } from "@angular/router";

import { authGuard } from "./auth.guard";

describe("authGuard", () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it("should be created", () => {
expect(executeGuard).toBeTruthy();
});
});
26 changes: 26 additions & 0 deletions apps/frontend/src/app/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { inject } from "@angular/core";
import { CanActivateFn, CanActivateChildFn, Router } from "@angular/router";
import { map } from "rxjs";

import { AuthService } from "./auth.service";

/**
* The guard that checks that the user is connected
*
* @param route same as in {@link CanActivateFn}
* @param state same as in {@link CanActivateFn}
* @returns same as in {@link CanActivateFn}
*
* @see {@link CanActivateFn}
* @see {@link CanActivateChildFn}
*/
export const authGuard: CanActivateChildFn & CanActivateFn = (route, state) => {
const service = inject(AuthService);
const router = inject(Router);

return service.userState$.pipe(
map(({ type }) =>
type === "connected" ? true : AuthService.createLoginUrlTree(router, state.url)
)
);
};
18 changes: 18 additions & 0 deletions apps/frontend/src/app/auth/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { TestBed } from "@angular/core/testing";
import { ApiModule } from "~/lib/ng/lib/api";

import { AuthInterceptor } from "./auth.interceptor";
import { AuthModule } from "./auth.module";

describe("AuthInterceptor", () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [ApiModule.forRoot({ client: { url: "" } }), AuthModule]
})
);

it("should be created", () => {
const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor);
expect(interceptor).toBeTruthy();
});
});
Loading

0 comments on commit e7df474

Please sign in to comment.