Skip to content

Commit

Permalink
New runtime config options: feature flags & logged user.
Browse files Browse the repository at this point in the history
Split auth api service and logged user service.
Minor cleanups on the way
  • Loading branch information
zaychenko-sergei committed Aug 21, 2023
1 parent 353cc11 commit 13cbcfb
Show file tree
Hide file tree
Showing 30 changed files with 628 additions and 286 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"rules": {
"no-bitwise": 0,
"@typescript-eslint/no-extraneous-class": ["off"],
"jasmine/new-line-before-expect": 0
"jasmine/new-line-before-expect": 0,
"jasmine/no-unsafe-spy": ["off"]
},
"ignorePatterns": ["node_modules", "dist", "kamu.graphql.interface.ts"]
}
5 changes: 4 additions & 1 deletion images/kamu-web-ui/runtime-config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"apiServerGqlUrl": "http://localhost:8080/graphql",
"loginEnabled": true
"featureFlags": {
"enableLogin": true,
"enableLogout": true
}
}
94 changes: 40 additions & 54 deletions src/app/api/auth.api.spec.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,38 @@
import { NavigationService } from "src/app/services/navigation.service";
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
import { Apollo } from "apollo-angular";
import { AuthApi } from "./auth.api";
import { AccountDetailsFragment, FetchAccountInfoDocument, GithubLoginDocument } from "./kamu.graphql.interface";
import { ApolloTestingController, ApolloTestingModule } from "apollo-angular/testing";
import {
mockAccountDetails,
mockGithubLoginResponse,
mockLogin401Error,
mockUserInfoFromAccessToken,
TEST_ACCESS_TOKEN,
TEST_GITHUB_CODE,
} from "./mock/auth.mock";
import AppValues from "../common/app.values";
import { MaybeNull } from "../common/app.types";
import { AuthenticationError } from "../common/errors";
import { first } from "rxjs/operators";

describe("AuthApi", () => {
let service: AuthApi;
let navigationService: NavigationService;
let controller: ApolloTestingController;
let localStorageSetItemSpy: jasmine.Spy;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [AuthApi, Apollo],
imports: [ApolloTestingModule],
});
service = TestBed.inject(AuthApi);
navigationService = TestBed.inject(NavigationService);
controller = TestBed.inject(ApolloTestingController);
localStorageSetItemSpy = spyOn(localStorage, "setItem").and.stub();
});

afterEach(() => {
controller.verify();
});

function loginViaAccessToken(): void {
service.fetchUserInfoFromAccessToken(TEST_ACCESS_TOKEN).subscribe(() => {
expect(service.isAuthenticated).toBeTrue();
expect(service.currentUser).toBe(mockUserInfoFromAccessToken.auth.accountInfo);
});
service.fetchUserInfoFromAccessToken(TEST_ACCESS_TOKEN).subscribe();

const op = controller.expectOne(FetchAccountInfoDocument);
expect(op.operation.variables.accessToken).toEqual(TEST_ACCESS_TOKEN);
Expand All @@ -52,14 +43,7 @@ describe("AuthApi", () => {
}

function loginFullyViaGithub(): void {
service.fetchUserInfoAndTokenFromGithubCallackCode(TEST_GITHUB_CODE).subscribe(() => {
expect(service.isAuthenticated).toBeTrue();
expect(service.currentUser).toBe(mockGithubLoginResponse.auth.githubLogin.accountInfo);
expect(localStorageSetItemSpy).toHaveBeenCalledWith(
AppValues.LOCAL_STORAGE_ACCESS_TOKEN,
mockGithubLoginResponse.auth.githubLogin.token.accessToken,
);
});
service.fetchUserInfoAndTokenFromGithubCallackCode(TEST_GITHUB_CODE).subscribe();

const op = controller.expectOne(GithubLoginDocument);
expect(op.operation.variables.code).toEqual(TEST_GITHUB_CODE);
Expand All @@ -69,44 +53,30 @@ describe("AuthApi", () => {
});
}

function checkUserIsLogged(user: AccountDetailsFragment): void {
expect(service.isAuthenticated).toBeTrue();
expect(service.currentUser).toEqual(user);
}

it("should be created", () => {
expect(service).toBeTruthy();
});

it("should check user is initially non-authenticated", () => {
expect(service.isAuthenticated).toBeFalse();
expect(service.currentUser).toBeNull();
});

it("should check user changes via login with alive access token", fakeAsync(() => {
let callbackInvoked = false;
service.onUserChanges.subscribe((user: MaybeNull<AccountDetailsFragment>) => {
callbackInvoked = true;
user ? checkUserIsLogged(user) : fail("User must not be null");
});

loginViaAccessToken();
tick();

expect(callbackInvoked).toBeTrue();
}));
it("should check full login GraphQL success", fakeAsync(() => {
const accessTokenObtained$ = service
.accessTokenObtained()
.pipe(first())
.subscribe((token: string) => {
expect(token).toEqual(mockGithubLoginResponse.auth.githubLogin.token.accessToken);
});

it("should check user changes via full login with github", fakeAsync(() => {
const subscription$ = service.onUserChanges
const accountChanged$ = service
.accountChanged()
.pipe(first())
.subscribe((user: MaybeNull<AccountDetailsFragment>) => {
user ? checkUserIsLogged(user) : fail("User must not be null");
.subscribe((user: AccountDetailsFragment) => {
expect(user).toEqual(mockAccountDetails);
});

loginFullyViaGithub();
tick();

expect(subscription$.closed).toBeTrue();
expect(accessTokenObtained$.closed).toBeTrue();
expect(accountChanged$.closed).toBeTrue();
}));

it("should check full login GraphQL failure", fakeAsync(() => {
Expand All @@ -129,6 +99,30 @@ describe("AuthApi", () => {
expect(subscription$.closed).toBeTrue();
}));

it("should check login via access token GraphQL success", fakeAsync(() => {
const accessTokenObtained$ = service
.accessTokenObtained()
.pipe(first())
.subscribe(() => {
fail("Unexpected call of access token update");
});

const accountChanged$ = service
.accountChanged()
.pipe(first())
.subscribe((user: AccountDetailsFragment) => {
expect(user).toEqual(mockAccountDetails);
});

loginViaAccessToken();
tick();

expect(accessTokenObtained$.closed).toBeFalse();
accessTokenObtained$.unsubscribe();

expect(accountChanged$.closed).toBeTrue();
}));

it("should check login via access token GraphQL failure", fakeAsync(() => {
const subscription$ = service
.fetchUserInfoFromAccessToken(TEST_ACCESS_TOKEN)
Expand All @@ -148,12 +142,4 @@ describe("AuthApi", () => {

expect(subscription$.closed).toBeTrue();
}));

it("should check user logout navigates to home page", () => {
const navigationServiceSpy = spyOn(navigationService, "navigateToHome");
loginViaAccessToken();
service.logOut();
expect(service.currentUser).toBeNull();
expect(navigationServiceSpy).toHaveBeenCalledWith();
});
});
50 changes: 11 additions & 39 deletions src/app/api/auth.api.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,40 @@
import { Injectable } from "@angular/core";
import { catchError, map } from "rxjs/operators";
import { Observable, Subject, throwError } from "rxjs";
import { NavigationService } from "../services/navigation.service";
import { Observable, ReplaySubject, Subject, throwError } from "rxjs";
import {
AccountDetailsFragment,
FetchAccountInfoGQL,
FetchAccountInfoMutation,
GithubLoginGQL,
GithubLoginMutation,
} from "./kamu.graphql.interface";
import AppValues from "../common/app.values";
import { MaybeNull } from "../common/app.types";
import { MutationResult } from "apollo-angular";
import { isNull } from "lodash";
import { AuthenticationError } from "../common/errors";

@Injectable({
providedIn: "root",
})
export class AuthApi {
private user: MaybeNull<AccountDetailsFragment> = null;
constructor(private githubLoginGQL: GithubLoginGQL, private fetchAccountInfoGQL: FetchAccountInfoGQL) {}

private userChanges$: Subject<MaybeNull<AccountDetailsFragment>> = new Subject<MaybeNull<AccountDetailsFragment>>();
private accessTokenObtained$: Subject<string> = new ReplaySubject<string>(1);
private accountChanged$: Subject<AccountDetailsFragment> = new ReplaySubject<AccountDetailsFragment>(1);

constructor(
private githubLoginGQL: GithubLoginGQL,
private fetchAccountInfoGQL: FetchAccountInfoGQL,
private navigationService: NavigationService,
) {}

public get onUserChanges(): Observable<MaybeNull<AccountDetailsFragment>> {
return this.userChanges$.asObservable();
}

public get currentUser(): MaybeNull<AccountDetailsFragment> {
return this.user;
}

public get isAuthenticated(): boolean {
return !isNull(this.user);
public accessTokenObtained(): Observable<string> {
return this.accessTokenObtained$.asObservable();
}

private changeUser(user: MaybeNull<AccountDetailsFragment>) {
this.user = user;
this.userChanges$.next(user);
public accountChanged(): Observable<AccountDetailsFragment> {
return this.accountChanged$.asObservable();
}

public fetchUserInfoAndTokenFromGithubCallackCode(code: string): Observable<void> {
return this.githubLoginGQL.mutate({ code }).pipe(
map((result: MutationResult<GithubLoginMutation>) => {
if (result.data) {
const data: GithubLoginMutation = result.data;
localStorage.setItem(AppValues.LOCAL_STORAGE_ACCESS_TOKEN, data.auth.githubLogin.token.accessToken);
this.changeUser(data.auth.githubLogin.accountInfo);
this.accessTokenObtained$.next(data.auth.githubLogin.token.accessToken);
this.accountChanged$.next(data.auth.githubLogin.accountInfo);
} else {
throw new AuthenticationError(result.errors ?? []);
}
Expand All @@ -66,22 +48,12 @@ export class AuthApi {
map((result: MutationResult<FetchAccountInfoMutation>) => {
if (result.data) {
const data: FetchAccountInfoMutation = result.data;
this.changeUser(data.auth.accountInfo);
this.accountChanged$.next(data.auth.accountInfo);
} else {
throw new AuthenticationError(result.errors ?? []);
}
}),
catchError((e: Error) => throwError(new AuthenticationError([e]))),
);
}

public terminateSession() {
this.changeUser(null);
localStorage.removeItem(AppValues.LOCAL_STORAGE_ACCESS_TOKEN);
}

public logOut(): void {
this.terminateSession();
this.navigationService.navigateToHome();
}
}
3 changes: 2 additions & 1 deletion src/app/api/mock/auth.mock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GraphQLError } from "graphql";
import { AccountDetailsFragment, FetchAccountInfoMutation, GithubLoginMutation } from "../kamu.graphql.interface";
import AppValues from "src/app/common/app.values";

export const TEST_GITHUB_CODE = "12345";
export const TEST_ACCESS_TOKEN = "someToken";
Expand All @@ -9,7 +10,7 @@ export const mockAccountDetails: AccountDetailsFragment = {
name: "Test User",
email: "test@example.com",
// avatarUrl: null,
avatarUrl: "https://avatars.githubusercontent.com/u/11951648?v=4",
avatarUrl: AppValues.DEFAULT_AVATAR_URL,
gravatarId: null,
};

Expand Down
35 changes: 31 additions & 4 deletions src/app/app-config.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { Injectable } from "@angular/core";
import { AccountDetailsFragment } from "./api/kamu.graphql.interface";
import { FeatureFlags } from "./common/feature-flags.model";

interface AppConfig {
apiServerGqlUrl: string;
loginEnabled: boolean;
featureFlags: {
enableLogin: boolean;
enableLogout: boolean;
};
loggedUser?: AppConfigLoggedUser;
}

interface AppConfigLoggedUser {
login: string;
name: string;
email?: string;
avatarUrl?: string;
}

@Injectable({
Expand All @@ -19,12 +32,26 @@ export class AppConfigService {
return this.appConfig.apiServerGqlUrl;
}

get loginEnabled(): boolean {
get featureFlags(): FeatureFlags {
if (!this.appConfig) {
this.appConfig = AppConfigService.loadAppConfig();
}

return this.appConfig.loginEnabled;
return this.appConfig.featureFlags;
}

get loggedUser(): AccountDetailsFragment | null {
if (!this.appConfig) {
this.appConfig = AppConfigService.loadAppConfig();
}

if (this.appConfig.loggedUser) {
return {
...this.appConfig.loggedUser,
} as AccountDetailsFragment;
} else {
return null;
}
}

private static loadAppConfig(): AppConfig {
Expand All @@ -33,8 +60,8 @@ export class AppConfigService {
request.send(null);
const data: AppConfig = JSON.parse(request.responseText) as AppConfig;
return {
...data,
apiServerGqlUrl: AppConfigService.toRemoteURL(data.apiServerGqlUrl),
loginEnabled: data.loginEnabled,
};
}

Expand Down
29 changes: 14 additions & 15 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
<app-spinner></app-spinner>
<app-header
[appLogo]="appLogo"
[isMobileView]="isMobileView"
[isVisible]="isHeaderVisible"
[userInfo]="user"
[isLoginEnabled]="isLoginEnabled"
(selectDatasetEmitter)="onSelectDataset($event)"
(clickAppLogoEmitter)="onClickAppLogo()"
(loginEmitter)="onLogin()"
(logOutEmitter)="onLogOut()"
(userProfileEmitter)="onOpenUserInfo()"
(addNewEmitter)="onAddNew()"
(clickHelpEmitter)="onHelp()"
(clickSettingsEmitter)="onSettings()"
(clickAnalyticsEmitter)="onAnalytics()"
(clickBillingEmitter)="onBilling()"
(clickUserDatasetsEmitter)="onUserDatasets()"
(clickUserProfileEmitter)="onUserProfile()"
[loggedUserInfo]="loggedUserInfo"
[featureFlags]="featureFlags"
(onSelectedDataset)="onSelectedDataset($event)"
(onClickedAppLogo)="onAppLogo()"
(onClickedLogin)="onLogin()"
(onClickedLogout)="onLogout()"
(onClickedOpenUserInfo)="onOpenUserInfo()"
(onClickedAddNew)="onAddNew()"
(onClickedHelp)="onHelp()"
(onClickedSettings)="onSettings()"
(onClickedAnalytics)="onAnalytics()"
(onClickedBilling)="onBilling()"
(onClickedUserDatasets)="onUserDatasets()"
(onClickedUserProfile)="onUserProfile()"
></app-header>
<router-outlet></router-outlet>
<modal data-test-id="modal"></modal>
Loading

0 comments on commit 13cbcfb

Please sign in to comment.