diff --git a/.env b/.env new file mode 100644 index 00000000..f92816fd --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +# should be added to .gitignore +TOTP_SECRET = IFTXE3SPOEYVURT2MRYGI52TKJ4HC3KH + +TEST_JWT_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..16ade69b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: push + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Setup nodejs + uses: actions/setup-node@v2 + with: + node-version: 20 + + - name: Download dependencies + run: npm install + + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + start: npm start + wait-on: 'http://localhost:3000' + diff --git a/cypress.config.ts b/cypress.config.ts index a203895b..0cdaffc3 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -14,10 +14,10 @@ export default defineConfig({ downloadsFolder: 'test/cypress/downloads', fixturesFolder: false, supportFile: 'test/cypress/support/e2e.ts', - setupNodeEvents (on: any) { - on('before:browser:launch', (browser: any = {}, launchOptions: any) => { // TODO Remove after upgrade to Cypress >=12.5.0 Chrome 119 become available on GitHub Workflows, see https://github.com/cypress-io/cypress-documentation/issues/5479 + setupNodeEvents (on: Cypress.PluginEvents) { + on('before:browser:launch', (browser: Cypress.Browser, launchOptions: Cypress.BeforeBrowserLaunchOptions) => { // TODO Remove after upgrade to Cypress >=12.5.0 Chrome 119 become available on GitHub Workflows, see https://github.com/cypress-io/cypress-documentation/issues/5479 if (browser.name === 'chrome' && browser.isHeadless) { - launchOptions.args = launchOptions.args.map((arg: any) => { + launchOptions.args = launchOptions.args.map((arg: string) => { if (arg === '--headless') { return '--headless=new' } @@ -33,7 +33,8 @@ export default defineConfig({ return security.generateCoupon(discount) }, GetBlueprint () { - for (const product of config.get('products')) { + const products = config.get('products'); + for (const product of products) { if (product.fileForRetrieveBlueprintChallenge) { const blueprint = product.fileForRetrieveBlueprintChallenge return blueprint @@ -45,17 +46,18 @@ export default defineConfig({ (product) => product.useForChristmasSpecialChallenge )[0] }, - GetCouponIntent () { - const trainingData = require(`data/chatbot/${utils.extractFilename( - config.get('application.chatBot.trainingData') - )}`) + async GetCouponIntent () { + const trainingData = await import( + `data/chatbot/${utils.extractFilename(config.get('application.chatBot.trainingData'))}` + ); const couponIntent = trainingData.data.filter( (data: { intent: string }) => data.intent === 'queries.couponCode' )[0] return couponIntent }, - GetFromMemories (property: string) { - for (const memory of config.get('memories') as any) { + GetFromMemories (property: keyof MemoryConfig) { + const memories = config.get('memories'); + for (const memory of memories) { if (memory[property]) { return memory[property] } diff --git a/data/static/users.yml b/data/static/users.yml index 9b3f8d72..d9d2080b 100644 --- a/data/static/users.yml +++ b/data/static/users.yml @@ -147,7 +147,7 @@ email: wurstbrot username: wurstbrot password: 'EinBelegtesBrotMitSchinkenSCHINKEN!' - totpSecret: IFTXE3SPOEYVURT2MRYGI52TKJ4HC3KH + totpSecret: ${TOTP_SECRET} key: timo role: 'admin' securityQuestion: diff --git a/data/staticData.ts b/data/staticData.ts index 203a8c52..6c9b5006 100644 --- a/data/staticData.ts +++ b/data/staticData.ts @@ -3,11 +3,16 @@ import { readFile } from 'fs/promises' import { safeLoad } from 'js-yaml' import logger from '../lib/logger' -export async function loadStaticData (file: string) { - const filePath = path.resolve('./data/static/' + file + '.yml') - return await readFile(filePath, 'utf8') - .then(safeLoad) - .catch(() => logger.error('Could not open file: "' + filePath + '"')) +export async function loadStaticData(file: string) { + const sanitizedFileName = path.basename(file); + const filePath = path.resolve('./data/static/' + sanitizedFileName + '.yml'); + + try { + const data = await readFile(filePath, 'utf8'); + return safeLoad(data); + } catch (error) { + logger.error('Could not open file: "' + filePath + '"'); + } } export interface StaticUser { diff --git a/frontend/src/app/Models/challenge.model.ts b/frontend/src/app/Models/challenge.model.ts index f60823d5..43e5d214 100644 --- a/frontend/src/app/Models/challenge.model.ts +++ b/frontend/src/app/Models/challenge.model.ts @@ -6,6 +6,7 @@ import { type SafeHtml } from '@angular/platform-browser' export interface Challenge { + id: string; // Add the `id` field here name: string key: string category: string diff --git a/frontend/src/app/Services/administration.service.spec.ts b/frontend/src/app/Services/administration.service.spec.ts index e67b1c9b..ab5d614d 100644 --- a/frontend/src/app/Services/administration.service.spec.ts +++ b/frontend/src/app/Services/administration.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { AdministrationService } from './administration.service' +interface ApplicationVersionResponse { + version: string; +} + describe('AdministrationService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,14 +26,14 @@ describe('AdministrationService', () => { it('should get application version directly from the rest api', inject([AdministrationService, HttpTestingController], fakeAsync((service: AdministrationService, httpMock: HttpTestingController) => { - let res: any - service.getApplicationVersion().subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.getApplicationVersion().subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/admin/application-version') req.flush({ version: 'apiResponse' }) tick() expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/administration.service.ts b/frontend/src/app/Services/administration.service.ts index fe89c16e..b7f0d685 100644 --- a/frontend/src/app/Services/administration.service.ts +++ b/frontend/src/app/Services/administration.service.ts @@ -8,6 +8,10 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { catchError, map } from 'rxjs/operators' +interface ApplicationVersionResponse { + version: string; +} + @Injectable({ providedIn: 'root' }) @@ -19,7 +23,7 @@ export class AdministrationService { getApplicationVersion () { return this.http.get(this.host + '/application-version').pipe( - map((response: any) => response.version), + map((response: ApplicationVersionResponse) => response.version), catchError((error: Error) => { throw error }) ) } diff --git a/frontend/src/app/Services/captcha.service.spec.ts b/frontend/src/app/Services/captcha.service.spec.ts index 732cb909..6b1b5b7d 100644 --- a/frontend/src/app/Services/captcha.service.spec.ts +++ b/frontend/src/app/Services/captcha.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { CaptchaService } from './captcha.service' +interface ApplicationVersionResponse { + version: string; +} + describe('CaptchaService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,14 +26,14 @@ describe('CaptchaService', () => { it('should get captcha directly from the rest api', inject([CaptchaService, HttpTestingController], fakeAsync((service: CaptchaService, httpMock: HttpTestingController) => { - let res: any - service.getCaptcha().subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.getCaptcha().subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/captcha/') req.flush('apiResponse') tick() expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/challenge.service.spec.ts b/frontend/src/app/Services/challenge.service.spec.ts index a216bc70..f70ba85e 100644 --- a/frontend/src/app/Services/challenge.service.spec.ts +++ b/frontend/src/app/Services/challenge.service.spec.ts @@ -1,145 +1,146 @@ -/* - * Copyright (c) 2014-2024 Bjoern Kimminich & the OWASP Juice Shop contributors. - * SPDX-License-Identifier: MIT - */ - -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' -import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' - -import { ChallengeService } from './challenge.service' +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { ChallengeService } from './challenge.service'; +import { Challenge } from '../Models/challenge.model'; // Make sure to import the correct type describe('ChallengeService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ - HttpClientTestingModule - ], - providers: [ChallengeService] - }) - }) + imports: [HttpClientTestingModule], + providers: [ChallengeService], + }); + }); it('should be created', inject([ChallengeService], (service: ChallengeService) => { - expect(service).toBeTruthy() - })) + expect(service).toBeTruthy(); + })); it('should get all challenges directly from the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.find().subscribe((data) => (res = data)) - - const req = httpMock.expectOne('http://localhost:3000/api/Challenges/') - req.flush({ data: 'apiResponse' }) - tick() - - expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') - httpMock.verify() + let res: Challenge[]; // Use the imported Challenge type + service.find().subscribe((data) => (res = data)); // This should be of type Challenge[] + + const req = httpMock.expectOne('http://localhost:3000/api/Challenges/'); + req.flush([{ id: '1', name: 'Challenge 1', description: 'Test challenge' }]); // Make sure the response matches Challenge[] + tick(); + + expect(req.request.method).toBe('GET'); + expect(res.length).toBeGreaterThan(0); + expect(res[0].id).toBe('1'); + expect(res[0].name).toBe('Challenge 1'); + httpMock.verify(); }) - )) + )); it('should get current continue code directly from the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.continueCode().subscribe((data) => (res = data)) + let res: { continueCode: string }; + service.continueCode().subscribe((data) => (res = data)); - const req = httpMock.expectOne('http://localhost:3000/rest/continue-code') - req.flush({ continueCode: 'apiResponse' }) - tick() + const req = httpMock.expectOne('http://localhost:3000/rest/continue-code'); + req.flush({ continueCode: 'apiResponse' }); + tick(); - expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') - httpMock.verify() + expect(req.request.method).toBe('GET'); + expect(res.continueCode).toBe('apiResponse'); + httpMock.verify(); }) - )) + )); it('should pass continue code for restoring challenge progress on to the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.restoreProgress('CODE').subscribe((data) => (res = data)) - - const req = httpMock.expectOne('http://localhost:3000/rest/continue-code/apply/CODE') - req.flush({ data: 'apiResponse' }) - tick() - - expect(req.request.method).toBe('PUT') - expect(res).toBe('apiResponse') - httpMock.verify() + let res: Challenge[]; + service.restoreProgress('CODE').subscribe((data) => (res = data)); + + const req = httpMock.expectOne('http://localhost:3000/rest/continue-code/apply/CODE'); + req.flush([{ id: '1', name: 'Restored Challenge', description: 'Restored description' }]); // Στέλνουμε πίνακα Challenge[] + tick(); + + expect(req.request.method).toBe('PUT'); + expect(res.length).toBeGreaterThan(0); + expect(res[0].id).toBe('1'); + expect(res[0].name).toBe('Restored Challenge'); + httpMock.verify(); }) - )) + )); it('should get current "Find It" coding challenge continue code directly from the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.continueCodeFindIt().subscribe((data) => (res = data)) + let res: { continueCode: string }; + service.continueCodeFindIt().subscribe((data) => (res = data)); - const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-findIt') - req.flush({ continueCode: 'apiResponse' }) - tick() + const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-findIt'); + req.flush({ continueCode: 'apiResponse' }); + tick(); - expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') - httpMock.verify() + expect(req.request.method).toBe('GET'); + expect(res.continueCode).toBe('apiResponse'); + httpMock.verify(); }) - )) + )); it('should pass "Find It" coding challenge continue code for restoring progress on to the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.restoreProgressFindIt('CODE').subscribe((data) => (res = data)) - - const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-findIt/apply/CODE') - req.flush({ data: 'apiResponse' }) - tick() - - expect(req.request.method).toBe('PUT') - expect(res).toBe('apiResponse') - httpMock.verify() + let res: Challenge[]; + service.restoreProgressFindIt('CODE').subscribe((data) => (res = data)); + + const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-findIt/apply/CODE'); + req.flush([{ id: '2', name: 'Find It Challenge', description: 'Find It description' }]); + tick(); + + expect(req.request.method).toBe('PUT'); + expect(res.length).toBeGreaterThan(0); + expect(res[0].id).toBe('2'); + expect(res[0].name).toBe('Find It Challenge'); + httpMock.verify(); }) - )) + )); it('should get current "Fix It" coding challenge continue code directly from the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.continueCodeFixIt().subscribe((data) => (res = data)) + let res: { continueCode: string }; + service.continueCodeFixIt().subscribe((data) => (res = data)); - const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-fixIt') - req.flush({ continueCode: 'apiResponse' }) - tick() + const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-fixIt'); + req.flush({ continueCode: 'apiResponse' }); + tick(); - expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') - httpMock.verify() + expect(req.request.method).toBe('GET'); + expect(res.continueCode).toBe('apiResponse'); + httpMock.verify(); }) - )) + )); it('should pass "Fix It" coding challenge continue code for restoring progress on to the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.restoreProgressFixIt('CODE').subscribe((data) => (res = data)) - - const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-fixIt/apply/CODE') - req.flush({ data: 'apiResponse' }) - tick() - - expect(req.request.method).toBe('PUT') - expect(res).toBe('apiResponse') - httpMock.verify() + let res: Challenge[]; + service.restoreProgressFixIt('CODE').subscribe((data) => (res = data)); + + const req = httpMock.expectOne('http://localhost:3000/rest/continue-code-fixIt/apply/CODE'); + req.flush([{ id: '3', name: 'Fix It Challenge', description: 'Fix It description' }]); + tick(); + + expect(req.request.method).toBe('PUT'); + expect(res.length).toBeGreaterThan(0); + expect(res[0].id).toBe('3'); + expect(res[0].name).toBe('Fix It Challenge'); + httpMock.verify(); }) - )) + )); it('should repeat notification directly from the rest api', inject([ChallengeService, HttpTestingController], fakeAsync((service: ChallengeService, httpMock: HttpTestingController) => { - let res: any - service.repeatNotification('CHALLENGE').subscribe((data) => (res = data)) + let res: string; + service.repeatNotification('CHALLENGE').subscribe((data) => (res = data)); - const req = httpMock.expectOne(req => req.url === 'http://localhost:3000/rest/repeat-notification') - req.flush('apiResponse') - tick() + const req = httpMock.expectOne(req => req.url === 'http://localhost:3000/rest/repeat-notification'); + req.flush('apiResponse'); + tick(); - expect(req.request.method).toBe('GET') - expect(req.request.params.get('challenge')).toBe('CHALLENGE') - expect(res).toBe('apiResponse') - httpMock.verify() + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('challenge')).toBe('CHALLENGE'); + expect(res).toBe('apiResponse'); + httpMock.verify(); }) - )) -}) + )); +}); \ No newline at end of file diff --git a/frontend/src/app/Services/challenge.service.ts b/frontend/src/app/Services/challenge.service.ts index ee1e8942..c7542ede 100644 --- a/frontend/src/app/Services/challenge.service.ts +++ b/frontend/src/app/Services/challenge.service.ts @@ -49,4 +49,4 @@ export class ChallengeService { restoreProgressFixIt (continueCode: string) { return this.http.put(this.hostServer + '/rest/continue-code-fixIt/apply/' + continueCode, {}).pipe(map((response: any) => response.data), catchError((err) => { throw err })) } -} +} \ No newline at end of file diff --git a/frontend/src/app/Services/chatbot.service.spec.ts b/frontend/src/app/Services/chatbot.service.spec.ts index 49aca74b..a792372b 100644 --- a/frontend/src/app/Services/chatbot.service.spec.ts +++ b/frontend/src/app/Services/chatbot.service.spec.ts @@ -8,6 +8,16 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { ChatbotService } from './chatbot.service' +interface ChatbotStatusResponse { + status: boolean; + body: string; +} + +interface ChatbotQueryResponse { + action: string; + body: string; +} + describe('ChatbotService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,32 +32,32 @@ describe('ChatbotService', () => { it('should get status from the REST API', inject([ChatbotService, HttpTestingController], fakeAsync((service: ChatbotService, httpMock: HttpTestingController) => { - let res: any - service.getChatbotStatus().subscribe((data) => (res = data)) + let res: ChatbotStatusResponse | undefined + service.getChatbotStatus().subscribe((data: ChatbotStatusResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/chatbot/status') req.flush({ status: true, body: 'apiResponse' }) tick() expect(req.request.method).toBe('GET') expect(req.request.body).toBeNull() - expect(res.status).toBeTrue() - expect(res.body).toBe('apiResponse') + expect(res?.status).toBeTrue() + expect(res?.body).toBe('apiResponse') httpMock.verify() }) )) it('should get query response from the REST API', inject([ChatbotService, HttpTestingController], fakeAsync((service: ChatbotService, httpMock: HttpTestingController) => { - let res: any - service.getResponse('query', 'apiQuery').subscribe((data) => (res = data)) + let res: ChatbotQueryResponse | undefined + service.getResponse('query', 'apiQuery').subscribe((data: ChatbotQueryResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/chatbot/respond') req.flush({ action: 'response', body: 'apiResponse' }) tick() expect(req.request.method).toBe('POST') expect(req.request.body.query).toBe('apiQuery') - expect(res.action).toBe('response') - expect(res.body).toBe('apiResponse') + expect(res?.action).toBe('response') + expect(res?.body).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/code-fixes.service.spec.ts b/frontend/src/app/Services/code-fixes.service.spec.ts index 0de38745..52803e51 100644 --- a/frontend/src/app/Services/code-fixes.service.spec.ts +++ b/frontend/src/app/Services/code-fixes.service.spec.ts @@ -19,7 +19,7 @@ describe('CodeFixesService', () => { it('should get code fixes for challenge directly from the rest api', inject([CodeFixesService, HttpTestingController], fakeAsync((service: CodeFixesService, httpMock: HttpTestingController) => { - let res: any + let res: unknown service.get('testChallenge').subscribe((data) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/snippets/fixes/testChallenge') @@ -34,7 +34,7 @@ describe('CodeFixesService', () => { it('should submit solution for "Fit It" phase of coding challenge via the rest api', inject([CodeFixesService, HttpTestingController], fakeAsync((service: CodeFixesService, httpMock: HttpTestingController) => { - let res: any + let res: unknown service.check('testChallenge', 1).subscribe((data) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/snippets/fixes') req.flush('apiResponse') diff --git a/frontend/src/app/Services/complaint.service.spec.ts b/frontend/src/app/Services/complaint.service.spec.ts index 1a7d6672..bf605620 100644 --- a/frontend/src/app/Services/complaint.service.spec.ts +++ b/frontend/src/app/Services/complaint.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { ComplaintService } from './complaint.service' +interface ApplicationVersionResponse { + version: string; +} + describe('ComplaintService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,15 +26,15 @@ describe('ComplaintService', () => { it('should create complaint directly via the rest api', inject([ComplaintService, HttpTestingController], fakeAsync((service: ComplaintService, httpMock: HttpTestingController) => { - let res: any - service.save(null).subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.save(null).subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/api/Complaints/') req.flush({ data: 'apiResponse' }) tick() expect(req.request.method).toBe('POST') expect(req.request.body).toBeNull() - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/country-mapping.service.spec.ts b/frontend/src/app/Services/country-mapping.service.spec.ts index ecc8d9f5..bafd175a 100644 --- a/frontend/src/app/Services/country-mapping.service.spec.ts +++ b/frontend/src/app/Services/country-mapping.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { CountryMappingService } from './country-mapping.service' +interface ApplicationVersionResponse { + version: string; +} + describe('CountryMappingService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,8 +26,8 @@ describe('CountryMappingService', () => { it('should get the country mapping directly through the rest API', inject([CountryMappingService, HttpTestingController], fakeAsync((service: CountryMappingService, httpMock: HttpTestingController) => { - let res: any - service.getCountryMapping().subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.getCountryMapping().subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/country-mapping') req.flush('apiResponse') @@ -31,7 +35,7 @@ describe('CountryMappingService', () => { tick() expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) diff --git a/frontend/src/app/Services/form-submit.service.ts b/frontend/src/app/Services/form-submit.service.ts index baa55e2a..f627777d 100644 --- a/frontend/src/app/Services/form-submit.service.ts +++ b/frontend/src/app/Services/form-submit.service.ts @@ -12,7 +12,7 @@ import { DOCUMENT } from '@angular/common' export class FormSubmitService { constructor (@Inject(DOCUMENT) private readonly _document: HTMLDocument) { } - attachEnterKeyHandler (formId: string, submitButtonId: string, onSubmit: any) { + attachEnterKeyHandler (formId: string, submitButtonId: string, onSubmit: () => void) { const form = this._document.getElementById(formId) as HTMLFormElement const submitButton = this._document.getElementById(submitButtonId) as HTMLInputElement diff --git a/frontend/src/app/Services/image-captcha.service.spec.ts b/frontend/src/app/Services/image-captcha.service.spec.ts index 938c5f88..1ef5c454 100644 --- a/frontend/src/app/Services/image-captcha.service.spec.ts +++ b/frontend/src/app/Services/image-captcha.service.spec.ts @@ -7,6 +7,10 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { ImageCaptchaService } from './image-captcha.service' +interface ApplicationVersionResponse { + version: string; +} + describe('ImageCaptchaService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -21,14 +25,14 @@ describe('ImageCaptchaService', () => { it('should get captcha directly from the rest api', inject([ImageCaptchaService, HttpTestingController], fakeAsync((service: ImageCaptchaService, httpMock: HttpTestingController) => { - let res: any - service.getCaptcha().subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.getCaptcha().subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/image-captcha/') req.flush('apiResponse') tick() expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/languages.service.spec.ts b/frontend/src/app/Services/languages.service.spec.ts index 3ea69204..a04dee97 100644 --- a/frontend/src/app/Services/languages.service.spec.ts +++ b/frontend/src/app/Services/languages.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { LanguagesService } from './languages.service' +interface ApplicationVersionResponse { + version: string; +} + describe('LanguagesService', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [HttpClientTestingModule], @@ -21,8 +25,8 @@ describe('LanguagesService', () => { it('should get the language list through the rest API', inject([LanguagesService, HttpTestingController], fakeAsync((service: LanguagesService, httpMock: HttpTestingController) => { - let res: any - service.getLanguages().subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.getLanguages().subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/languages') req.flush('apiResponse') @@ -30,7 +34,7 @@ describe('LanguagesService', () => { tick() expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) diff --git a/frontend/src/app/Services/security-answer.service.spec.ts b/frontend/src/app/Services/security-answer.service.spec.ts index 2bacd7c2..845db929 100644 --- a/frontend/src/app/Services/security-answer.service.spec.ts +++ b/frontend/src/app/Services/security-answer.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { SecurityAnswerService } from './security-answer.service' +interface ApplicationVersionResponse { + version: string; +} + describe('SecurityAnswerService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,15 +26,15 @@ describe('SecurityAnswerService', () => { it('should create feedback directly via the rest api', inject([SecurityAnswerService, HttpTestingController], fakeAsync((service: SecurityAnswerService, httpMock: HttpTestingController) => { - let res: any - service.save(null).subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.save(null).subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/api/SecurityAnswers/') req.flush({ data: 'apiResponse' }) tick() expect(req.request.method).toBe('POST') expect(req.request.body).toBeFalsy() - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/track-order.service.spec.ts b/frontend/src/app/Services/track-order.service.spec.ts index 50e7d436..069f69d8 100644 --- a/frontend/src/app/Services/track-order.service.spec.ts +++ b/frontend/src/app/Services/track-order.service.spec.ts @@ -8,6 +8,10 @@ import { fakeAsync, inject, TestBed, tick } from '@angular/core/testing' import { TrackOrderService } from './track-order.service' +interface ApplicationVersionResponse { + version: string; +} + describe('TrackOrderService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -22,14 +26,14 @@ describe('TrackOrderService', () => { it('should get tracking order results directly via the rest api', inject([TrackOrderService, HttpTestingController], fakeAsync((service: TrackOrderService, httpMock: HttpTestingController) => { - let res: any - service.find('5267-f9cd5882f54c75a3').subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.find('5267-f9cd5882f54c75a3').subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/rest/track-order/5267-f9cd5882f54c75a3') req.flush('apiResponse') tick() expect(req.request.method).toBe('GET') - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/Services/user.service.ts b/frontend/src/app/Services/user.service.ts index 4c0aae7e..45029040 100644 --- a/frontend/src/app/Services/user.service.ts +++ b/frontend/src/app/Services/user.service.ts @@ -78,4 +78,4 @@ export class UserService { upgradeToDeluxe (paymentMode: string, paymentId: any) { return this.http.post(this.hostServer + '/rest/deluxe-membership', { paymentMode, paymentId }).pipe(map((response: any) => response.data), catchError((err) => { throw err })) } -} +} \ No newline at end of file diff --git a/frontend/src/app/Services/vuln-lines.service.spec.ts b/frontend/src/app/Services/vuln-lines.service.spec.ts index 2caee086..d98eba65 100644 --- a/frontend/src/app/Services/vuln-lines.service.spec.ts +++ b/frontend/src/app/Services/vuln-lines.service.spec.ts @@ -2,6 +2,10 @@ import { TestBed, inject, fakeAsync, tick } from '@angular/core/testing' import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' import { VulnLinesService } from './vuln-lines.service' +interface ApplicationVersionResponse { + version: string; +} + describe('VulnLinesService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -16,15 +20,15 @@ describe('VulnLinesService', () => { it('should submit solution for "Fit It" phase of coding challenge via the rest api', inject([VulnLinesService, HttpTestingController], fakeAsync((service: VulnLinesService, httpMock: HttpTestingController) => { - let res: any - service.check('testChallenge', [1, 2]).subscribe((data) => (res = data)) + let res: ApplicationVersionResponse | undefined + service.check('testChallenge', [1, 2]).subscribe((data: ApplicationVersionResponse) => (res = data)) const req = httpMock.expectOne('http://localhost:3000/snippets/verdict') req.flush('apiResponse') tick() expect(req.request.method).toBe('POST') expect(req.request.body).toEqual({ key: 'testChallenge', selectedLines: [1, 2] }) - expect(res).toBe('apiResponse') + expect(res?.version).toBe('apiResponse') httpMock.verify() }) )) diff --git a/frontend/src/app/app.guard.spec.ts b/frontend/src/app/app.guard.spec.ts index 6c827c03..a7e98518 100644 --- a/frontend/src/app/app.guard.spec.ts +++ b/frontend/src/app/app.guard.spec.ts @@ -9,6 +9,9 @@ import { HttpClientTestingModule } from '@angular/common/http/testing' import { RouterTestingModule } from '@angular/router/testing' import { ErrorPageComponent } from './error-page/error-page.component' +import dotenv from 'dotenv'; +dotenv.config(); + describe('LoginGuard', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -37,7 +40,7 @@ describe('LoginGuard', () => { })) it('returns payload from decoding a valid JWT', inject([LoginGuard], (guard: LoginGuard) => { - localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + localStorage.setItem('token', process.env.TEST_JWT_TOKEN) expect(guard.tokenDecode()).toEqual({ sub: '1234567890', name: 'John Doe', @@ -57,7 +60,7 @@ describe('LoginGuard', () => { }) describe('AdminGuard', () => { - let loginGuard: any + let loginGuard: jasmine.SpyObj beforeEach(() => { loginGuard = jasmine.createSpyObj('LoginGuard', ['tokenDecode', 'forbidRoute']) @@ -105,7 +108,7 @@ describe('AdminGuard', () => { }) describe('AccountingGuard', () => { - let loginGuard: any + let loginGuard: jasmine.SpyObj beforeEach(() => { loginGuard = jasmine.createSpyObj('LoginGuard', ['tokenDecode', 'forbidRoute']) @@ -153,7 +156,7 @@ describe('AccountingGuard', () => { }) describe('DeluxeGuard', () => { - let loginGuard: any + let loginGuard: jasmine.SpyObj beforeEach(() => { loginGuard = jasmine.createSpyObj('LoginGuard', ['tokenDecode']) diff --git a/frontend/src/app/app.routing.ts b/frontend/src/app/app.routing.ts index d6c21136..f02342a7 100644 --- a/frontend/src/app/app.routing.ts +++ b/frontend/src/app/app.routing.ts @@ -287,7 +287,7 @@ export function token1 (...args: number[]) { // vuln-code-snippet neutral-line t }).join('') // vuln-code-snippet neutral-line tokenSaleChallenge } // vuln-code-snippet neutral-line tokenSaleChallenge -export function token2 (...args: number[]) { // vuln-code-snippet neutral-line tokenSaleChallenge +export function token2 (..._: number[]) { // vuln-code-snippet neutral-line tokenSaleChallenge const T = Array.prototype.slice.call(arguments) // vuln-code-snippet neutral-line tokenSaleChallenge const M = T.shift() // vuln-code-snippet neutral-line tokenSaleChallenge return T.reverse().map(function (m, H) { // vuln-code-snippet neutral-line tokenSaleChallenge diff --git a/frontend/src/app/challenge-solved-notification/challenge-solved-notification.component.spec.ts b/frontend/src/app/challenge-solved-notification/challenge-solved-notification.component.spec.ts index cf9ce53e..ba61d501 100644 --- a/frontend/src/app/challenge-solved-notification/challenge-solved-notification.component.spec.ts +++ b/frontend/src/app/challenge-solved-notification/challenge-solved-notification.component.spec.ts @@ -10,10 +10,11 @@ import { CountryMappingService } from '../Services/country-mapping.service' import { CookieModule, CookieService } from 'ngx-cookie' import { TranslateModule, TranslateService } from '@ngx-translate/core' import { ChallengeService } from '../Services/challenge.service' -import { ConfigurationService } from '../Services/configuration.service' +import { ConfigurationService, Config } from '../Services/configuration.service' import { HttpClientTestingModule } from '@angular/common/http/testing' import { type ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing' import { SocketIoService } from '../Services/socket-io.service' +import { Socket } from 'socket.io-client'; import { ChallengeSolvedNotificationComponent } from './challenge-solved-notification.component' import { of, throwError } from 'rxjs' @@ -21,38 +22,118 @@ import { EventEmitter } from '@angular/core' import { MatIconModule } from '@angular/material/icon' class MockSocket { - on (str: string, callback: any) { + on (_: string, callback: () => void) { callback() } - emit (a: any, b: any) { + emit () { return null } } +const mockConfig: Config = { + server: { port: 3000 }, + application: { + domain: 'localhost', + name: 'Juice Shop', + logo: 'logo.png', + favicon: 'favicon.ico', + theme: 'dark', + showVersionNumber: true, + showGitHubLinks: false, + localBackupEnabled: true, + numberOfRandomFakeUsers: 10, + altcoinName: 'JuiceCoin', + privacyContactEmail: 'privacy@juiceshop.com', + social: { + twitterUrl: 'https://twitter.com/juiceshop', + facebookUrl: 'https://facebook.com/juiceshop', + slackUrl: 'https://slack.com/juiceshop', + redditUrl: 'https://reddit.com/r/juiceshop', + pressKitUrl: 'https://juiceshop.com/presskit', + nftUrl: 'https://juiceshop.com/nft', + questionnaireUrl: 'https://juiceshop.com/questionnaire' + }, + recyclePage: { + topProductImage: 'top.png', + bottomProductImage: 'bottom.png' + }, + welcomeBanner: { + showOnFirstStart: true, + title: 'Welcome to Juice Shop', + message: 'Enjoy your stay!' + }, + cookieConsent: { + message: 'We use cookies', + dismissText: 'Got it!', + linkText: 'Learn more', + linkUrl: 'https://juiceshop.com/cookies' + }, + securityTxt: { + contact: 'security@juiceshop.com', + encryption: 'encryption-key', + acknowledgements: 'Thanks to all contributors' + }, + promotion: { + video: 'promo.mp4', + subtitles: 'promo.srt' + }, + easterEggPlanet: { + name: 'Juice Planet', + overlayMap: 'map.png' + }, + googleOauth: { + clientId: 'google-client-id', + authorizedRedirects: ['https://juiceshop.com/oauth'] + } + }, + challenges: { + showSolvedNotifications: true, + showHints: true, + showMitigations: true, + codingChallengesEnabled: 'all', + restrictToTutorialsFirst: false, + safetyMode: 'strict', + overwriteUrlForProductTamperingChallenge: 'https://juiceshop.com/tampering', + showFeedbackButtons: true + }, + hackingInstructor: { + isEnabled: true, + avatarImage: 'avatar.png' + }, + products: [], + memories: [], + ctf: { + showFlagsInNotifications: true, + showCountryDetailsInNotifications: 'all', + countryMapping: [] + } +}; + describe('ChallengeSolvedNotificationComponent', () => { - let component: ChallengeSolvedNotificationComponent - let fixture: ComponentFixture - let socketIoService: any - let translateService: any - let cookieService: any - let challengeService: any - let configurationService: any - let mockSocket: any + let component: ChallengeSolvedNotificationComponent; + let fixture: ComponentFixture; + let socketIoService: jasmine.SpyObj; + let translateService: jasmine.SpyObj; + let cookieService: jasmine.SpyObj; + let challengeService: jasmine.SpyObj; + let configurationService: jasmine.SpyObj; + let mockSocket: MockSocket; beforeEach(waitForAsync(() => { mockSocket = new MockSocket() socketIoService = jasmine.createSpyObj('SocketIoService', ['socket']) - socketIoService.socket.and.returnValue(mockSocket) + socketIoService.socket.and.returnValue(mockSocket as unknown as Socket) translateService = jasmine.createSpyObj('TranslateService', ['get']) - translateService.get.and.returnValue(of({})) - translateService.onLangChange = new EventEmitter() - translateService.onTranslationChange = new EventEmitter() - translateService.onDefaultLangChange = new EventEmitter() + translateService = jasmine.createSpyObj('TranslateService', ['get'], { + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter() + }); cookieService = jasmine.createSpyObj('CookieService', ['put']) challengeService = jasmine.createSpyObj('ChallengeService', ['continueCode']) configurationService = jasmine.createSpyObj('ConfigurationService', ['getApplicationConfiguration']) - configurationService.getApplicationConfiguration.and.returnValue(of({})) + configurationService.getApplicationConfiguration.and.returnValue(of(mockConfig)) TestBed.configureTestingModule({ imports: [ @@ -143,35 +224,35 @@ describe('ChallengeSolvedNotificationComponent', () => { })) it('should show CTF flag codes if configured accordingly', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ ctf: { showFlagsInNotifications: true } })) + configurationService.getApplicationConfiguration.and.returnValue(of(mockConfig)) component.ngOnInit() expect(component.showCtfFlagsInNotifications).toBeTrue() }) it('should hide CTF flag codes if configured accordingly', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ ctf: { showFlagsInNotifications: false } })) + configurationService.getApplicationConfiguration.and.returnValue(of(mockConfig)) component.ngOnInit() expect(component.showCtfFlagsInNotifications).toBeFalse() }) it('should hide CTF flag codes by default', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ ctf: { } })) + configurationService.getApplicationConfiguration.and.returnValue(of(mockConfig)) component.ngOnInit() expect(component.showCtfFlagsInNotifications).toBeFalse() }) it('should hide FBCTF-specific country details by default', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ ctf: { } })) + configurationService.getApplicationConfiguration.and.returnValue(of(mockConfig)) component.ngOnInit() expect(component.showCtfCountryDetailsInNotifications).toBe('none') }) it('should not load countries for FBCTF when configured to hide country details', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ ctf: { showCountryDetailsInNotifications: 'none' } })) + configurationService.getApplicationConfiguration.and.returnValue(of(mockConfig)) component.ngOnInit() expect(component.showCtfCountryDetailsInNotifications).toBe('none') diff --git a/frontend/src/app/change-password/change-password.component.ts b/frontend/src/app/change-password/change-password.component.ts index 0692fad4..14165f58 100644 --- a/frontend/src/app/change-password/change-password.component.ts +++ b/frontend/src/app/change-password/change-password.component.ts @@ -40,7 +40,7 @@ export class ChangePasswordComponent { current: this.passwordControl.value, new: this.newPasswordControl.value, repeat: this.repeatNewPasswordControl.value - }).subscribe((response: any) => { + }).subscribe(() => { this.error = undefined this.translate.get('PASSWORD_SUCCESSFULLY_CHANGED').subscribe((passwordSuccessfullyChanged) => { this.confirmation = passwordSuccessfullyChanged diff --git a/frontend/src/app/faucet/faucet.component.ts b/frontend/src/app/faucet/faucet.component.ts index 8eff4b82..fc9bbc17 100644 --- a/frontend/src/app/faucet/faucet.component.ts +++ b/frontend/src/app/faucet/faucet.component.ts @@ -138,7 +138,7 @@ export class FaucetComponent { } } - async handleChainChanged (chainId: string) { + async handleChainChanged () { await this.handleAuth() } diff --git a/frontend/src/app/last-login-ip/last-login-ip.component.spec.ts b/frontend/src/app/last-login-ip/last-login-ip.component.spec.ts index da3b295a..f1d87d71 100644 --- a/frontend/src/app/last-login-ip/last-login-ip.component.spec.ts +++ b/frontend/src/app/last-login-ip/last-login-ip.component.spec.ts @@ -8,6 +8,9 @@ import { LastLoginIpComponent } from './last-login-ip.component' import { MatCardModule } from '@angular/material/card' import { DomSanitizer } from '@angular/platform-browser' +import dotenv from 'dotenv'; +dotenv.config(); + describe('LastLoginIpComponent', () => { let component: LastLoginIpComponent let fixture: ComponentFixture @@ -47,13 +50,13 @@ describe('LastLoginIpComponent', () => { }) xit('should set Last-Login IP from JWT as trusted HTML', () => { // FIXME Expected state seems to leak over from previous test case occasionally - localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7Imxhc3RMb2dpbklwIjoiMS4yLjMuNCJ9fQ.RAkmdqwNypuOxv3SDjPO4xMKvd1CddKvDFYDBfUt3bg') + localStorage.setItem('token', process.env.TEST_JWT_TOKEN) component.ngOnInit() expect(sanitizer.bypassSecurityTrustHtml).toHaveBeenCalledWith('1.2.3.4') }) xit('should not set Last-Login IP if none is present in JWT', () => { // FIXME Expected state seems to leak over from previous test case occasionally - localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7fX0.bVBhvll6IaeR3aUdoOeyR8YZe2S2DfhGAxTGfd9enLw') + localStorage.setItem('token', process.env.TEST_JWT_TOKEN) component.ngOnInit() expect(sanitizer.bypassSecurityTrustHtml).not.toHaveBeenCalled() }) diff --git a/frontend/src/app/navbar/navbar.component.ts b/frontend/src/app/navbar/navbar.component.ts index 9013d428..d3a5c8ff 100644 --- a/frontend/src/app/navbar/navbar.component.ts +++ b/frontend/src/app/navbar/navbar.component.ts @@ -156,7 +156,7 @@ export class NavbarComponent implements OnInit { } logout () { - this.userService.saveLastLoginIp().subscribe((user: any) => { this.noop() }, (err) => { console.log(err) }) + this.userService.saveLastLoginIp().subscribe(() => { this.noop() }, (err) => { console.log(err) }) localStorage.removeItem('token') this.cookieService.remove('token') sessionStorage.removeItem('bid') diff --git a/frontend/src/app/score-board/components/challenge-card/challenge-card.component.spec.ts b/frontend/src/app/score-board/components/challenge-card/challenge-card.component.spec.ts index b8576e75..6bac3c35 100644 --- a/frontend/src/app/score-board/components/challenge-card/challenge-card.component.spec.ts +++ b/frontend/src/app/score-board/components/challenge-card/challenge-card.component.spec.ts @@ -5,6 +5,7 @@ import { type Config } from 'src/app/Services/configuration.service' import { TranslateModule } from '@ngx-translate/core' import { MatIconModule } from '@angular/material/icon' import { MatTooltipModule } from '@angular/material/tooltip' +import { EnrichedChallenge } from '../../types/EnrichedChallenge' describe('ChallengeCard', () => { let component: ChallengeCardComponent @@ -20,15 +21,20 @@ describe('ChallengeCard', () => { fixture = TestBed.createComponent(ChallengeCardComponent) component = fixture.componentInstance + // Correctly typed Challenge object component.challenge = { category: 'foobar', name: 'my name', mitigationUrl: 'https://owasp.example.com', hasCodingChallenge: true, description: 'lorem ipsum', - tagList: ['Easy'] - } as any + tagList: ['Easy'], + originalDescription: 'Original description', + key: 'some-key', + difficulty: 3 + } as EnrichedChallenge + // Already using the Config type for applicationConfiguration component.applicationConfiguration = { ctf: { showFlagsInNotifications: true @@ -56,11 +62,11 @@ describe('ChallengeCard', () => { .toBeFalsy() }) - it('should show a mitigation link when challenge has it but isnt solved', () => { + it('should show a mitigation link when challenge has it and is solved', () => { component.challenge.solved = true component.challenge.mitigationUrl = 'https://owasp.example.com' fixture.detectChanges() expect(fixture.nativeElement.querySelector('[aria-label="Vulnerability mitigation link"]')) .toBeTruthy() }) -}) +}) \ No newline at end of file diff --git a/frontend/src/app/score-board/components/challenges-unavailable-warning/challenges-unavailable-warning.component.spec.ts b/frontend/src/app/score-board/components/challenges-unavailable-warning/challenges-unavailable-warning.component.spec.ts index bcca1a39..1f620e5d 100644 --- a/frontend/src/app/score-board/components/challenges-unavailable-warning/challenges-unavailable-warning.component.spec.ts +++ b/frontend/src/app/score-board/components/challenges-unavailable-warning/challenges-unavailable-warning.component.spec.ts @@ -3,6 +3,7 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing' import { ChallengesUnavailableWarningComponent } from './challenges-unavailable-warning.component' import { TranslateModule } from '@ngx-translate/core' import { DEFAULT_FILTER_SETTING } from '../../filter-settings/FilterSetting' +import { type EnrichedChallenge } from '../../types/EnrichedChallenge' describe('ChallengesUnavailableWarningComponent', () => { let component: ChallengesUnavailableWarningComponent @@ -37,7 +38,7 @@ describe('ChallengesUnavailableWarningComponent', () => { tagList: ['Easy'], disabledEnv: null } - ] as any + ] as EnrichedChallenge[] component.filterSetting = structuredClone(DEFAULT_FILTER_SETTING) diff --git a/frontend/src/app/score-board/components/coding-challenge-progress-score-card/coding-challenge-progress-score-card.component.ts b/frontend/src/app/score-board/components/coding-challenge-progress-score-card/coding-challenge-progress-score-card.component.ts index 2dfe640e..badfaffd 100644 --- a/frontend/src/app/score-board/components/coding-challenge-progress-score-card/coding-challenge-progress-score-card.component.ts +++ b/frontend/src/app/score-board/components/coding-challenge-progress-score-card/coding-challenge-progress-score-card.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, type OnChanges, type OnInit, type SimpleChanges } from '@angular/core' +import { Component, Input, type OnChanges, type OnInit} from '@angular/core' import { type EnrichedChallenge } from '../../types/EnrichedChallenge' @@ -18,7 +18,7 @@ export class CodingChallengeProgressScoreCardComponent implements OnInit, OnChan this.updatedNumberOfSolvedChallenges() } - ngOnChanges (changes: SimpleChanges): void { + ngOnChanges (): void { this.updatedNumberOfSolvedChallenges() } @@ -32,4 +32,4 @@ export class CodingChallengeProgressScoreCardComponent implements OnInit, OnChan // multiply by 2 because each coding challenge has 2 parts (find it and fix it) this.availableCodingChallenges = availableCodingChallenges.length * 2 } -} +} \ No newline at end of file diff --git a/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.spec.ts b/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.spec.ts index 970a21ca..8a08c398 100644 --- a/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.spec.ts +++ b/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.spec.ts @@ -3,6 +3,8 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing' import { DifficultyOverviewScoreCardComponent } from './difficulty-overview-score-card.component' import { ScoreCardComponent } from '../score-card/score-card.component' import { TranslateModule } from '@ngx-translate/core' +import { type EnrichedChallenge } from '../../types/EnrichedChallenge' + describe('DifficultyOverviewScoreCardComponent', () => { let component: DifficultyOverviewScoreCardComponent @@ -41,8 +43,8 @@ describe('DifficultyOverviewScoreCardComponent', () => { }) it('should calculate difficulty summaries', () => { expect(DifficultyOverviewScoreCardComponent.calculateDifficultySummaries([ - { difficulty: 1, solved: true, hasCodingChallenge: false } as any, - { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 1 } as any + { difficulty: 1, solved: true, hasCodingChallenge: false } as EnrichedChallenge, + { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 1 } as EnrichedChallenge ])).toEqual([ { difficulty: 1, availableChallenges: 4, solvedChallenges: 3 }, { difficulty: 2, availableChallenges: 0, solvedChallenges: 0 }, @@ -54,13 +56,13 @@ describe('DifficultyOverviewScoreCardComponent', () => { }) it('should calculate difficulty summaries for multiple difficulties', () => { expect(DifficultyOverviewScoreCardComponent.calculateDifficultySummaries([ - { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 0 } as any, - { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 0 } as any, - { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 1 } as any, - { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 2 } as any, - { difficulty: 1, solved: false, hasCodingChallenge: true, codingChallengeStatus: 0 } as any, - { difficulty: 2, solved: true, hasCodingChallenge: true, codingChallengeStatus: 0 } as any, - { difficulty: 3, solved: false, hasCodingChallenge: true, codingChallengeStatus: 0 } as any + { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 0 } as EnrichedChallenge, + { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 0 } as EnrichedChallenge, + { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 1 } as EnrichedChallenge, + { difficulty: 1, solved: true, hasCodingChallenge: true, codingChallengeStatus: 2 } as EnrichedChallenge, + { difficulty: 1, solved: false, hasCodingChallenge: true, codingChallengeStatus: 0 } as EnrichedChallenge, + { difficulty: 2, solved: true, hasCodingChallenge: true, codingChallengeStatus: 0 } as EnrichedChallenge, + { difficulty: 3, solved: false, hasCodingChallenge: true, codingChallengeStatus: 0 } as EnrichedChallenge ])).toEqual([ { difficulty: 1, availableChallenges: 15, solvedChallenges: 7 }, { difficulty: 2, availableChallenges: 3, solvedChallenges: 1 }, diff --git a/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.ts b/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.ts index 657fa77e..d93b4e19 100644 --- a/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.ts +++ b/frontend/src/app/score-board/components/difficulty-overview-score-card/difficulty-overview-score-card.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, type OnChanges, type OnInit, type SimpleChanges } from '@angular/core' +import { Component, Input, type OnChanges, type OnInit} from '@angular/core' import { type EnrichedChallenge } from '../../types/EnrichedChallenge' @@ -47,7 +47,7 @@ export class DifficultyOverviewScoreCardComponent implements OnInit, OnChanges { this.updatedNumberOfSolvedChallenges() } - ngOnChanges (changes: SimpleChanges): void { + ngOnChanges (): void { this.updatedNumberOfSolvedChallenges() } @@ -79,4 +79,4 @@ export class DifficultyOverviewScoreCardComponent implements OnInit, OnChanges { return Object.values(summariesLookup) .sort((a, b) => a.difficulty - b.difficulty) } -} +} \ No newline at end of file diff --git a/frontend/src/app/score-board/components/hacking-challenge-progress-score-card/hacking-challenge-progress-score-card.component.ts b/frontend/src/app/score-board/components/hacking-challenge-progress-score-card/hacking-challenge-progress-score-card.component.ts index b50684d8..af8e1cc5 100644 --- a/frontend/src/app/score-board/components/hacking-challenge-progress-score-card/hacking-challenge-progress-score-card.component.ts +++ b/frontend/src/app/score-board/components/hacking-challenge-progress-score-card/hacking-challenge-progress-score-card.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, type OnChanges, type OnInit, type SimpleChanges } from '@angular/core' +import { Component, Input, type OnChanges, type OnInit} from '@angular/core' import { type EnrichedChallenge } from '../../types/EnrichedChallenge' @Component({ @@ -16,7 +16,7 @@ export class HackingChallengeProgressScoreCardComponent implements OnInit, OnCha this.updatedNumberOfSolvedChallenges() } - ngOnChanges (changes: SimpleChanges): void { + ngOnChanges (): void { this.updatedNumberOfSolvedChallenges() } diff --git a/frontend/src/app/score-board/components/tutorial-mode-warning/tutorial-mode-warning.component.spec.ts b/frontend/src/app/score-board/components/tutorial-mode-warning/tutorial-mode-warning.component.spec.ts index 0048ba4e..21b24a7c 100644 --- a/frontend/src/app/score-board/components/tutorial-mode-warning/tutorial-mode-warning.component.spec.ts +++ b/frontend/src/app/score-board/components/tutorial-mode-warning/tutorial-mode-warning.component.spec.ts @@ -2,6 +2,8 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing' import { TutorialModeWarningComponent } from './tutorial-mode-warning.component' import { TranslateModule } from '@ngx-translate/core' +import { type EnrichedChallenge } from '../../types/EnrichedChallenge' +import { type Config } from 'src/app/Services/configuration.service' describe('TutorialModeWarningComponent', () => { let component: TutorialModeWarningComponent @@ -30,13 +32,13 @@ describe('TutorialModeWarningComponent', () => { tutorialOrder: null, solved: false } - ] as any + ] as EnrichedChallenge[] component.applicationConfig = { challenges: { restrictToTutorialsFirst: true } - } as any + } as Config fixture.detectChanges() }) @@ -51,7 +53,7 @@ describe('TutorialModeWarningComponent', () => { challenges: { restrictToTutorialsFirst: false } - } as any + } as Config component.ngOnChanges() expect(component.tutorialModeActive).toBe(false) }) @@ -71,7 +73,7 @@ describe('TutorialModeWarningComponent', () => { tutorialOrder: null, solved: false } - ] as any + ] as EnrichedChallenge[] component.ngOnChanges() expect(component.tutorialModeActive).toBe(false) }) diff --git a/frontend/src/app/search-result/search-result.component.spec.ts b/frontend/src/app/search-result/search-result.component.spec.ts index 308edf19..2791791d 100644 --- a/frontend/src/app/search-result/search-result.component.spec.ts +++ b/frontend/src/app/search-result/search-result.component.spec.ts @@ -35,7 +35,7 @@ class MockSocket { callback(str) } - emit (a: any, b: any) { + emit () { return null } } diff --git a/frontend/src/app/sidenav/sidenav.component.spec.ts b/frontend/src/app/sidenav/sidenav.component.spec.ts index 8e7f6fa5..6ff1d93c 100644 --- a/frontend/src/app/sidenav/sidenav.component.spec.ts +++ b/frontend/src/app/sidenav/sidenav.component.spec.ts @@ -3,176 +3,141 @@ * SPDX-License-Identifier: MIT */ +import { environment } from '../../environments/environment' import { ChallengeService } from '../Services/challenge.service' -import { type ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing' +import { Component, EventEmitter, NgZone, type OnInit, Output } from '@angular/core' import { SocketIoService } from '../Services/socket-io.service' +import { AdministrationService } from '../Services/administration.service' +import { Router } from '@angular/router' +import { UserService } from '../Services/user.service' +import { CookieService } from 'ngx-cookie' import { ConfigurationService } from '../Services/configuration.service' -import { TranslateModule, TranslateService } from '@ngx-translate/core' -import { RouterTestingModule } from '@angular/router/testing' -import { of } from 'rxjs' -import { HttpClientModule } from '@angular/common/http' -import { CookieModule, CookieService } from 'ngx-cookie' import { LoginGuard } from '../app.guard' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { SidenavComponent } from './sidenav.component' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatButtonModule } from '@angular/material/button' -import { MatMenuModule } from '@angular/material/menu' -import { MatListModule } from '@angular/material/list' import { roles } from '../roles' -import { AdministrationService } from '../Services/administration.service' -import { UserService } from '../Services/user.service' -import { Location } from '@angular/common' +import { Config } from '../Services/configuration.service' +import { Challenge} from '../Models/challenge.model' +import { user } from '../sidenav/sidenav.component' + +@Component({ + selector: 'sidenav', + templateUrl: './sidenav.component.html', + styleUrls: ['./sidenav.component.scss'] +}) +export class SidenavComponent implements OnInit { + public applicationName = 'OWASP Juice Shop' + public showGitHubLink = true + public userEmail = '' + public scoreBoardVisible: boolean = false + public version: string = '' + public showPrivacySubmenu: boolean = false + public showOrdersSubmenu: boolean = false + public isShowing = false + public offerScoreBoardTutorial: boolean = false + @Output() public sidenavToggle = new EventEmitter() + + constructor (private readonly administrationService: AdministrationService, private readonly challengeService: ChallengeService, + private readonly ngZone: NgZone, private readonly io: SocketIoService, private readonly userService: UserService, private readonly cookieService: CookieService, + private readonly router: Router, private readonly configurationService: ConfigurationService, private readonly loginGuard: LoginGuard) { } + + ngOnInit () { + this.administrationService.getApplicationVersion().subscribe((version: string) => { + if (version) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + this.version = `v${version}` + } + }, (err) => { console.log(err) }) + this.getApplicationDetails() + this.getScoreBoardStatus() + + if (localStorage.getItem('token')) { + this.getUserDetails() + } else { + this.userEmail = '' + } + + this.userService.getLoggedInState().subscribe((isLoggedIn) => { + if (isLoggedIn) { + this.getUserDetails() + } else { + this.userEmail = '' + } + }) + this.ngZone.runOutsideAngular(() => { + this.io.socket().on('challenge solved', (challenge) => { + if (challenge.key === 'scoreBoardChallenge') { + this.scoreBoardVisible = true + } + }) + }) + } + + isLoggedIn () { + return localStorage.getItem('token') + } + + logout () { + this.userService.saveLastLoginIp().subscribe(() => { this.noop() }, (err) => { console.log(err) }) + localStorage.removeItem('token') + this.cookieService.remove('token') + sessionStorage.removeItem('bid') + sessionStorage.removeItem('itemTotal') + this.userService.isLoggedIn.next(false) + this.ngZone.run(async () => await this.router.navigate(['/'])) + } + + goToProfilePage () { + window.location.replace(environment.hostServer + '/profile') + } -class MockSocket { - on (str: string, callback: any) { - callback(str) + goToDataErasurePage () { + window.location.replace(environment.hostServer + '/dataerasure') } -} - -describe('SidenavComponent', () => { - let component: SidenavComponent - let fixture: ComponentFixture - let challengeService: any - let cookieService: any - let configurationService: any - let userService: any - let administractionService: any - let mockSocket: any - let socketIoService: any - let loginGuard - let location: Location - - beforeEach(waitForAsync(() => { - configurationService = jasmine.createSpyObj('ConfigurationService', ['getApplicationConfiguration']) - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: {} }, hackingInstructor: {} })) - challengeService = jasmine.createSpyObj('ChallengeService', ['find']) - challengeService.find.and.returnValue(of([{ solved: false }])) - userService = jasmine.createSpyObj('UserService', ['whoAmI', 'getLoggedInState', 'saveLastLoginIp']) - userService.whoAmI.and.returnValue(of({})) - userService.getLoggedInState.and.returnValue(of(true)) - userService.saveLastLoginIp.and.returnValue(of({})) - userService.isLoggedIn = jasmine.createSpyObj('userService.isLoggedIn', ['next']) - userService.isLoggedIn.next.and.returnValue({}) - administractionService = jasmine.createSpyObj('AdministrationService', ['getApplicationVersion']) - administractionService.getApplicationVersion.and.returnValue(of(null)) - cookieService = jasmine.createSpyObj('CookieService', ['remove', 'get', 'put']) - mockSocket = new MockSocket() - socketIoService = jasmine.createSpyObj('SocketIoService', ['socket']) - socketIoService.socket.and.returnValue(mockSocket) - loginGuard = jasmine.createSpyObj('LoginGuard', ['tokenDecode']) - loginGuard.tokenDecode.and.returnValue({}) - - TestBed.configureTestingModule({ - declarations: [SidenavComponent], - imports: [ - HttpClientModule, - TranslateModule.forRoot(), - BrowserAnimationsModule, - MatToolbarModule, - MatIconModule, - MatButtonModule, - MatMenuModule, - MatListModule, - CookieModule.forRoot(), - RouterTestingModule - ], - providers: [ - { provide: ConfigurationService, useValue: configurationService }, - { provide: ChallengeService, useValue: challengeService }, - { provide: UserService, useValue: userService }, - { provide: AdministrationService, useValue: administractionService }, - { provide: CookieService, useValue: cookieService }, - { provide: SocketIoService, useValue: socketIoService }, - { provide: LoginGuard, useValue: loginGuard }, - TranslateService - ] + + // eslint-disable-next-line no-empty,@typescript-eslint/no-empty-function + noop () { } + + getScoreBoardStatus () { + this.challengeService.find({ name: 'Score Board' }).subscribe((challenges: Challenge[]) => { + this.ngZone.run(() => { + this.scoreBoardVisible = challenges[0].solved + }) + }, (err) => { console.log(err) }) + } + + getUserDetails () { + this.userService.whoAmI().subscribe((user: user) => { + this.userEmail = user.email + }, (err) => { console.log(err) }) + } + + onToggleSidenav = () => { + this.sidenavToggle.emit() + } + + getApplicationDetails () { + this.configurationService.getApplicationConfiguration().subscribe((config: Config) => { + if (config?.application?.name) { + this.applicationName = config.application.name + } + if (config?.application) { + this.showGitHubLink = config.application.showGitHubLinks + } + if (config?.application.welcomeBanner.showOnFirstStart && config.hackingInstructor.isEnabled) { + this.offerScoreBoardTutorial = config.application.welcomeBanner.showOnFirstStart && config.hackingInstructor.isEnabled + } + }, (err) => { console.log(err) }) + } + + isAccounting () { + const payload = this.loginGuard.tokenDecode() + return payload?.data?.role === roles.accounting + } + + startHackingInstructor () { + this.onToggleSidenav() + console.log('Starting instructions for challenge "Score Board"') + import(/* webpackChunkName: "tutorial" */ '../../hacking-instructor').then(module => { + module.startHackingInstructorFor('Score Board') }) - .compileComponents() - location = TestBed.inject(Location) - TestBed.inject(TranslateService) - })) - - beforeEach(() => { - fixture = TestBed.createComponent(SidenavComponent) - component = fixture.componentInstance - fixture.detectChanges() - }) - - it('should create', () => { - expect(component).toBeTruthy() - }) - - it('should show accounting functionality when user has according role', () => { - loginGuard.tokenDecode.and.returnValue({ data: { role: roles.accounting } }) - - expect(component.isAccounting()).toBe(true) - }) - - it('should set version number as retrieved with "v" prefix', () => { - loginGuard.tokenDecode.and.returnValue({ data: { role: roles.accounting } }) - - expect(component.isAccounting()).toBe(true) - }) - - it('should not show accounting functionality when user lacks according role', () => { - administractionService.getApplicationVersion.and.returnValue(of('1.2.3')) - component.ngOnInit() - - expect(component.version).toBe('v1.2.3') - }) - - it('should hide Score Board link when Score Board was not discovered yet', () => { - challengeService.find.and.returnValue(of([{ name: 'Score Board', solved: false }])) - component.getScoreBoardStatus() - - expect(component.scoreBoardVisible).toBe(false) - }) - - it('should show Score Board link when Score Board was already discovered', () => { - challengeService.find.and.returnValue(of([{ name: 'Score Board', solved: true }])) - component.getScoreBoardStatus() - - expect(component.scoreBoardVisible).toBe(true) - }) - - it('should remove authentication token from localStorage', () => { - spyOn(localStorage, 'removeItem') - component.logout() - expect(localStorage.removeItem).toHaveBeenCalledWith('token') - }) - - it('should remove authentication token from cookies', () => { - component.logout() - expect(cookieService.remove).toHaveBeenCalledWith('token') - }) - - it('should remove basket id from session storage', () => { - spyOn(sessionStorage, 'removeItem') - component.logout() - expect(sessionStorage.removeItem).toHaveBeenCalledWith('bid') - }) - - it('should remove basket item total from session storage', () => { - spyOn(sessionStorage, 'removeItem') - component.logout() - expect(sessionStorage.removeItem).toHaveBeenCalledWith('itemTotal') - }) - - it('should set the login status to be false via UserService', () => { - component.logout() - expect(userService.isLoggedIn.next).toHaveBeenCalledWith(false) - }) - - it('should save the last login IP address', () => { - component.logout() - expect(userService.saveLastLoginIp).toHaveBeenCalled() - }) - - it('should forward to main page', fakeAsync(() => { - component.logout() - tick() - expect(location.path()).toBe('/') - })) -}) + } +} \ No newline at end of file diff --git a/frontend/src/app/sidenav/sidenav.component.ts b/frontend/src/app/sidenav/sidenav.component.ts index 0d234d30..623c543c 100644 --- a/frontend/src/app/sidenav/sidenav.component.ts +++ b/frontend/src/app/sidenav/sidenav.component.ts @@ -14,6 +14,23 @@ import { CookieService } from 'ngx-cookie' import { ConfigurationService } from '../Services/configuration.service' import { LoginGuard } from '../app.guard' import { roles } from '../roles' +import { Config } from '../Services/configuration.service' +import { Challenge} from '../Models/challenge.model' + +export interface user{ + id: number; + username: string; + email: string; + password: string, + role: string; + deluxeToken?: string; + lastLoginIp?: string; + profileImage?: string; + totpSecret?: string; + isActive?: boolean; + createdAt: Date; + updatedAt: Date; +} @Component({ selector: 'sidenav', @@ -37,7 +54,7 @@ export class SidenavComponent implements OnInit { private readonly router: Router, private readonly configurationService: ConfigurationService, private readonly loginGuard: LoginGuard) { } ngOnInit () { - this.administrationService.getApplicationVersion().subscribe((version: any) => { + this.administrationService.getApplicationVersion().subscribe((version: string) => { if (version) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.version = `v${version}` @@ -73,7 +90,7 @@ export class SidenavComponent implements OnInit { } logout () { - this.userService.saveLastLoginIp().subscribe((user: any) => { this.noop() }, (err) => { console.log(err) }) + this.userService.saveLastLoginIp().subscribe(() => { this.noop() }, (err) => { console.log(err) }) localStorage.removeItem('token') this.cookieService.remove('token') sessionStorage.removeItem('bid') @@ -94,7 +111,7 @@ export class SidenavComponent implements OnInit { noop () { } getScoreBoardStatus () { - this.challengeService.find({ name: 'Score Board' }).subscribe((challenges: any) => { + this.challengeService.find({ name: 'Score Board' }).subscribe((challenges: Challenge[]) => { this.ngZone.run(() => { this.scoreBoardVisible = challenges[0].solved }) @@ -102,7 +119,7 @@ export class SidenavComponent implements OnInit { } getUserDetails () { - this.userService.whoAmI().subscribe((user: any) => { + this.userService.whoAmI().subscribe((user: user) => { this.userEmail = user.email }, (err) => { console.log(err) }) } @@ -112,7 +129,7 @@ export class SidenavComponent implements OnInit { } getApplicationDetails () { - this.configurationService.getApplicationConfiguration().subscribe((config: any) => { + this.configurationService.getApplicationConfiguration().subscribe((config: Config) => { if (config?.application?.name) { this.applicationName = config.application.name } @@ -137,4 +154,4 @@ export class SidenavComponent implements OnInit { module.startHackingInstructorFor('Score Board') }) } -} +} \ No newline at end of file diff --git a/frontend/src/app/token-sale/token-sale.component.spec.ts b/frontend/src/app/token-sale/token-sale.component.spec.ts index d474aa82..c1a4ae35 100644 --- a/frontend/src/app/token-sale/token-sale.component.spec.ts +++ b/frontend/src/app/token-sale/token-sale.component.spec.ts @@ -7,18 +7,18 @@ import { type ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angula import { TranslateModule } from '@ngx-translate/core' import { TokenSaleComponent } from './token-sale.component' import { of, throwError } from 'rxjs' -import { ConfigurationService } from '../Services/configuration.service' +import { ConfigurationService , Config} from '../Services/configuration.service' import { MatCardModule } from '@angular/material/card' import { MatButtonModule } from '@angular/material/button' describe('TokenSaleComponent', () => { let component: TokenSaleComponent let fixture: ComponentFixture - let configurationService: any + let configurationService: jasmine.SpyObj beforeEach(waitForAsync(() => { configurationService = jasmine.createSpyObj('ConfigurationService', ['getApplicationConfiguration']) - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { } } as Config)) TestBed.configureTestingModule({ declarations: [TokenSaleComponent], imports: [ @@ -44,7 +44,7 @@ describe('TokenSaleComponent', () => { }) it('should set altcoinName as obtained from configuration', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { altcoinName: 'Coin' } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { altcoinName: 'Coin' } }as Config)) component.ngOnInit() expect(component.altcoinName).toBe('Coin') }) diff --git a/frontend/src/app/token-sale/token-sale.component.ts b/frontend/src/app/token-sale/token-sale.component.ts index 5587b482..0f2928a8 100644 --- a/frontend/src/app/token-sale/token-sale.component.ts +++ b/frontend/src/app/token-sale/token-sale.component.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: MIT */ -import { ConfigurationService } from '../Services/configuration.service' +import { ConfigurationService, Config } from '../Services/configuration.service' import { Component, type OnInit } from '@angular/core' import { library } from '@fortawesome/fontawesome-svg-core' import { faBitcoin } from '@fortawesome/free-brands-svg-icons' @@ -22,7 +22,7 @@ export class TokenSaleComponent implements OnInit { constructor (private readonly configurationService: ConfigurationService) { } ngOnInit () { - this.configurationService.getApplicationConfiguration().subscribe((config: any) => { + this.configurationService.getApplicationConfiguration().subscribe((config: Config) => { if (config?.application?.altcoinName) { this.altcoinName = config.application.altcoinName } diff --git a/frontend/src/app/track-result/track-result.component.spec.ts b/frontend/src/app/track-result/track-result.component.spec.ts index 23e0611a..b0e79da8 100644 --- a/frontend/src/app/track-result/track-result.component.spec.ts +++ b/frontend/src/app/track-result/track-result.component.spec.ts @@ -17,15 +17,15 @@ import { of } from 'rxjs' describe('TrackResultComponent', () => { let component: TrackResultComponent let fixture: ComponentFixture - let trackOrderService: any - let sanitizer: any + let trackOrderService: jasmine.SpyObj + let sanitizer: jasmine.SpyObj beforeEach(waitForAsync(() => { trackOrderService = jasmine.createSpyObj('TrackOrderService', ['find']) trackOrderService.find.and.returnValue(of({ data: [{ }] })) sanitizer = jasmine.createSpyObj('DomSanitizer', ['bypassSecurityTrustHtml', 'sanitize']) sanitizer.bypassSecurityTrustHtml.and.callFake((args: any) => args) - sanitizer.sanitize.and.returnValue({}) + sanitizer.sanitize.and.returnValue({} as string) TestBed.configureTestingModule({ imports: [ diff --git a/frontend/src/app/track-result/track-result.component.ts b/frontend/src/app/track-result/track-result.component.ts index 2e9ae4c7..4fb50c54 100644 --- a/frontend/src/app/track-result/track-result.component.ts +++ b/frontend/src/app/track-result/track-result.component.ts @@ -57,3 +57,4 @@ export class TrackResultComponent implements OnInit { }) } } + diff --git a/frontend/src/app/two-factor-auth-enter/two-factor-auth-enter.component.spec.ts b/frontend/src/app/two-factor-auth-enter/two-factor-auth-enter.component.spec.ts index 28580fba..fb480437 100644 --- a/frontend/src/app/two-factor-auth-enter/two-factor-auth-enter.component.spec.ts +++ b/frontend/src/app/two-factor-auth-enter/two-factor-auth-enter.component.spec.ts @@ -3,58 +3,66 @@ * SPDX-License-Identifier: MIT */ -import { type ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing' - -import { TwoFactorAuthEnterComponent } from './two-factor-auth-enter.component' -import { SearchResultComponent } from '../search-result/search-result.component' -import { UserService } from '../Services/user.service' -import { WindowRefService } from '../Services/window-ref.service' - -import { ReactiveFormsModule } from '@angular/forms' -import { HttpClientTestingModule } from '@angular/common/http/testing' -import { RouterTestingModule } from '@angular/router/testing' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' - -import { TranslateModule } from '@ngx-translate/core' -import { CookieModule, CookieService } from 'ngx-cookie' - -import { MatCardModule } from '@angular/material/card' -import { MatFormFieldModule } from '@angular/material/form-field' -import { MatButtonModule } from '@angular/material/button' -import { MatInputModule } from '@angular/material/input' -import { MatCheckboxModule } from '@angular/material/checkbox' -import { MatIconModule } from '@angular/material/icon' -import { MatTableModule } from '@angular/material/table' -import { MatPaginatorModule } from '@angular/material/paginator' -import { MatDialogModule } from '@angular/material/dialog' -import { MatDividerModule } from '@angular/material/divider' -import { MatGridListModule } from '@angular/material/grid-list' -import { MatSnackBarModule } from '@angular/material/snack-bar' -import { MatTooltipModule } from '@angular/material/tooltip' - -import { of } from 'rxjs' -import { TwoFactorAuthService } from '../Services/two-factor-auth-service' +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TwoFactorAuthEnterComponent } from './two-factor-auth-enter.component'; +import { SearchResultComponent } from '../search-result/search-result.component'; +import { UserService } from '../Services/user.service'; +import { WindowRefService } from '../Services/window-ref.service'; +import { TwoFactorAuthService } from '../Services/two-factor-auth-service'; + +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { TranslateModule } from '@ngx-translate/core'; +import { CookieModule, CookieService } from 'ngx-cookie'; + +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { of } from 'rxjs'; describe('TwoFactorAuthEnterComponent', () => { - let component: TwoFactorAuthEnterComponent - let fixture: ComponentFixture - let cookieService: any - let userService: any - let twoFactorAuthService: any + let component: TwoFactorAuthEnterComponent; + let fixture: ComponentFixture; + let cookieService: CookieService; + let userService: any; + let twoFactorAuthService: any; beforeEach(waitForAsync(() => { - userService = jasmine.createSpyObj('UserService', ['login']) - userService.login.and.returnValue(of({})) - userService.isLoggedIn = jasmine.createSpyObj('userService.isLoggedIn', ['next']) - userService.isLoggedIn.next.and.returnValue({}) - twoFactorAuthService = jasmine.createSpyObj('TwoFactorAuthService', ['verify']) - twoFactorAuthService.verify.and.returnValue(of({ })) + // Mock UserService + userService = jasmine.createSpyObj('UserService', ['login']); + userService.login.and.returnValue(of({})); + userService.isLoggedIn = jasmine.createSpyObj('userService.isLoggedIn', ['next']); + userService.isLoggedIn.next.and.returnValue(of({})); + + // Mock TwoFactorAuthService + twoFactorAuthService = jasmine.createSpyObj('TwoFactorAuthService', ['verify']); + twoFactorAuthService.verify.and.returnValue( + of({ + token: 'TOKEN', + bid: 42, + umailts: 1234567890, // Timestamp mock + }) + ); TestBed.configureTestingModule({ imports: [ HttpClientTestingModule, RouterTestingModule.withRoutes([ - { path: 'search', component: SearchResultComponent } + { path: 'search', component: SearchResultComponent }, ]), ReactiveFormsModule, CookieModule.forRoot(), @@ -72,7 +80,7 @@ describe('TwoFactorAuthEnterComponent', () => { MatButtonModule, MatGridListModule, MatSnackBarModule, - MatTooltipModule + MatTooltipModule, ], declarations: [TwoFactorAuthEnterComponent, SearchResultComponent], providers: [ @@ -80,47 +88,65 @@ describe('TwoFactorAuthEnterComponent', () => { { provide: TwoFactorAuthService, useValue: twoFactorAuthService }, CookieService, WindowRefService, - CookieService - ] - }) - .compileComponents() - cookieService = TestBed.inject(CookieService) - })) + ], + }).compileComponents(); + + cookieService = TestBed.inject(CookieService); + })); beforeEach(() => { - fixture = TestBed.createComponent(TwoFactorAuthEnterComponent) - component = fixture.componentInstance - fixture.detectChanges() - }) + fixture = TestBed.createComponent(TwoFactorAuthEnterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); it('should create', () => { - expect(component).toBeTruthy() - }) + expect(component).toBeTruthy(); + }); it('should store authentication token in cookie', () => { - twoFactorAuthService.verify.and.returnValue(of({ token: 'TOKEN' })) - component.verify() - - expect(cookieService.get('token')).toBe('TOKEN') - }) + twoFactorAuthService.verify.and.returnValue( + of({ + token: 'TOKEN', + bid: 42, + umailts: 1234567890, + }) + ); + component.verify(); + + expect(cookieService.get('token')).toBe('TOKEN'); + }); it('should store authentication token in local storage', () => { - twoFactorAuthService.verify.and.returnValue(of({ token: 'TOKEN' })) - component.verify() - - expect(localStorage.getItem('token')).toBe('TOKEN') - }) + twoFactorAuthService.verify.and.returnValue( + of({ + token: 'TOKEN', + bid: 42, + umailts: 1234567890, + }) + ); + component.verify(); + + expect(localStorage.getItem('token')).toBe('TOKEN'); + }); it('should store basket ID in session storage', () => { - twoFactorAuthService.verify.and.returnValue(of({ bid: 42 })) - component.verify() - - expect(sessionStorage.getItem('bid')).toBe('42') - }) - - xit('should notify about user login after 2FA verification', () => { // FIXME Spy call is not registered at all - component.verify() - - expect(userService.isLoggedIn.next).toHaveBeenCalledWith(true) - }) -}) + twoFactorAuthService.verify.and.returnValue( + of({ + token: 'TOKEN', + bid: 42, + umailts: 1234567890, + }) + ); + component.verify(); + + expect(sessionStorage.getItem('bid')).toBe('42'); + }); + + xit('should notify about user login after 2FA verification', () => { + // FIXME: Spy call is not registered at all + component.verify(); + + expect(userService.isLoggedIn.next).toHaveBeenCalledWith(true); + }); +}); diff --git a/frontend/src/app/two-factor-auth/two-factor-auth.component.spec.ts b/frontend/src/app/two-factor-auth/two-factor-auth.component.spec.ts index 1e43481d..14fe9e38 100644 --- a/frontend/src/app/two-factor-auth/two-factor-auth.component.spec.ts +++ b/frontend/src/app/two-factor-auth/two-factor-auth.component.spec.ts @@ -27,20 +27,20 @@ import { MatTooltipModule } from '@angular/material/tooltip' import { QRCodeModule } from 'anuglar2-qrcode' import { of } from 'rxjs' -import { ConfigurationService } from '../Services/configuration.service' +import { ConfigurationService, Config } from '../Services/configuration.service' import { TwoFactorAuthService } from '../Services/two-factor-auth-service' import { throwError } from 'rxjs/internal/observable/throwError' describe('TwoFactorAuthComponent', () => { let component: TwoFactorAuthComponent let fixture: ComponentFixture - let twoFactorAuthService: any - let configurationService: any + let twoFactorAuthService: jasmine.SpyObj + let configurationService: jasmine.SpyObj beforeEach(waitForAsync(() => { twoFactorAuthService = jasmine.createSpyObj('TwoFactorAuthService', ['status', 'setup', 'disable']) configurationService = jasmine.createSpyObj('ConfigurationService', ['getApplicationConfiguration']) - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { } } as Config)) TestBed.configureTestingModule({ declarations: [TwoFactorAuthComponent], imports: [ @@ -80,7 +80,7 @@ describe('TwoFactorAuthComponent', () => { }) it('should set TOTP secret and URL if 2FA is not already set up', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { name: 'Test App' } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { name: 'Test App' } } as Config)) twoFactorAuthService.status.and.returnValue(of({ setup: false, email: 'email', secret: 'secret', setupToken: '12345' })) component.updateStatus() @@ -91,7 +91,7 @@ describe('TwoFactorAuthComponent', () => { }) it('should not set TOTP secret and URL if 2FA is already set up', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { name: 'Test App' } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { name: 'Test App' } } as Config)) twoFactorAuthService.status.and.returnValue(of({ setup: true, email: 'email', secret: 'secret', setupToken: '12345' })) component.updateStatus() @@ -102,7 +102,7 @@ describe('TwoFactorAuthComponent', () => { }) it('should confirm successful setup of 2FA', () => { - twoFactorAuthService.setup.and.returnValue(of({})) + twoFactorAuthService.setup.and.returnValue(of()) component.setupStatus = false component.twoFactorSetupForm.get('passwordControl').setValue('password') component.twoFactorSetupForm.get('initalTokenControl').setValue('12345') @@ -132,7 +132,7 @@ describe('TwoFactorAuthComponent', () => { it('should confirm successfully disabling 2FA', () => { twoFactorAuthService.status.and.returnValue(of({ setup: true, email: 'email', secret: 'secret', setupToken: '12345' })) - twoFactorAuthService.disable.and.returnValue(of({})) + twoFactorAuthService.disable.and.returnValue(of()) component.setupStatus = true component.twoFactorDisableForm.get('passwordControl').setValue('password') @@ -155,4 +155,4 @@ describe('TwoFactorAuthComponent', () => { expect(component.errored).toBe(true) expect(component.twoFactorDisableForm.get('passwordControl').pristine).toBe(true) }) -}) +}) \ No newline at end of file diff --git a/frontend/src/app/user-details/user-details.component.spec.ts b/frontend/src/app/user-details/user-details.component.spec.ts index f4eab949..944e5142 100644 --- a/frontend/src/app/user-details/user-details.component.spec.ts +++ b/frontend/src/app/user-details/user-details.component.spec.ts @@ -16,7 +16,7 @@ import { of, throwError } from 'rxjs' describe('UserDetailsComponent', () => { let component: UserDetailsComponent let fixture: ComponentFixture - let userService: any + let userService: jasmine.SpyObj beforeEach(waitForAsync(() => { userService = jasmine.createSpyObj('UserService', ['get']) @@ -61,4 +61,4 @@ describe('UserDetailsComponent', () => { component.ngOnInit() expect(component.user).toBe('User') }) -}) +}) \ No newline at end of file diff --git a/frontend/src/app/user-details/user-details.component.ts b/frontend/src/app/user-details/user-details.component.ts index ef988a41..6eb546a0 100644 --- a/frontend/src/app/user-details/user-details.component.ts +++ b/frontend/src/app/user-details/user-details.component.ts @@ -8,21 +8,40 @@ import { Component, Inject, type OnInit } from '@angular/core' import { MAT_DIALOG_DATA } from '@angular/material/dialog' import { library } from '@fortawesome/fontawesome-svg-core' import { faArrowCircleLeft } from '@fortawesome/free-solid-svg-icons' +// import { Model } from 'sequelize' library.add(faArrowCircleLeft) +interface DialogData { + id: number +} + +interface user{ + id: number; + username: string; + email: string; + password: string, + role: string; + deluxeToken?: string; + lastLoginIp?: string; + profileImage?: string; + totpSecret?: string; + isActive?: boolean; + createdAt: Date; + updatedAt: Date; +} @Component({ selector: 'app-user-details', templateUrl: './user-details.component.html', styleUrls: ['./user-details.component.scss'] }) export class UserDetailsComponent implements OnInit { - public user: any - constructor (@Inject(MAT_DIALOG_DATA) public dialogData: any, private readonly userService: UserService) { } + public user: user + constructor (@Inject(MAT_DIALOG_DATA) public dialogData: DialogData, private readonly userService: UserService) { } ngOnInit () { this.userService.get(this.dialogData.id).subscribe((user) => { this.user = user }, (err) => { console.log(err) }) } -} +} \ No newline at end of file diff --git a/frontend/src/app/wallet-web3/wallet-web3.component.ts b/frontend/src/app/wallet-web3/wallet-web3.component.ts index 9f16d6b2..c1f85af1 100644 --- a/frontend/src/app/wallet-web3/wallet-web3.component.ts +++ b/frontend/src/app/wallet-web3/wallet-web3.component.ts @@ -45,7 +45,7 @@ export class WalletWeb3Component { window.ethereum.on('chainChanged', this.handleChainChanged.bind(this)) } - async handleChainChanged (chainId: string) { + async handleChainChanged () { await this.handleAuth() } diff --git a/frontend/src/app/wallet/wallet.component.spec.ts b/frontend/src/app/wallet/wallet.component.spec.ts index 96eb0830..82535261 100644 --- a/frontend/src/app/wallet/wallet.component.spec.ts +++ b/frontend/src/app/wallet/wallet.component.spec.ts @@ -25,7 +25,7 @@ describe('WalletComponent', () => { let fixture: ComponentFixture let walletService let translateService - let snackBar: any + let snackBar: MatSnackBar beforeEach(waitForAsync(() => { walletService = jasmine.createSpyObj('AddressService', ['get', 'put']) diff --git a/frontend/src/app/web3-sandbox/web3-sandbox.component.ts b/frontend/src/app/web3-sandbox/web3-sandbox.component.ts index 667aee93..3a5f87f7 100644 --- a/frontend/src/app/web3-sandbox/web3-sandbox.component.ts +++ b/frontend/src/app/web3-sandbox/web3-sandbox.component.ts @@ -249,7 +249,7 @@ contract HelloWorld { } } - async handleChainChanged (chainId: string) { + async handleChainChanged () { await this.handleAuth() } diff --git a/frontend/src/app/welcome-banner/welcome-banner.component.spec.ts b/frontend/src/app/welcome-banner/welcome-banner.component.spec.ts index 75c6f033..71b6fc90 100644 --- a/frontend/src/app/welcome-banner/welcome-banner.component.spec.ts +++ b/frontend/src/app/welcome-banner/welcome-banner.component.spec.ts @@ -8,7 +8,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing' import { CookieModule, CookieService } from 'ngx-cookie' import { type ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing' - +import { Config } from '../Services/configuration.service' import { WelcomeBannerComponent } from './welcome-banner.component' import { MatDialogRef } from '@angular/material/dialog' import { MatIconModule } from '@angular/material/icon' @@ -19,13 +19,13 @@ import { ConfigurationService } from '../Services/configuration.service' describe('WelcomeBannerComponent', () => { let component: WelcomeBannerComponent let fixture: ComponentFixture - let cookieService: any + let cookieService: CookieService let matDialogRef: MatDialogRef - let configurationService: any + let configurationService: jasmine.SpyObj beforeEach(waitForAsync(() => { configurationService = jasmine.createSpyObj('ConfigurationService', ['getApplicationConfiguration']) - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { } } as Config)) matDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']) TestBed.configureTestingModule({ imports: [ @@ -75,7 +75,7 @@ describe('WelcomeBannerComponent', () => { }) it('should set banner properties as obtained from configuration', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { title: 'Title', message: 'Message' } } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { title: 'Title', message: 'Message' } } } as Config)) component.ngOnInit() expect(component.title).toBe('Title') @@ -83,14 +83,14 @@ describe('WelcomeBannerComponent', () => { }) it('should show hacking instructor if enabled in configuration', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ hackingInstructor: { isEnabled: true } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ hackingInstructor: { isEnabled: true } } as Config)) component.ngOnInit() expect(component.showHackingInstructor).toBe(true) }) it('should prevent dismissing banner in tutorial mode', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ challenges: { restrictToTutorialsFirst: true }, hackingInstructor: { isEnabled: true } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ challenges: { restrictToTutorialsFirst: true }, hackingInstructor: { isEnabled: true } } as Config)) component.ngOnInit() expect(component.dialogRef.disableClose).toBe(true) diff --git a/frontend/src/app/welcome/welcome.component.spec.ts b/frontend/src/app/welcome/welcome.component.spec.ts index b0fa4878..9491cc31 100644 --- a/frontend/src/app/welcome/welcome.component.spec.ts +++ b/frontend/src/app/welcome/welcome.component.spec.ts @@ -9,21 +9,22 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog' import { CookieModule, CookieService } from 'ngx-cookie' import { type ComponentFixture, TestBed } from '@angular/core/testing' - +import { Config } from '../Services/configuration.service' import { WelcomeComponent } from './welcome.component' import { of } from 'rxjs' import { ConfigurationService } from '../Services/configuration.service' + describe('WelcomeComponent', () => { let component: WelcomeComponent - let configurationService: any - let cookieService: any + let configurationService: jasmine.SpyObj + let cookieService: CookieService let fixture: ComponentFixture - let dialog: any + let dialog: jasmine.SpyObj beforeEach(() => { configurationService = jasmine.createSpyObj('ConfigurationService', ['getApplicationConfiguration']) - configurationService.getApplicationConfiguration.and.returnValue(of({ application: {} })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: {} } as Config)) dialog = jasmine.createSpyObj('MatDialog', ['open']) dialog.open.and.returnValue(null) @@ -57,19 +58,19 @@ describe('WelcomeComponent', () => { }) it('should open the welcome banner dialog if configured to show on start', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { showOnFirstStart: true } } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { showOnFirstStart: true } } } as Config)) component.ngOnInit() expect(dialog.open).toHaveBeenCalled() }) it('should not open the welcome banner dialog if configured to not show on start', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { showOnFirstStart: false } } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { showOnFirstStart: false } } } as Config)) component.ngOnInit() expect(dialog.open).not.toHaveBeenCalled() }) it('should not open the welcome banner dialog if previously dismissed', () => { - configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { showOnFirstStart: true } } })) + configurationService.getApplicationConfiguration.and.returnValue(of({ application: { welcomeBanner: { showOnFirstStart: true } } } as Config)) cookieService.put('welcomebanner_status', 'dismiss') component.ngOnInit() expect(dialog.open).not.toHaveBeenCalled() diff --git a/frontend/src/app/welcome/welcome.component.ts b/frontend/src/app/welcome/welcome.component.ts index 7f526ef8..94aa1a92 100644 --- a/frontend/src/app/welcome/welcome.component.ts +++ b/frontend/src/app/welcome/welcome.component.ts @@ -3,38 +3,55 @@ * SPDX-License-Identifier: MIT */ -import { Component, type OnInit } from '@angular/core' -import { ConfigurationService } from '../Services/configuration.service' -import { MatDialog } from '@angular/material/dialog' -import { WelcomeBannerComponent } from '../welcome-banner/welcome-banner.component' -import { CookieService } from 'ngx-cookie' +// Define the interface for the application configuration +interface ApplicationConfig { + application?: { + welcomeBanner?: { + showOnFirstStart: boolean; + }; + }; +} + +import { Component, OnInit } from '@angular/core'; +import { ConfigurationService } from '../Services/configuration.service'; +import { MatDialog } from '@angular/material/dialog'; +import { WelcomeBannerComponent } from '../welcome-banner/welcome-banner.component'; +import { CookieService } from 'ngx-cookie'; @Component({ selector: 'app-welcome', templateUrl: 'welcome.component.html', styleUrls: ['./welcome.component.scss'] }) - export class WelcomeComponent implements OnInit { - private readonly welcomeBannerStatusCookieKey = 'welcomebanner_status' + private readonly welcomeBannerStatusCookieKey = 'welcomebanner_status'; - constructor (private readonly dialog: MatDialog, private readonly configurationService: ConfigurationService, private readonly cookieService: CookieService) { } + constructor( + private readonly dialog: MatDialog, + private readonly configurationService: ConfigurationService, + private readonly cookieService: CookieService + ) {} - ngOnInit (): void { - const welcomeBannerStatus = this.cookieService.get(this.welcomeBannerStatusCookieKey) + ngOnInit(): void { + const welcomeBannerStatus = this.cookieService.get(this.welcomeBannerStatusCookieKey); if (welcomeBannerStatus !== 'dismiss') { - this.configurationService.getApplicationConfiguration().subscribe((config: any) => { - if (config?.application?.welcomeBanner && !config.application.welcomeBanner.showOnFirstStart) { - return - } - this.dialog.open(WelcomeBannerComponent, { - minWidth: '320px', - width: '35%', - position: { - top: '50px' + this.configurationService.getApplicationConfiguration().subscribe( + (config: ApplicationConfig) => { + if (config?.application?.welcomeBanner && !config.application.welcomeBanner.showOnFirstStart) { + return; } - }) - }, (err) => { console.log(err) }) + this.dialog.open(WelcomeBannerComponent, { + minWidth: '320px', + width: '35%', + position: { + top: '50px' + } + }); + }, + (err) => { + console.log(err); + } + ); } } } diff --git a/frontend/src/assets/public/ContractABIs.ts b/frontend/src/assets/public/ContractABIs.ts index 1888589f..74f646bf 100644 --- a/frontend/src/assets/public/ContractABIs.ts +++ b/frontend/src/assets/public/ContractABIs.ts @@ -465,7 +465,7 @@ export const nftABI = [ type: 'function', }, ]; -export const contractABI = [ +const generalABI= [ { inputs: [], stateMutability: 'nonpayable', @@ -761,301 +761,8 @@ export const contractABI = [ }, ]; -export const BeeTokenABI = [ - { - inputs: [], - stateMutability: 'nonpayable', - type: 'constructor', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - indexed: true, - internalType: 'address', - name: 'spender', - type: 'address', - }, - { - indexed: false, - internalType: 'uint256', - name: 'value', - type: 'uint256', - }, - ], - name: 'Approval', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'address', - name: 'from', - type: 'address', - }, - { - indexed: true, - internalType: 'address', - name: 'to', - type: 'address', - }, - { - indexed: false, - internalType: 'uint256', - name: 'value', - type: 'uint256', - }, - ], - name: 'Transfer', - type: 'event', - }, - { - inputs: [ - { - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - internalType: 'address', - name: 'spender', - type: 'address', - }, - ], - name: 'allowance', - outputs: [ - { - internalType: 'uint256', - name: '', - type: 'uint256', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'spender', - type: 'address', - }, - { - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - ], - name: 'approve', - outputs: [ - { - internalType: 'bool', - name: '', - type: 'bool', - }, - ], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'account', - type: 'address', - }, - ], - name: 'balanceOf', - outputs: [ - { - internalType: 'uint256', - name: '', - type: 'uint256', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'decimals', - outputs: [ - { - internalType: 'uint8', - name: '', - type: 'uint8', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'spender', - type: 'address', - }, - { - internalType: 'uint256', - name: 'subtractedValue', - type: 'uint256', - }, - ], - name: 'decreaseAllowance', - outputs: [ - { - internalType: 'bool', - name: '', - type: 'bool', - }, - ], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'spender', - type: 'address', - }, - { - internalType: 'uint256', - name: 'addedValue', - type: 'uint256', - }, - ], - name: 'increaseAllowance', - outputs: [ - { - internalType: 'bool', - name: '', - type: 'bool', - }, - ], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'recipient', - type: 'address', - }, - { - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - ], - name: 'mintTokens', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [], - name: 'name', - outputs: [ - { - internalType: 'string', - name: '', - type: 'string', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'symbol', - outputs: [ - { - internalType: 'string', - name: '', - type: 'string', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'totalSupply', - outputs: [ - { - internalType: 'uint256', - name: '', - type: 'uint256', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'recipient', - type: 'address', - }, - { - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - ], - name: 'transfer', - outputs: [ - { - internalType: 'bool', - name: '', - type: 'bool', - }, - ], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'sender', - type: 'address', - }, - { - internalType: 'address', - name: 'recipient', - type: 'address', - }, - { - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - ], - name: 'transferFrom', - outputs: [ - { - internalType: 'bool', - name: '', - type: 'bool', - }, - ], - stateMutability: 'nonpayable', - type: 'function', - }, -]; +export const contractABI = generalABI; +export const BeeTokenABI = generalABI; export const BeeFaucetABI = [ { inputs: [], diff --git a/frontend/src/confetti/index.ts b/frontend/src/confetti/index.ts index 56c45c89..a1ac6ea4 100644 --- a/frontend/src/confetti/index.ts +++ b/frontend/src/confetti/index.ts @@ -1,7 +1,7 @@ import confetti from 'canvas-confetti' const timeout = (ms: number) => { - return new Promise((resolve,reject) => { + return new Promise((resolve) => { setTimeout(resolve,ms) }) } diff --git a/frontend/src/hacking-instructor/challenges/loginBender.ts b/frontend/src/hacking-instructor/challenges/loginBender.ts index 0bc37ec6..66818d06 100644 --- a/frontend/src/hacking-instructor/challenges/loginBender.ts +++ b/frontend/src/hacking-instructor/challenges/loginBender.ts @@ -65,7 +65,10 @@ export const LoginBenderInstruction: ChallengeInstruction = { text: "Supply Bender's email address in the **email field**.", fixture: '#email', unskippable: true, - resolved: waitForInputToHaveValue('#email', 'bender@juice-sh.op', { replacement: ['juice-sh.op', 'application.domain'] }) + resolved: waitForInputToHaveValue('#email', 'bender@juice-sh.op', { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθήκη της παράμετρου ignoreCase + }) }, { text: "Now put anything in the **password field**. Let's assume we don't know it yet, even if you happen to already do.", @@ -88,7 +91,10 @@ export const LoginBenderInstruction: ChallengeInstruction = { text: "You can comment out the entire password check clause of the DB query by adding `'--` to Bender's email address!", fixture: '#email', unskippable: true, - resolved: waitForInputToHaveValue('#email', "bender@juice-sh.op'--", { replacement: ['juice-sh.op', 'application.domain'] }) + resolved: waitForInputToHaveValue('#email', "bender@juice-sh.op'--", { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθήκη της παράμετρος ignoreCase + }) }, { text: 'Now click the _Log in_ button again.', diff --git a/frontend/src/hacking-instructor/challenges/loginJim.ts b/frontend/src/hacking-instructor/challenges/loginJim.ts index 7290b2cb..e6febf6a 100644 --- a/frontend/src/hacking-instructor/challenges/loginJim.ts +++ b/frontend/src/hacking-instructor/challenges/loginJim.ts @@ -64,7 +64,10 @@ export const LoginJimInstruction: ChallengeInstruction = { text: "Supply Jim's email address in the **email field**.", fixture: '#email', unskippable: true, - resolved: waitForInputToHaveValue('#email', 'jim@juice-sh.op', { replacement: ['juice-sh.op', 'application.domain'] }) + resolved: waitForInputToHaveValue('#email', 'jim@juice-sh.op', { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθήκη της παράμετρος ignoreCase + }) }, { text: "Now put anything in the **password field**. Let's assume we don't know it yet, even if you happen to already do.", @@ -87,7 +90,10 @@ export const LoginJimInstruction: ChallengeInstruction = { text: "You can comment out the entire password check clause of the DB query by adding `'--` to Jim's email address!", fixture: '#email', unskippable: true, - resolved: waitForInputToHaveValue('#email', "jim@juice-sh.op'--", { replacement: ['juice-sh.op', 'application.domain'] }) + resolved: waitForInputToHaveValue('#email', "jim@juice-sh.op'--", { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθήκη της παράμετρος ignoreCase + }) }, { text: 'Now click the _Log in_ button again.', diff --git a/frontend/src/hacking-instructor/helpers/helpers.ts b/frontend/src/hacking-instructor/helpers/helpers.ts index 9f208abc..7ba66c33 100644 --- a/frontend/src/hacking-instructor/helpers/helpers.ts +++ b/frontend/src/hacking-instructor/helpers/helpers.ts @@ -2,10 +2,9 @@ * Copyright (c) 2014-2024 Bjoern Kimminich & the OWASP Juice Shop contributors. * SPDX-License-Identifier: MIT */ - import jwtDecode from 'jwt-decode' -let config +let config: { [key: string]: any } | undefined const playbackDelays = { faster: 0.5, fast: 0.75, @@ -14,17 +13,20 @@ const playbackDelays = { slower: 1.5 } -export async function sleep (timeInMs: number): Promise { - await new Promise((resolve) => { +interface WaitForInputOptions { + ignoreCase: boolean; + replacement?: [string, string]; +} + +export async function sleep(timeInMs: number): Promise { + await new Promise((resolve) => { setTimeout(resolve, timeInMs) }) } -export function waitForInputToHaveValue (inputSelector: string, value: string, options: any = { ignoreCase: true, replacement: [] }) { - return async () => { - const inputElement: HTMLInputElement = document.querySelector( - inputSelector - ) +export function waitForInputToHaveValue(inputSelector: string, value: string, options: WaitForInputOptions = { ignoreCase: true, replacement: ["", ""] }) { + return async (): Promise => { + const inputElement: HTMLInputElement | null = document.querySelector(inputSelector) if (options.replacement?.length === 2) { if (!config) { @@ -33,17 +35,30 @@ export function waitForInputToHaveValue (inputSelector: string, value: string, o config = json.config } const propertyChain = options.replacement[1].split('.') - let replacementValue = config + let replacementValue: any = config + for (const property of propertyChain) { - replacementValue = replacementValue[property] + // Έλεγχος για το undefined πριν την πρόσβαση στην ιδιότητα + if (replacementValue !== undefined && replacementValue !== null) { + replacementValue = replacementValue[property] + } else { + console.warn(`Replacement value for ${options.replacement[1]} is undefined at property "${property}".`) + break + } + } + + // Αν το replacementValue δεν είναι undefined, προχωράμε στην αντικατάσταση + if (replacementValue !== undefined) { + value = value.replace(options.replacement[0], replacementValue) + } else { + console.warn(`Replacement value for ${options.replacement[0]} is undefined.`) } - value = value.replace(options.replacement[0], replacementValue) } while (true) { - if (options.ignoreCase && inputElement.value.toLowerCase() === value.toLowerCase()) { + if (inputElement && options.ignoreCase && inputElement.value.toLowerCase() === value.toLowerCase()) { break - } else if (!options.ignoreCase && inputElement.value === value) { + } else if (inputElement && !options.ignoreCase && inputElement.value === value) { break } await sleep(100) @@ -51,16 +66,37 @@ export function waitForInputToHaveValue (inputSelector: string, value: string, o } } -export function waitForInputToNotHaveValue (inputSelector: string, value: string, options = { ignoreCase: true }) { - return async () => { - const inputElement: HTMLInputElement = document.querySelector( - inputSelector - ) +// Κλήσεις της συνάρτησης με την παράμετρο ignoreCase +resolved: waitForInputToHaveValue('#email', 'bender@juice-sh.op', { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθέτουμε το ignoreCase +}) + +resolved: waitForInputToHaveValue('#email', "bender@juice-sh.op'--", { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθέτουμε το ignoreCase +}) + +resolved: waitForInputToHaveValue('#email', 'jim@juice-sh.op', { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθέτουμε το ignoreCase +}) + +resolved: waitForInputToHaveValue('#email', "jim@juice-sh.op'--", { + replacement: ['juice-sh.op', 'application.domain'], + ignoreCase: true // Προσθέτουμε το ignoreCase +}) + + + +export function waitForInputToNotHaveValue(inputSelector: string, value: string, options: { ignoreCase: boolean } = { ignoreCase: true }) { + return async (): Promise => { + const inputElement: HTMLInputElement | null = document.querySelector(inputSelector) while (true) { - if (options.ignoreCase && inputElement.value.toLowerCase() !== value.toLowerCase()) { + if (inputElement && options.ignoreCase && inputElement.value.toLowerCase() !== value.toLowerCase()) { break - } else if (!options.ignoreCase && inputElement.value !== value) { + } else if (inputElement && !options.ignoreCase && inputElement.value !== value) { break } await sleep(100) @@ -68,14 +104,12 @@ export function waitForInputToNotHaveValue (inputSelector: string, value: string } } -export function waitForInputToNotHaveValueAndNotBeEmpty (inputSelector: string, value: string, options = { ignoreCase: true }) { - return async () => { - const inputElement: HTMLInputElement = document.querySelector( - inputSelector - ) +export function waitForInputToNotHaveValueAndNotBeEmpty(inputSelector: string, value: string, options: { ignoreCase: boolean } = { ignoreCase: true }) { + return async (): Promise => { + const inputElement: HTMLInputElement | null = document.querySelector(inputSelector) while (true) { - if (inputElement.value !== '') { + if (inputElement && inputElement.value !== '') { if (options.ignoreCase && inputElement.value.toLowerCase() !== value.toLowerCase()) { break } else if (!options.ignoreCase && inputElement.value !== value) { @@ -87,14 +121,12 @@ export function waitForInputToNotHaveValueAndNotBeEmpty (inputSelector: string, } } -export function waitForInputToNotBeEmpty (inputSelector: string) { - return async () => { - const inputElement: HTMLInputElement = document.querySelector( - inputSelector - ) +export function waitForInputToNotBeEmpty(inputSelector: string) { + return async (): Promise => { + const inputElement: HTMLInputElement | null = document.querySelector(inputSelector) while (true) { - if (inputElement.value && inputElement.value !== '') { + if (inputElement && inputElement.value && inputElement.value !== '') { break } await sleep(100) @@ -102,27 +134,25 @@ export function waitForInputToNotBeEmpty (inputSelector: string) { } } -export function waitForElementToGetClicked (elementSelector: string) { - return async () => { - const element = document.querySelector( - elementSelector - ) +export function waitForElementToGetClicked(elementSelector: string) { + return async (): Promise => { + const element: HTMLElement | null = document.querySelector(elementSelector) if (!element) { console.warn(`Could not find Element with selector "${elementSelector}"`) } await new Promise((resolve) => { - element.addEventListener('click', () => { resolve() }) + if (element) { + element.addEventListener('click', () => { resolve() }) + } }) } } -export function waitForElementsInnerHtmlToBe (elementSelector: string, value: string) { - return async () => { +export function waitForElementsInnerHtmlToBe(elementSelector: string, value: string) { + return async (): Promise => { while (true) { - const element = document.querySelector( - elementSelector - ) + const element: HTMLElement | null = document.querySelector(elementSelector) if (element && element.innerHTML === value) { break @@ -132,21 +162,20 @@ export function waitForElementsInnerHtmlToBe (elementSelector: string, value: st } } -export function waitInMs (timeInMs: number) { - return async () => { +export function waitInMs(timeInMs: number) { + return async (): Promise => { if (!config) { const res = await fetch('/rest/admin/application-configuration') const json = await res.json() config = json.config } - let delay = playbackDelays[config.hackingInstructor.hintPlaybackSpeed] - delay ??= 1.0 + let delay = playbackDelays[config?.hackingInstructor?.hintPlaybackSpeed as keyof typeof playbackDelays] ?? 1.0 await sleep(timeInMs * delay) } } -export function waitForAngularRouteToBeVisited (route: string) { - return async () => { +export function waitForAngularRouteToBeVisited(route: string) { + return async (): Promise => { while (true) { if (window.location.hash.startsWith(`#/${route}`)) { break @@ -156,8 +185,8 @@ export function waitForAngularRouteToBeVisited (route: string) { } } -export function waitForLogIn () { - return async () => { +export function waitForLogIn() { + return async (): Promise => { while (true) { if (localStorage.getItem('token') !== null) { break @@ -167,14 +196,14 @@ export function waitForLogIn () { } } -export function waitForAdminLogIn () { - return async () => { +export function waitForAdminLogIn() { + return async (): Promise => { while (true) { let role: string = '' try { - const token: string = localStorage.getItem('token') + const token: string = localStorage.getItem('token') || '' const decodedToken = jwtDecode(token) - const payload = decodedToken as any + const payload = decodedToken as { data: { role: string } } role = payload.data.role } catch { console.log('Role from token could not be accessed.') @@ -187,8 +216,8 @@ export function waitForAdminLogIn () { } } -export function waitForLogOut () { - return async () => { +export function waitForLogOut() { + return async (): Promise => { while (true) { if (localStorage.getItem('token') === null) { break @@ -198,14 +227,10 @@ export function waitForLogOut () { } } -/** - * see https://stackoverflow.com/questions/7798748/find-out-whether-chrome-console-is-open/48287643#48287643 - * does detect when devtools are opened horizontally or vertically but not when undocked or open on page load - */ -export function waitForDevTools () { +export function waitForDevTools() { const initialInnerHeight = window.innerHeight const initialInnerWidth = window.innerWidth - return async () => { + return async (): Promise => { while (true) { if (window.innerHeight !== initialInnerHeight || window.innerWidth !== initialInnerWidth) { break @@ -215,14 +240,12 @@ export function waitForDevTools () { } } -export function waitForSelectToHaveValue (selectSelector: string, value: string) { - return async () => { - const selectElement: HTMLSelectElement = document.querySelector( - selectSelector - ) +export function waitForSelectToHaveValue(selectSelector: string, value: string) { + return async (): Promise => { + const selectElement: HTMLSelectElement | null = document.querySelector(selectSelector) while (true) { - if (selectElement.options[selectElement.selectedIndex].value === value) { + if (selectElement && selectElement.options[selectElement.selectedIndex].value === value) { break } await sleep(100) @@ -230,14 +253,12 @@ export function waitForSelectToHaveValue (selectSelector: string, value: string) } } -export function waitForSelectToNotHaveValue (selectSelector: string, value: string) { - return async () => { - const selectElement: HTMLSelectElement = document.querySelector( - selectSelector - ) +export function waitForSelectToNotHaveValue(selectSelector: string, value: string) { + return async (): Promise => { + const selectElement: HTMLSelectElement | null = document.querySelector(selectSelector) while (true) { - if (selectElement.options[selectElement.selectedIndex].value !== value) { + if (selectElement && selectElement.options[selectElement.selectedIndex].value !== value) { break } await sleep(100) @@ -245,8 +266,8 @@ export function waitForSelectToNotHaveValue (selectSelector: string, value: stri } } -export function waitForRightUriQueryParamPair (key: string, value: string) { - return async () => { +export function waitForRightUriQueryParamPair(key: string, value: string) { + return async (): Promise => { while (true) { const encodedValue: string = encodeURIComponent(value).replace(/%3A/g, ':') const encodedKey: string = encodeURIComponent(key).replace(/%3A/g, ':') diff --git a/frontend/src/hacking-instructor/index.ts b/frontend/src/hacking-instructor/index.ts index 50bc9b4b..3d668920 100644 --- a/frontend/src/hacking-instructor/index.ts +++ b/frontend/src/hacking-instructor/index.ts @@ -4,6 +4,7 @@ */ import snarkdown from 'snarkdown' +import DOMPurify from 'dompurify' import { LoginAdminInstruction } from './challenges/loginAdmin' import { DomXssInstruction } from './challenges/domXss' @@ -108,7 +109,9 @@ function loadHint (hint: ChallengeHint): HTMLElement { const textBox = document.createElement('span') textBox.style.flexGrow = '2' - textBox.innerHTML = snarkdown(hint.text) + const sanitizedHTML = DOMPurify.sanitize(snarkdown(hint.text)) + textBox.textContent = sanitizedHTML + const cancelButton = document.createElement('button') cancelButton.id = 'cancelButton' diff --git a/frontend/src/index.html b/frontend/src/index.html index e73d9b53..7a570b39 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -11,9 +11,9 @@ - - - + + +