feat: Introduce Playwright for UI testing

Instead of using Robot Framework which does not run during a `mvn install`. Playwright seems to be the better approach. We can now write them as normal JUnit test and they are executed during a build.

Additionally this PR solves some interesting bugs found during writing Playwright tests:

- A reset of a lesson removes all assignments as a result another user wouldn't see any assignments
- If someone solves an assignment the assignment automatically got solved for a new user since the assignment included the `solved` flag which immediately got copied to new lesson progress.
- Introduction of assignment progress linking a assignment not directly to all users.
This commit is contained in:
Nanne Baars
2025-01-26 16:59:59 +01:00
committed by GitHub
parent 9d5ab5fb21
commit 8e45316638
47 changed files with 599 additions and 281 deletions

View File

@ -1,61 +0,0 @@
name: "UI-Test"
on:
pull_request:
paths-ignore:
- 'LICENSE'
- 'docs/**'
push:
tags-ignore:
- 'v*'
paths-ignore:
- '.txt'
- '*.MD'
- '*.md'
- 'LICENSE'
- 'docs/**'
jobs:
build:
runs-on: ubuntu-latest
# display name of the job
name: "Robot framework test"
steps:
# Uses an default action to checkout the code
- uses: actions/checkout@v4.1.6
# Uses an action to add Python to the VM
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.7'
architecture: x64
# Uses an action to add JDK 23 to the VM (and mvn?)
- name: set up JDK 23
uses: actions/setup-java@v4.2.1
with:
distribution: 'temurin'
java-version: 23
architecture: x64
cache: 'maven'
- uses: BSFishy/pip-action@v1
with:
packages: |
robotframework
robotframework-SeleniumLibrary
webdriver-manager
selenium==4.9.1
# TODO https://github.com/robotframework/SeleniumLibrary/issues/1835
- name: Run with Maven
run: mvn --no-transfer-progress spring-boot:run &
- name: Wait to start
uses: ifaxity/wait-on-action@v1
with:
resource: http://127.0.0.1:8080/WebGoat
- name: Test with Robotframework
run: python3 -m robot --variable HEADLESS:"1" --outputdir robotreport robot/goat.robot
# send report to forks only due to limits on permission tokens
- name: Send report to commit
if: github.repository != 'WebGoat/WebGoat' && github.event_name == 'push'
uses: joonvena/robotframework-reporter-action@v2.2
with:
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
report_path: 'robotreport'

5
.gitignore vendored
View File

@ -57,3 +57,8 @@ TestClass.class
/.gitconfig
webgoat.gitconfig
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

21
pom.xml
View File

@ -97,10 +97,12 @@
<thymeleaf.version>3.1.2.RELEASE</thymeleaf.version>
<waittimeForServerStart>60</waittimeForServerStart>
<webdriver.version>5.9.2</webdriver.version>
<webgoat.context>/</webgoat.context>
<webgoat.context>/WebGoat</webgoat.context>
<webgoat.port>8080</webgoat.port>
<webgoat.sslenabled>false</webgoat.sslenabled>
<webjars-locator-core.version>0.59</webjars-locator-core.version>
<webwolf.context>/</webwolf.context>
<webwolf.context>/WebWolf</webwolf.context>
<webwolf.port>9090</webwolf.port>
<wiremock.version>3.10.0</wiremock.version>
<xml-resolver.version>1.2</xml-resolver.version>
<xstream.version>1.4.5</xstream.version>
@ -217,6 +219,11 @@
<artifactId>jruby</artifactId>
<version>9.4.9.0</version>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.49.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
@ -400,6 +407,11 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
@ -486,7 +498,7 @@
<logback.configurationFile>${basedir}/src/test/resources/logback-test.xml</logback.configurationFile>
</systemPropertyVariables>
<argLine>-Xmx512m</argLine>
<includes>org/owasp/webgoat/*Test</includes>
<includes>org/owasp/webgoat/integration/*Test, org/owasp/webgoat/playwright/**/*Test</includes>
</configuration>
<executions>
<execution>
@ -517,6 +529,7 @@
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED</argLine>
<excludes>
<exclude>**/*IntegrationTest.java</exclude>
<exclude>**/*UITest.java</exclude>
</excludes>
</configuration>
</plugin>
@ -702,7 +715,7 @@
</arguments>
<waitForInterrupt>false</waitForInterrupt>
<waitAfterLaunch>${waittimeForServerStart}</waitAfterLaunch>
<healthCheckUrl>http://127.0.0.1:${webgoat.port}${webgoat.context}login</healthCheckUrl>
<healthCheckUrl>http://127.0.0.1:${webgoat.port}${webgoat.context}/login</healthCheckUrl>
</configuration>
</execution>
<execution>

View File

@ -1,21 +0,0 @@
# Install and use Robotframework
## Install Chromedriver on Mac OS
brew install cask chromedriver
chromedriver --version
Then see security settings and allow the file to run
## Install
pip3 install virtualenv --user
python3 -m virtualenv .venv
source .venv/bin/activate
pip install --upgrade robotframework
pip install --upgrade robotframework-SeleniumLibrary
pip install --upgrade webdriver-manager
brew upgrade
robot --variable HEADLESS:"0" --variable ENDPOINT:"http://127.0.0.1:8080/WebGoat" goat.robot
Make sure that the Chrome version, the webdriver version and all related components are up-to-date and compatible!

View File

@ -1,129 +0,0 @@
*** Settings ***
Documentation Setup WebGoat Robotframework tests
Library SeleniumLibrary timeout=100 run_on_failure=Capture Page Screenshot
Library String
Library OperatingSystem
Suite Setup Initial_Page ${ENDPOINT} ${BROWSER}
Suite Teardown Close_Page
*** Variables ***
${BROWSER} chrome
${SLEEP} 100
${DELAY} 0.25
${ENDPOINT} http://127.0.0.1:8080/WebGoat
${ENDPOINT_WOLF} http://127.0.0.1:9090/WebWolf
${USERNAME} robotuser
${PASSWORD} password
${HEADLESS} ${FALSE}
*** Keywords ***
Initial_Page
[Documentation] Check the inital page
[Arguments] ${ENDPOINT} ${BROWSER}
Log To Console Start WebGoat UI Testing
IF ${HEADLESS}
Open Browser ${ENDPOINT} ${BROWSER} options=add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'});add_argument("-headless");add_argument("--start-maximized") alias=webgoat
ELSE
Open Browser ${ENDPOINT} ${BROWSER} options=add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'}) alias=webgoat
END
Switch Browser webgoat
Maximize Browser Window
Set Window Size ${1400} ${1000}
Set Window Position ${0} ${0}
Set Selenium Speed ${DELAY}
Log To Console Start WebWolf UI Testing
IF ${HEADLESS}
Open Browser ${ENDPOINT_WOLF} ${BROWSER} options=add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'});add_argument("-headless");add_argument("--start-maximized") alias=webwolf
ELSE
Open Browser ${ENDPOINT_WOLF} ${BROWSER} options=add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'}) alias=webwolf
END
Switch Browser webwolf
Maximize Browser Window
Set Window Size ${1400} ${1000}
Set Window Position ${500} ${0}
Set Selenium Speed ${DELAY}
Close_Page
[Documentation] Closing the browser
Log To Console ==> Stop WebGoat UI Testing
IF ${HEADLESS}
Switch Browser webgoat
Close Browser
Switch Browser webwolf
Close Browser
END
*** Test Cases ***
Check_Initial_Page
[Tags] WebGoatTests
Switch Browser webgoat
Page Should Contain Username
Click Button Sign in
Page Should Contain Invalid username
Click Link /WebGoat/registration
Check_Registration_Page
[Tags] WebGoatTests
Page Should Contain Username
Input Text username ${USERNAME}
Input Text password ${PASSWORD}
Input Text matchingPassword ${PASSWORD}
Click Element agree
Click Button Sign up
Check_Welcome_Page
[Tags] WebGoatTests
Page Should Contain WebGoat
Go To ${ENDPOINT}/login
Page Should Contain Username
Input Text username ${USERNAME}
Input Text password ${PASSWORD}
Click Button Sign in
Page Should Contain WebGoat
Check_Menu_Page
[Tags] WebGoatTests
Click Element css=a[category='Introduction']
Click Element Introduction-WebGoat
CLick Element Introduction-WebWolf
Click Element css=a[category='General']
CLick Element General-HTTPBasics
Click Element xpath=//*[.='2']
Input Text person ${USERNAME}
Click Button Go!
${OUT_VALUE} Get Text xpath=//div[contains(@class, 'attack-feedback')]
${OUT_RESULT} Evaluate "resutobor" in """${OUT_VALUE}"""
IF not ${OUT_RESULT}
Fail "not ok"
END
Check_WebWolf
Switch Browser webwolf
location should be ${ENDPOINT_WOLF}/login
Input Text username ${USERNAME}
Input Text password ${PASSWORD}
Click Button Sign In
Go To ${ENDPOINT_WOLF}/mail
Go To ${ENDPOINT_WOLF}/requests
Go To ${ENDPOINT_WOLF}/files
Check_JWT_Page
Go To ${ENDPOINT_WOLF}/jwt
Click Element token
Wait Until Element Is Enabled token 5s
Input Text token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Click Element secretKey
Input Text secretKey none
Sleep 2s # Pause before reading the result
${OUT_VALUE} Get Value xpath=//textarea[@id='token']
Log To Console Found token ${OUT_VALUE}
${OUT_RESULT} Evaluate "ImuPnHvLdU7ULKfbD4aJU" in """${OUT_VALUE}"""
Log To Console Found token ${OUT_RESULT}
Capture Page Screenshot
Check_Files_Page
Go To ${ENDPOINT_WOLF}/files
Choose File css:input[type="file"] ${CURDIR}/goat.robot
Click Button Upload files

View File

@ -0,0 +1,36 @@
package org.owasp.webgoat;
public record ServerUrlConfig(String host, String port, String contextPath) {
public ServerUrlConfig {
contextPath = contextPath.replaceAll("/", "");
}
public String getBaseUrl() {
return "http://%s:%s".formatted(host, port);
}
public String url(String path) {
return "%s/%s".formatted(getFullUrl(), path);
}
private String getFullUrl() {
return "http://%s:%s/%s".formatted(host, port, contextPath);
}
public static ServerUrlConfig webGoat() {
return new ServerUrlConfig(
"localhost", env("WEBGOAT_PORT", "8080"), env("WEBGOAT_CONTEXT", "WebGoat"));
}
public static ServerUrlConfig webWolf() {
return new ServerUrlConfig(
"localhost", env("WEBWOLF_PORT", "9090"), env("WEBWOLF_CONTEXT", "WebWolf"));
}
private static String env(String variableName, String defaultValue) {
return System.getenv().getOrDefault(variableName, "").isEmpty()
? defaultValue
: System.getenv(variableName);
}
}

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.junit.jupiter.api.Assertions.assertTrue;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.junit.jupiter.api.Assertions.fail;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import java.io.IOException;
import java.util.HashMap;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static io.restassured.RestAssured.given;
@ -11,27 +11,20 @@ import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.owasp.webgoat.ServerUrlConfig;
import org.springframework.http.HttpStatus;
public abstract class IntegrationTest {
private static String webGoatPort = System.getenv().getOrDefault("WEBGOAT_PORT", "8080");
@Getter private static String webWolfPort = System.getenv().getOrDefault("WEBWOLF_PORT", "9090");
@Getter
private static String webWolfHost = System.getenv().getOrDefault("WEBWOLF_HOST", "127.0.0.1");
private static String webGoatContext =
System.getenv().getOrDefault("WEBGOAT_CONTEXT", "/WebGoat/");
private static String webWolfContext =
System.getenv().getOrDefault("WEBWOLF_CONTEXT", "/WebWolf/");
private final ServerUrlConfig webGoatUrlConfig = ServerUrlConfig.webGoat();
@Getter private final ServerUrlConfig webWolfUrlConfig = ServerUrlConfig.webWolf();
@Getter private String webGoatCookie;
@Getter private String webWolfCookie;
@Getter private final String user = "webgoat";
protected String url(String url) {
return "http://localhost:%s%s%s".formatted(webGoatPort, webGoatContext, url);
return webGoatUrlConfig.url(url);
}
protected class WebWolfUrlBuilder {
@ -40,8 +33,7 @@ public abstract class IntegrationTest {
private String path = null;
protected String build() {
return "http://localhost:%s%s%s"
.formatted(webWolfPort, webWolfContext, path != null ? path : "");
return webWolfUrlConfig.url(path != null ? path : "");
}
/**

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
@ -136,7 +136,7 @@ public class PasswordResetLessonIntegrationTest extends IntegrationTest {
private void clickForgotEmailLink(String user) {
RestAssured.given()
.when()
.header(HttpHeaders.HOST, String.format("%s:%s", getWebWolfHost(), getWebWolfPort()))
.header(HttpHeaders.HOST, String.format("%s:%s", "127.0.0.1", getWebWolfUrlConfig().port()))
.relaxedHTTPSValidation()
.cookie("JSESSIONID", getWebGoatCookie())
.formParams("email", user)

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import io.restassured.RestAssured;
import io.restassured.response.Response;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import java.util.HashMap;
import java.util.Map;

View File

@ -21,7 +21,7 @@
* Source for this application is maintained at https://github.com/WebGoat/WebGoat, a repository for free software projects.
*/
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import java.util.Map;
import org.junit.jupiter.api.Test;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import java.util.HashMap;
import java.util.Map;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import java.util.HashMap;
import java.util.Map;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.hamcrest.CoreMatchers.containsString;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import static org.junit.jupiter.api.Assertions.assertTrue;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import io.restassured.RestAssured;
import java.util.HashMap;

View File

@ -1,4 +1,4 @@
package org.owasp.webgoat;
package org.owasp.webgoat.integration;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

View File

@ -0,0 +1,65 @@
package org.owasp.webgoat.playwright.webgoat;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import com.microsoft.playwright.*;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.owasp.webgoat.container.lessons.LessonName;
import org.owasp.webgoat.playwright.webgoat.helpers.Authentication;
import org.owasp.webgoat.playwright.webgoat.pages.HttpBasicsLessonPage;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class HttpBasicsLessonUITest extends PlaywrightTest {
private HttpBasicsLessonPage lessonPage;
@BeforeEach
void navigateToLesson(Browser browser) {
var lessonName = new LessonName("HttpBasics");
var page = Authentication.sylvester(browser);
this.lessonPage = new HttpBasicsLessonPage(page);
lessonPage.resetLesson(lessonName);
lessonPage.open(lessonName);
}
@Test
@Order(1)
void shouldShowDefaultPage() {
assertThat(lessonPage.getTitle()).hasText("HTTP Basics");
Assertions.assertThat(lessonPage.noAssignmentsCompleted()).isTrue();
Assertions.assertThat(lessonPage.numberOfAssignments()).isEqualTo(2);
}
@Test
@Order(2)
@DisplayName(
"When the user enters their name, the server should reverse it then the assignment should be"
+ " solved")
void solvePage2() {
lessonPage.navigateTo(2);
lessonPage.getEnterYourName().fill("John Doe");
lessonPage.getGoButton().click();
assertThat(lessonPage.getAssignmentOutput())
.containsText("The server has reversed your name: eoD nhoJ");
Assertions.assertThat(lessonPage.isAssignmentSolved(2)).isTrue();
}
@Test
@Order(3)
@DisplayName("When the user enters nothing then the server should display an error message")
void invalidPage2() {
lessonPage.navigateTo(2);
lessonPage.getEnterYourName().fill("");
lessonPage.getGoButton().click();
assertThat(lessonPage.getAssignmentOutput()).containsText("Try again, name cannot be empty.");
}
}

View File

@ -0,0 +1,27 @@
package org.owasp.webgoat.playwright.webgoat;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import org.junit.jupiter.api.Test;
import org.owasp.webgoat.playwright.webgoat.helpers.Authentication;
import org.owasp.webgoat.playwright.webgoat.pages.WebGoatLoginPage;
class LoginUITest extends PlaywrightTest {
@Test
void loginLogout(Browser browser) {
var page = Authentication.tweety(browser);
var loginPage = new WebGoatLoginPage(page);
loginPage.open();
loginPage.login(Authentication.getTweety().name(), Authentication.getTweety().password());
// logout
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("")).click();
page.getByRole(AriaRole.MENUITEM, new Page.GetByRoleOptions().setName("Logout")).click();
assertThat(loginPage.getSignInButton()).isVisible();
}
}

View File

@ -0,0 +1,33 @@
package org.owasp.webgoat.playwright.webgoat;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.junit.Options;
import com.microsoft.playwright.junit.OptionsFactory;
import com.microsoft.playwright.junit.UsePlaywright;
import org.owasp.webgoat.ServerUrlConfig;
@UsePlaywright(PlaywrightTest.WebGoatOptions.class)
public class PlaywrightTest {
private static final ServerUrlConfig webGoatUrlConfig = ServerUrlConfig.webGoat();
private static final ServerUrlConfig webWolfUrlConfig = ServerUrlConfig.webWolf();
public static class WebGoatOptions implements OptionsFactory {
@Override
public Options getOptions() {
return new Options().setHeadless(true).setContextOptions(getContextOptions());
}
}
protected static Browser.NewContextOptions getContextOptions() {
return new Browser.NewContextOptions().setBaseURL(webGoatUrlConfig.getBaseUrl());
}
public static String webGoatUrl(String path) {
return webGoatUrlConfig.url(path);
}
public static String webWolfURL(String path) {
return webWolfUrlConfig.url(path);
}
}

View File

@ -0,0 +1,61 @@
package org.owasp.webgoat.playwright.webgoat.helpers;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.Page;
import lombok.Getter;
import org.owasp.webgoat.playwright.webgoat.pages.RegistrationPage;
import org.owasp.webgoat.playwright.webgoat.pages.WebGoatLoginPage;
import org.owasp.webgoat.playwright.webwolf.pages.WebWolfLoginPage;
/**
* Helper class to authenticate users in WebGoat and WebWolf.
*
* <p>It provides two users: sylvester and tweety. The users are authenticated by logging in to
* WebGoat and WebWolf. Once authenticated, the user's authentication token is stored in the browser
* and reused for subsequent requests.
*/
public class Authentication {
public record User(String name, String password, String auth) {
boolean loggedIn() {
return auth != null;
}
}
@Getter private static User sylvester = new User("sylvester", "sylvester", null);
@Getter private static User tweety = new User("tweety", "tweety", null);
public static Page sylvester(Browser browser) {
User user = login(browser, sylvester);
return browser.newContext(new Browser.NewContextOptions().setStorageState(user.auth)).newPage();
}
public static Page tweety(Browser browser) {
User user = login(browser, tweety);
return browser.newContext(new Browser.NewContextOptions().setStorageState(user.auth)).newPage();
}
private static User login(Browser browser, User user) {
if (user.loggedIn()) {
return user;
}
var page = browser.newContext().newPage();
RegistrationPage registrationPage = new RegistrationPage(page);
registrationPage.open();
registrationPage.register(user.name, user.password);
WebGoatLoginPage loginPage = new WebGoatLoginPage(page);
loginPage.open();
loginPage.login(user.name, user.password);
assertThat(loginPage.getSignInButton()).not().isVisible();
WebWolfLoginPage webWolfLoginPage = new WebWolfLoginPage(page);
webWolfLoginPage.open();
webWolfLoginPage.login(user.name, user.password);
assertThat(loginPage.getSignInButton()).not().isVisible();
return new User(user.name, user.password, page.context().storageState());
}
}

View File

@ -0,0 +1,24 @@
package org.owasp.webgoat.playwright.webgoat.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import lombok.Getter;
@Getter
public class HttpBasicsLessonPage extends LessonPage {
private final Locator enterYourName;
private final Locator goButton;
public HttpBasicsLessonPage(Page page) {
super(page);
enterYourName = page.locator("input[name=\"person\"]");
goButton = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Go!"));
}
public Locator getTitle() {
return getPage()
.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("HTTP Basics"));
}
}

View File

@ -0,0 +1,64 @@
package org.owasp.webgoat.playwright.webgoat.pages;
import static org.owasp.webgoat.playwright.webgoat.PlaywrightTest.webGoatUrl;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import lombok.Getter;
import org.assertj.core.api.Assertions;
import org.owasp.webgoat.container.lessons.LessonName;
@Getter
class LessonPage {
private final Page page;
public LessonPage(Page page) {
this.page = page;
}
public void navigateTo(int pageNumber) {
page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("" + pageNumber)).click();
}
public void open(LessonName lessonName) {
page.navigate(webGoatUrl("start.mvc#lesson/%s".formatted(lessonName.lessonName())));
}
/**
* Force a reload for the UI to response, this is normally done by a JavaScript reloading every 5
* seconds
*/
public void refreshPage() {
page.reload();
}
public void resetLesson(LessonName lessonName) {
Assertions.assertThat(
page.request()
.get(webGoatUrl("service/restartlesson.mvc/%s".formatted(lessonName)))
.ok())
.isTrue();
refreshPage();
}
public int numberOfAssignments() {
return page.locator(".attack-link.solved-false").count()
+ page.locator(".attack-link.solved-true").count();
}
public boolean isAssignmentSolved(int pageNumber) {
var solvedAssignments = page.locator(".attack-link.solved-true");
solvedAssignments.waitFor();
return solvedAssignments.all().stream().anyMatch(l -> l.textContent().equals("" + pageNumber));
}
public boolean noAssignmentsCompleted() {
return page.locator(".attack-link.solved-true").count() == 0;
}
public Locator getAssignmentOutput() {
return page.locator("#lesson-content-wrapper");
}
}

View File

@ -0,0 +1,32 @@
package org.owasp.webgoat.playwright.webgoat.pages;
import static com.microsoft.playwright.options.AriaRole.BUTTON;
import static org.owasp.webgoat.playwright.webgoat.PlaywrightTest.webGoatUrl;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import lombok.Getter;
public class RegistrationPage {
private final Page page;
@Getter private final Locator signUpButton;
public RegistrationPage(Page page) {
this.page = page;
this.signUpButton = this.page.getByRole(BUTTON, new Page.GetByRoleOptions().setName("Sign up"));
}
public void open() {
page.navigate(webGoatUrl("registration"));
}
public void register(String username, String password) {
page.getByPlaceholder("Username").fill(username);
page.getByLabel("Password", new Page.GetByLabelOptions().setExact(true)).fill(password);
page.getByLabel("Confirm password").fill(password);
page.getByLabel("Agree with the terms and").check();
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign up")).click();
}
}

View File

@ -0,0 +1,29 @@
package org.owasp.webgoat.playwright.webgoat.pages;
import static com.microsoft.playwright.options.AriaRole.BUTTON;
import static org.owasp.webgoat.playwright.webgoat.PlaywrightTest.webGoatUrl;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import lombok.Getter;
public class WebGoatLoginPage {
private final Page page;
@Getter private final Locator signInButton;
public WebGoatLoginPage(Page page) {
this.page = page;
this.signInButton = this.page.getByRole(BUTTON, new Page.GetByRoleOptions().setName("Sign in"));
}
public void open() {
page.navigate(webGoatUrl("login"));
}
public void login(String username, String password) {
page.getByPlaceholder("Username").fill(username);
page.getByPlaceholder("Password").fill(password);
page.getByRole(BUTTON, new Page.GetByRoleOptions().setName("Sign in")).click();
}
}

View File

@ -0,0 +1,32 @@
package org.owasp.webgoat.playwright.webwolf;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import com.microsoft.playwright.Browser;
import org.junit.jupiter.api.Test;
import org.owasp.webgoat.playwright.webgoat.PlaywrightTest;
import org.owasp.webgoat.playwright.webgoat.helpers.Authentication;
class JwtUITest extends PlaywrightTest {
@Test
void shouldDecodeJwt(Browser browser) {
var page = Authentication.sylvester(browser);
var secretKey = "test";
var jwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
page.navigate(webWolfURL("jwt"));
page.getByPlaceholder("Enter your secret key").fill(secretKey);
page.getByPlaceholder("Paste token here").type(jwt);
assertThat(page.locator("#header"))
.hasValue("{\n \"alg\" : \"HS256\",\n \"typ\" : \"JWT\"\n}");
assertThat(page.locator("#payload"))
.hasValue(
"{\n"
+ " \"iat\" : 1516239022,\n"
+ " \"name\" : \"John Doe\",\n"
+ " \"sub\" : \"1234567890\"\n"
+ "}");
}
}

View File

@ -0,0 +1,27 @@
package org.owasp.webgoat.playwright.webwolf;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import com.microsoft.playwright.Browser;
import org.junit.jupiter.api.Test;
import org.owasp.webgoat.playwright.webgoat.PlaywrightTest;
import org.owasp.webgoat.playwright.webgoat.helpers.Authentication;
import org.owasp.webgoat.playwright.webwolf.pages.WebWolfLoginPage;
public class LoginUITest extends PlaywrightTest {
@Test
void login(Browser browser) {
var page = Authentication.tweety(browser);
var loginPage = new WebWolfLoginPage(page);
loginPage.open();
loginPage.login(Authentication.getTweety().name(), Authentication.getTweety().password());
assertThat(loginPage.getSignInButton()).not().isVisible();
// logout
loginPage.logout();
assertThat(loginPage.getSignInButton()).isVisible();
}
}

View File

@ -0,0 +1,37 @@
package org.owasp.webgoat.playwright.webwolf.pages;
import static com.microsoft.playwright.options.AriaRole.BUTTON;
import static org.owasp.webgoat.playwright.webgoat.PlaywrightTest.webWolfURL;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import lombok.Getter;
public class WebWolfLoginPage {
private final Page page;
@Getter private final Locator signInButton;
private final Locator signOutButton;
public WebWolfLoginPage(Page page) {
this.page = page;
this.signInButton = this.page.getByRole(BUTTON, new Page.GetByRoleOptions().setName("Sign In"));
this.signOutButton =
this.page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Sign out"));
}
public void open() {
page.navigate(webWolfURL("login"));
}
public void login(String username, String password) {
page.getByPlaceholder("Username WebGoat").fill(username);
page.getByPlaceholder("Password WebGoat").fill(password);
signInButton.click();
}
public void logout() {
this.signOutButton.click();
}
}

View File

@ -51,7 +51,6 @@ public class Assignment {
private String name;
private String path;
private boolean solved = false;
@Transient private List<String> hints;
@ -75,8 +74,4 @@ public class Assignment {
this.path = path;
this.hints = hints;
}
public void solved() {
this.solved = true;
}
}

View File

@ -18,4 +18,9 @@ public record LessonName(String lessonName) {
lessonName = lessonName.substring(0, lessonName.indexOf(".lesson"));
}
}
@Override
public String toString() {
return lessonName;
}
}

View File

@ -10,7 +10,6 @@ import org.owasp.webgoat.container.lessons.LessonName;
import org.owasp.webgoat.container.session.Course;
import org.owasp.webgoat.container.users.UserProgressRepository;
import org.springframework.stereotype.Controller;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
@ -40,11 +39,9 @@ public class LessonProgressService {
var userProgress = userProgressRepository.findByUser(username);
var lesson = course.getLessonByName(lessonName);
Assert.isTrue(lesson != null, "Lesson not found: " + lessonName);
var lessonProgress = userProgress.getLessonProgress(lesson);
return lessonProgress.getLessonOverview().entrySet().stream()
.map(entry -> new LessonOverview(entry.getKey(), entry.getValue()))
.map(entry -> new LessonOverview(entry.getKey().getAssignment(), entry.getValue()))
.toList();
}

View File

@ -0,0 +1,47 @@
package org.owasp.webgoat.container.users;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.owasp.webgoat.container.lessons.Assignment;
import org.springframework.util.Assert;
@Entity
@EqualsAndHashCode
public class AssignmentProgress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Getter
@OneToOne(cascade = CascadeType.ALL)
private Assignment assignment;
@Getter private boolean solved;
protected AssignmentProgress() {}
public AssignmentProgress(Assignment assignment) {
this.assignment = assignment;
}
public boolean hasSameName(String name) {
Assert.notNull(name, "Name cannot be null");
return assignment.getName().equals(name);
}
public void solved() {
this.solved = true;
}
public void reset() {
this.solved = false;
}
}

View File

@ -9,14 +9,12 @@ import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.owasp.webgoat.container.lessons.Assignment;
import org.owasp.webgoat.container.lessons.Lesson;
/**
@ -61,7 +59,7 @@ public class LessonProgress {
@Getter private String lessonName;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private final Set<Assignment> assignments = new HashSet<>();
private final Set<AssignmentProgress> assignments = new HashSet<>();
@Getter private int numberOfAttempts = 0;
@Version private Integer version;
@ -72,11 +70,11 @@ public class LessonProgress {
public LessonProgress(Lesson lesson) {
lessonName = lesson.getId();
assignments.addAll(lesson.getAssignments() == null ? List.of() : lesson.getAssignments());
assignments.addAll(lesson.getAssignments().stream().map(AssignmentProgress::new).toList());
}
public Optional<Assignment> getAssignment(String name) {
return assignments.stream().filter(a -> a.getName().equals(name)).findFirst();
private Optional<AssignmentProgress> getAssignment(String name) {
return assignments.stream().filter(a -> a.hasSameName(name)).findFirst();
}
/**
@ -85,14 +83,14 @@ public class LessonProgress {
* @param solvedAssignment the assignment which the user solved
*/
public void assignmentSolved(String solvedAssignment) {
getAssignment(solvedAssignment).ifPresent(Assignment::solved);
getAssignment(solvedAssignment).ifPresent(AssignmentProgress::solved);
}
/**
* @return did they user solved all solvedAssignments for the lesson?
*/
public boolean isLessonSolved() {
return assignments.stream().allMatch(Assignment::isSolved);
return assignments.stream().allMatch(AssignmentProgress::isSolved);
}
/** Increase the number attempts to solve the lesson */
@ -102,14 +100,14 @@ public class LessonProgress {
/** Reset the tracker. We do not reset the number of attempts here! */
void reset() {
assignments.clear();
assignments.forEach(AssignmentProgress::reset);
}
/**
* @return list containing all the assignments solved or not
*/
public Map<Assignment, Boolean> getLessonOverview() {
return assignments.stream().collect(Collectors.toMap(a -> a, Assignment::isSolved));
public Map<AssignmentProgress, Boolean> getLessonOverview() {
return assignments.stream().collect(Collectors.toMap(a -> a, AssignmentProgress::isSolved));
}
long numberOfSolvedAssignments() {

View File

@ -31,6 +31,7 @@ public class UserService implements UserDetailsService {
throw new UsernameNotFoundException("User not found");
} else {
webGoatUser.createUser();
// TODO maybe better to use an event to initialize lessons to keep dependencies low
lessonInitializables.forEach(l -> l.initialize(webGoatUser));
}
return webGoatUser;

View File

@ -2,15 +2,20 @@
-- For the normal WebGoat server there is a bean which already provided the schema (and creates it see DatabaseInitialization)
CREATE SCHEMA IF NOT EXISTS CONTAINER;
create
table CONTAINER.assignment
create table CONTAINER.assignment
(
solved boolean not null,
id bigint generated by default as identity (start with 1),
name varchar(255),
path varchar(255),
primary key (id)
);
create table CONTAINER.assignment_progress
(
solved boolean not null,
assignment_id bigint unique,
id bigint generated by default as identity (start with 1),
primary key (id)
);
create table CONTAINER.lesson_progress
(
number_of_attempts integer not null,
@ -55,8 +60,10 @@ create table CONTAINER.email
title VARCHAR(255)
);
alter table CONTAINER.assignment_progress
add constraint FK7o6abukma83ku3xrge9sy0qnr foreign key (assignment_id) references CONTAINER.assignment;
alter table CONTAINER.lesson_progress_assignments
add constraint FKbd9xavuwr1rxbcqhcu3jckyro foreign key (assignments_id) references CONTAINER.assignment;
add constraint FKrw89vmnela8kj0nbg1xdws5bt foreign key (assignments_id) references CONTAINER.assignment_progress;
alter table CONTAINER.lesson_progress_assignments
add constraint FKl8vg2qfqhmsnt18qqcyydq7iu foreign key (lesson_progress_id) references CONTAINER.lesson_progress;
alter table CONTAINER.user_progress_lesson_progress

View File

@ -16,6 +16,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.owasp.webgoat.container.lessons.Assignment;
import org.owasp.webgoat.container.lessons.Lesson;
import org.owasp.webgoat.container.session.Course;
import org.owasp.webgoat.container.users.AssignmentProgress;
import org.owasp.webgoat.container.users.LessonProgress;
import org.owasp.webgoat.container.users.UserProgress;
import org.owasp.webgoat.container.users.UserProgressRepository;
@ -68,10 +69,11 @@ class LessonProgressServiceTest {
@BeforeEach
void setup() {
Assignment assignment = new Assignment("test", "test", List.of());
AssignmentProgress assignmentProgress = new AssignmentProgress(assignment);
when(userProgressRepository.findByUser(any())).thenReturn(userProgress);
when(userProgress.getLessonProgress(any(Lesson.class))).thenReturn(lessonTracker);
when(course.getLessonByName(any())).thenReturn(lesson);
when(lessonTracker.getLessonOverview()).thenReturn(Maps.newHashMap(assignment, true));
when(lessonTracker.getLessonOverview()).thenReturn(Maps.newHashMap(assignmentProgress, true));
this.mockMvc =
MockMvcBuilders.standaloneSetup(new LessonProgressService(userProgressRepository, course))
.build();

View File

@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.owasp.webgoat.container.lessons.Assignment;
import org.owasp.webgoat.container.lessons.Lesson;
import org.owasp.webgoat.container.users.AssignmentProgress;
import org.owasp.webgoat.container.users.LessonProgress;
/**
@ -67,9 +68,8 @@ class LessonTrackerTest {
LessonProgress lessonTracker = new LessonProgress(lesson);
lessonTracker.assignmentSolved("a1");
Map<Assignment, Boolean> lessonOverview = lessonTracker.getLessonOverview();
assertThat(lessonOverview.get(a1)).isTrue();
assertThat(lessonOverview.get(a2)).isFalse();
Map<AssignmentProgress, Boolean> lessonOverview = lessonTracker.getLessonOverview();
assertThat(lessonOverview.values()).containsExactlyInAnyOrder(true, false);
}
@Test