diff --git a/memorystore/valkey/session/README.md b/memorystore/valkey/session/README.md new file mode 100644 index 00000000000..40c8145ae24 --- /dev/null +++ b/memorystore/valkey/session/README.md @@ -0,0 +1,101 @@ +# Session Management + +This demo shows how to use Valkey as an in-memory sessions to store user tokens for quick access in a session management application. By storing user tokens in Valkey, the application can quickly retrieve and validate tokens without having to query the database. + +## Running the application locally + +### 1. Run your database locally. You can download and install PostgresSQL via this [link](https://www.postgresql.org/download/) + +### 2. Run your Valkey server locally. You can download and install Valkey via this [link](https://valkey.io/download/) + +### 3. Ensure that you have a user created called `postgres` + +```bash +createuser -s postgres +``` + +### 4. Next, create the required database tables + +```bash +psql -U postgres -d postgres -f ./app/init.sql +``` + +### 5. Run the app + +```bash +mvn clean spring-boot:run +``` + +### 6. Navigate to the web url `http://localhost:8080` to view your application + +## How to run the application locally (via Docker) + +You can use [docker compose](https://docs.docker.com/compose/install/) to run the app locally. Run the following: + +```bash +cd app +docker-compose up --build +``` + +You can also run the app with sample data by running the following: + +```bash +cd sample-data +docker-compose up --build +``` + +## How to deploy the application to Google Cloud + +1. You can use [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli) to deploy the infrastructure to Google Cloud. Run the following: + + ```bash + cd app + terraform init + terraform apply + ``` + + It should fail to ceate the Google Cloud Run service, but don't worry, we'll fix that in the next series of steps. The message you get might look like this: + + ``` + Error waiting to create Service: Error waiting for Creating Service: Error code 13, message: Revision 'sessions-app-service-00001-9zj' is not ready and cannot serve traffic. Image 'gcr.io/cloudannot serve traffic. Image 'gcr.io/cloud-memorystore-demos/sessions-app:latest' not found. + ``` + +2. Once the infrastructure is created, you'll need to run the `init.sql` script in the Cloud SQL instance to create the necessary tables. You can use the Cloud Shell to do this. Run the following command in the Cloud Shell: + + ```bash + gcloud sql connect --database=sessions-app-db --user=admin # The admin and database were created in the Terraform script + ``` + + Note: Ensure that the instance name is the same as the one you used in the Terraform script. + + a. When prompted to enable the Cloud SQL Admin app, type `Y` and press `Enter`. + b. When prompted to enter the password, type the password you set in the Terraform script and press `Enter`. + c. Once you're connected to the Cloud SQL instance, run the following command to run the `init.sql` script: + + ```sql + \i init.sql + ``` + +3. Finally, redeploy the Cloud Run service using the local source code. Run the following command: + + ```bash + gcloud run deploy \ + --source=. \ + --region= \ + --update-env-vars=ALLOWED_ORIGINS=example.com \ + ``` + + Note: Ensure that the instance name and region are the same as the ones you used in the Terraform script. Also, replace `example.com` with the domain of your frontend application. + +Now you should have the application running on Google Cloud. + +### Endpoints + +- `POST /auth/register` - Registers a new user +- `POST /auth/login` - Logs in a user +- `POST /auth/logout` - Logs out a user +- `POST /auth/verify` - Verifies a user's token +- `GET /api/basket` - Get all items +- `POST /api/basket/add` - Add item with quantity +- `POST /api/basket/remove` - Remove item quantity +- `POST /api/basket/clear` - Clear entire basket diff --git a/memorystore/valkey/session/app/.gitignore b/memorystore/valkey/session/app/.gitignore new file mode 100644 index 00000000000..91618798d77 --- /dev/null +++ b/memorystore/valkey/session/app/.gitignore @@ -0,0 +1,92 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store + +############################## +## Terraform +############################## +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file diff --git a/memorystore/valkey/session/app/Dockerfile b/memorystore/valkey/session/app/Dockerfile new file mode 100644 index 00000000000..131e16d0504 --- /dev/null +++ b/memorystore/valkey/session/app/Dockerfile @@ -0,0 +1,24 @@ +# Use an OpenJDK base image +FROM openjdk:17-jdk-slim + +# Install Maven for building the project +RUN apt-get update && apt-get install -y maven + +# Set the working directory +WORKDIR /app + +# Copy Maven project files +COPY pom.xml ./ +COPY src ./src + +# Build the project +RUN mvn clean package -DskipTests + +# Copy the built JAR file to the container +RUN cp target/app-1.0-SNAPSHOT.jar app.jar + +# Expose the application port +EXPOSE 8080 + +# Run the application +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/memorystore/valkey/session/app/docker-compose.yaml b/memorystore/valkey/session/app/docker-compose.yaml new file mode 100644 index 00000000000..b79c2550d8a --- /dev/null +++ b/memorystore/valkey/session/app/docker-compose.yaml @@ -0,0 +1,51 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: sessions-app + +services: + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + + postgres: + image: postgres:latest + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + + app: + build: + context: . + dockerfile: Dockerfile + container_name: java-app + ports: + - "8080:8080" + depends_on: + - valkey + - postgres + environment: + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password diff --git a/memorystore/valkey/session/app/init.sql b/memorystore/valkey/session/app/init.sql new file mode 100644 index 00000000000..57c4241bc3f --- /dev/null +++ b/memorystore/valkey/session/app/init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS account ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + username VARCHAR(20) NOT NULL, + password VARCHAR(255) NOT NULL +); diff --git a/memorystore/valkey/session/app/main.tf b/memorystore/valkey/session/app/main.tf new file mode 100644 index 00000000000..2bb5d171e5a --- /dev/null +++ b/memorystore/valkey/session/app/main.tf @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +provider "google" { + project = "cloud-memorystore-demos" + region = "us-central1" +} + +data "google_project" "project" { + project_id = "cloud-memorystore-demos" +} + +resource "google_compute_network" "app_network" { + name = "sessions-app-network" +} + +resource "google_compute_firewall" "allow_http" { + name = "sessions-app-allow-http-8080" + network = google_compute_network.app_network.name + + allow { + protocol = "tcp" + ports = ["8080"] + } + + source_ranges = ["0.0.0.0/0"] + + depends_on = [google_compute_network.app_network] +} + +resource "google_cloud_run_v2_service" "api" { + name = "sessions-api-service" + location = "us-central1" + + template { + containers { + image = "replace" # Will be set at a later time + + env { + name = "VALKEY_HOST" + value = module.valkey.discovery_endpoints[0]["address"] + } + + env { + name = "VALKEY_PORT" + value = module.valkey.discovery_endpoints[0]["port"] + } + + env { + name = "DB_URL" + value = "jdbc:postgresql://${google_sql_database_instance.postgres.public_ip_address}/${google_sql_database.postgres_db.name}" + } + + env { + name = "DB_USERNAME" + value = google_sql_user.postgres_user.name + } + + env { + name = "DB_PASSWORD" + value = google_sql_user.postgres_user.password + } + + ports { + container_port = 8080 + } + } + + vpc_access { + network_interfaces { + network = google_compute_network.app_network.name + subnetwork = google_compute_network.app_network.name + tags = [] + } + } + } + + depends_on = [ + google_compute_network.app_network, + module.valkey, + google_sql_database_instance.postgres + ] +} + +module "valkey" { + source = "terraform-google-modules/memorystore/google//modules/valkey" + version = "12.0" + + instance_id = "sessions-app-valkey-instance" + project_id = data.google_project.project.project_id + location = "us-central1" + node_type = "SHARED_CORE_NANO" + shard_count = 1 + engine_version = "VALKEY_7_2" + + network = google_compute_network.app_network.name + + service_connection_policies = { + sessions_valkey_scp = { + subnet_names = [google_compute_network.app_network.name] + } + } + + depends_on = [google_compute_network.app_network] +} + +resource "google_sql_database_instance" "postgres" { + name = "sessions-app-postgres-instance" + database_version = "POSTGRES_16" + region = "us-central1" + + settings { + edition = "ENTERPRISE" + tier = "db-custom-1-3840" + + ip_configuration { + ipv4_enabled = true + + authorized_networks { + name = "sessions-app-access" + value = "0.0.0.0/0" + } + } + } + + depends_on = [google_compute_network.app_network] +} + +resource "google_sql_user" "postgres_user" { + name = "admin" + instance = google_sql_database_instance.postgres.name + password = "password123" # Set this to the password you want to use for the user + + depends_on = [google_sql_database_instance.postgres] +} + +resource "google_sql_database" "postgres_db" { + name = "sessions-app-db" + instance = google_sql_database_instance.postgres.name + + depends_on = [google_sql_database_instance.postgres] +} \ No newline at end of file diff --git a/memorystore/valkey/session/app/pom.xml b/memorystore/valkey/session/app/pom.xml new file mode 100644 index 00000000000..4b72b5539ef --- /dev/null +++ b/memorystore/valkey/session/app/pom.xml @@ -0,0 +1,155 @@ + + + + + + 4.0.0 + + org.example + app + 1.0-SNAPSHOT + + + + com.google.cloud.samples + shared-configuration + 1.2.0 + + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + 3.3.6 + + + + + org.slf4j + slf4j-api + 2.0.16 + + + org.slf4j + slf4j-simple + 2.0.16 + + + + + org.springframework.security + spring-security-crypto + 5.6.0 + + + + + redis.clients + jedis + 4.3.0 + + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.3.6 + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + 3.3.6 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + + org.springframework.boot + spring-boot-starter-test + 3.3.6 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + -parameters + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + true + + + app.Main + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M8 + + + + \ No newline at end of file diff --git a/memorystore/valkey/session/app/src/main/java/app/AccountRepository.java b/memorystore/valkey/session/app/src/main/java/app/AccountRepository.java new file mode 100644 index 00000000000..a4fad303499 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/AccountRepository.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Handles CRUD operations for the account table. */ + +package app; + +import java.util.Map; +import java.util.Optional; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Repository; + +@Repository +public class AccountRepository { + + private final JdbcTemplate jdbcTemplate; + + public AccountRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Optional authenticateUser(String username, String password) { + try { + // Fetch hashedPassword and userId in a single query + Map accountData = + jdbcTemplate.queryForMap( + "SELECT id, password FROM account WHERE username = ?", username); + + String hashedPassword = (String) accountData.get("password"); + Integer userId = (Integer) accountData.get("id"); + + // Check password validity + if (hashedPassword != null && BCrypt.checkpw(password, hashedPassword)) { + return Optional.of(userId); // Authentication successful + } else { + return Optional.empty(); // Authentication failed + } + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); // No user found + } + } + + public void registerUser(String email, String username, String password) { + // Validate input + if (email == null || username == null || password == null) { + throw new IllegalArgumentException("Email, username, and password must not be null"); + } + + // Hash the password to securely store it + String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt()); + + // Insert user into the database + jdbcTemplate.update( + "INSERT INTO account (email, username, password) VALUES (?, ?, ?)", + email, + username, + hashedPassword); + } + + public boolean isEmailRegistered(String email) { + String sql = "SELECT EXISTS (SELECT 1 FROM account WHERE email = ?)"; + return Boolean.TRUE.equals(jdbcTemplate.queryForObject(sql, Boolean.class, email)); + } + + public boolean isUsernameRegistered(String username) { + String sql = "SELECT EXISTS (SELECT 1 FROM account WHERE username = ?)"; + return Boolean.TRUE.equals(jdbcTemplate.queryForObject(sql, Boolean.class, username)); + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/AuthController.java b/memorystore/valkey/session/app/src/main/java/app/AuthController.java new file mode 100644 index 00000000000..d6e66327d10 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/AuthController.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The Auth controller for the application. + * + *

The controller contains the following endpoints: - POST /auth/register - Registers a new user + * - POST /auth/login - Logs in a user - POST /auth/logout - Logs out a user - POST /auth/verify - + * Verifies a user's token + */ + +package app; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final DataController dataController; + + public AuthController(DataController dataController) { + this.dataController = dataController; + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterInfo info) { + String email = info.email; + String username = info.username; + String password = info.password; + + // Validate email + if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) { + return ResponseEntity.badRequest().body(Global.EMAIL_INVALID); + } + + // Validate username + if (!username.matches("^[a-zA-Z0-9._-]+$")) { + return ResponseEntity.badRequest().body(Global.USERNAME_INVALID); + } else if (username.length() < 3 || username.length() > 20) { + return ResponseEntity.badRequest().body(Global.USERNAME_LENGTH); + } + + // Validate password + if (password.length() < 8 || password.length() > 255) { + return ResponseEntity.badRequest().body(Global.PASSWORD_LENGTH); + } + + // Check if email or username is already taken + if (dataController.checkIfEmailExists(email)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Global.EMAIL_ALREADY_REGISTERED); + } + if (dataController.checkIfUsernameExists(username)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Global.USERNAME_TAKEN); + } + + // Register user + dataController.register(email, username, password); + return ResponseEntity.ok(Global.REGISTERED); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginInfo info, HttpServletResponse response) { + String username = info.username; + String password = info.password; + + // Attempt to log in + String token = dataController.login(username, password); + + // Invalid credentials + if (token == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Global.INVALID_CREDENTIALS); + } + + // Create and set a cookie + response.addCookie(Utils.createCookie(token)); + return ResponseEntity.ok(Global.LOGGED_IN); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + String token = Utils.getTokenFromCookie(request.getCookies()); + if (token == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Global.INVALID_TOKEN); + } + + // Logout user + dataController.logout(token); + + return ResponseEntity.ok(Global.LOGGED_OUT); + } + + @PostMapping("/verify") + public ResponseEntity verify(HttpServletRequest request, HttpServletResponse response) { + String token = Utils.getTokenFromCookie(request.getCookies()); + if (token == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Global.INVALID_TOKEN); + } + + // Verify token and extend session + String username = dataController.verify(token); + if (username == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Global.INVALID_TOKEN); + } + + // Refresh cookie expiration + Cookie cookie = Utils.createCookie(token); + response.addCookie(cookie); + return ResponseEntity.ok( + new VerifyResponse(username, cookie.getMaxAge()).toJson().toString()); + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/BasketController.java b/memorystore/valkey/session/app/src/main/java/app/BasketController.java new file mode 100644 index 00000000000..9bcc07bbc2c --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/BasketController.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The Auth controller for the application. + * + *

The controller contains the following endpoints: - GET /api/basket - Get all items - POST + * /api/basket/add - Add item with quantity - POST /api/basket/remove - Remove item quantity - POST + * /api/basket/clear - Clear entire basket + */ + +package app; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import redis.clients.jedis.Jedis; + +@RestController +@RequestMapping("/api/basket") +public class BasketController { + + private final Jedis jedis; + + public BasketController(Jedis jedis) { + this.jedis = jedis; + } + + // Get all items + @GetMapping + public ResponseEntity> getBasket(HttpServletRequest request) { + String basketKey = getBasketKey(request); + return ResponseEntity.ok(jedis.hgetAll(basketKey)); + } + + // Add item with quantity + @PostMapping("/add") + public ResponseEntity addItem( + @RequestParam String itemId, + @RequestParam(defaultValue = "1") int quantity, + HttpServletRequest request) { + String basketKey = getBasketKey(request); + long newQty = jedis.hincrBy(basketKey, itemId, quantity); + return ResponseEntity.ok("Quantity updated: " + newQty); + } + + // Remove item quantity + @PostMapping("/remove") + public ResponseEntity removeItem( + @RequestParam String itemId, + @RequestParam(defaultValue = "1") int quantity, + HttpServletRequest request) { + String basketKey = getBasketKey(request); + long newQty = jedis.hincrBy(basketKey, itemId, -quantity); + if (newQty <= 0) { + jedis.hdel(basketKey, itemId); + return ResponseEntity.ok("Item removed"); + } + return ResponseEntity.ok("Quantity updated: " + newQty); + } + + // Clear entire basket + @PostMapping("/clear") + public ResponseEntity clearBasket(HttpServletRequest request) { + jedis.del(getBasketKey(request)); + return ResponseEntity.ok("Basket cleared"); + } + + private String getBasketKey(HttpServletRequest request) { + String token = Utils.getTokenFromCookie(request.getCookies()); + return "basket:" + token; + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/BasketItem.java b/memorystore/valkey/session/app/src/main/java/app/BasketItem.java new file mode 100644 index 00000000000..dba98a3e536 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/BasketItem.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +public class BasketItem { + + private int id; + private int quantity; + + public BasketItem(int id, int quantity) { + this.id = id; + this.quantity = quantity; + } + + public int getId() { + return id; + } + + public int getQuantity() { + return quantity; + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/DataController.java b/memorystore/valkey/session/app/src/main/java/app/DataController.java new file mode 100644 index 00000000000..8055f40922d --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/DataController.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Responsible for handling the data operations between the API, Valkey, and the database. */ + +package app; + +import java.util.Optional; +import org.springframework.stereotype.Controller; +import redis.clients.jedis.Jedis; + +@Controller +public class DataController { + + private final AccountRepository accountRepository; + private final Jedis jedis; + + public DataController(AccountRepository accountRepository, Jedis jedis) { + this.accountRepository = accountRepository; + this.jedis = jedis; + } + + public void register(String email, String username, String password) { + accountRepository.registerUser(email, username, password); + } + + public String login(String username, String password) { + // Authenticate user + Optional userId = accountRepository.authenticateUser(username, password); + + // No user found + if (userId.isEmpty()) { + return null; + } + + // Generate token for the user + String token = Utils.generateToken(Global.TOKEN_BYTE_LENGTH); + + // Store token in Valkey + jedis.setex(token, Global.TOKEN_EXPIRATION, username); + + return token; + } + + public void logout(String token) { + jedis.del(token); + } + + public String verify(String token) { + // Retrieve username from Valkey + String username = jedis.get(token); + + // No username found for the token + if (username == null) { + return null; + } + + // Extend token expiration + jedis.expire(token, Global.TOKEN_EXPIRATION); + + return username; + } + + public boolean checkIfEmailExists(String email) { + return accountRepository.isEmailRegistered(email); + } + + public boolean checkIfUsernameExists(String username) { + return accountRepository.isUsernameRegistered(username); + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/Global.java b/memorystore/valkey/session/app/src/main/java/app/Global.java new file mode 100644 index 00000000000..f6729446aba --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/Global.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Global constants for the application. */ + +package app; + +public class Global { + + public static final String INVALID_CREDENTIALS = "Invalid username or password"; + public static final String INVALID_TOKEN = "Invalid token"; + public static final String REGISTERED = "User registered successfully"; + public static final String EMAIL_INVALID = "Invalid email format"; + public static final String EMAIL_ALREADY_REGISTERED = "Email is already registered"; + public static final String USERNAME_INVALID = + "Username must only contain letters, numbers, periods, underscores, and hyphens"; + public static final String USERNAME_LENGTH = "Username must be between 3 and 20 characters"; + public static final String USERNAME_TAKEN = "Username is already taken"; + public static final String PASSWORD_LENGTH = "Password must be between 8 and 255 characters"; + public static final String LOGGED_IN = "Logged in"; + public static final String LOGGED_OUT = "Logged out"; + + public static final Integer TOKEN_BYTE_LENGTH = 128; + public static final Integer TOKEN_EXPIRATION = + 1800; // Token expiration time in seconds (30 minutes) + public static final String TOKEN_COOKIE_NAME = "token"; +} diff --git a/memorystore/valkey/session/app/src/main/java/app/HomeController.java b/memorystore/valkey/session/app/src/main/java/app/HomeController.java new file mode 100644 index 00000000000..01beb956154 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/HomeController.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @GetMapping("/") + public String home(Model model) { + return "index"; // Refers to templates/index.html + } + + @GetMapping("/login") + public String login(Model model) { + return "login"; // Refers to templates/login.html + } + + @GetMapping("/register") + public String logout(Model model) { + return "register"; // Refers to templates/register.html + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/session/app/src/main/java/app/JdbcConfig.java new file mode 100644 index 00000000000..dddc46ec17f --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/JdbcConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Configuration for the JDBC DataSource to connect to the PostgreSQL server. */ + +package app; + +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + + +@Configuration +public class JdbcConfig { + + // Database configuration properties with environment variable fallback + @Value("${DB_URL:jdbc:postgresql://localhost:5432/default_db}") + private String url; + + @Value("${DB_USERNAME:postgres}") + private String username; + + @Value("${DB_PASSWORD:}") + private String password; + + @Bean + public DataSource dataSource() { + // Validate mandatory properties + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("Database URL (DB_URL) is not configured"); + } + if (username == null || username.isEmpty()) { + throw new IllegalArgumentException("Database username (DB_USERNAME) is not configured"); + } + + // Set up the DataSource + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.postgresql.Driver"); + dataSource.setUrl(url); + dataSource.setUsername(username); + dataSource.setPassword(password); + + return dataSource; + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/session/app/src/main/java/app/JedisConfig.java new file mode 100644 index 00000000000..a395aee059b --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/JedisConfig.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Configuration for the Jedis client to connect to the Valkey server. */ + +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import redis.clients.jedis.Jedis; + +@Configuration +public class JedisConfig { + + // Redis server configuration properties + @Value("${VALKEY_HOST:localhost}") // Default to localhost if not set + private String redisHost; + + @Value("${VALKEY_PORT:6379}") // Default to 6379 if not set + private int redisPort; + + @Value("${VALKEY_PASSWORD:}") // Empty by default if not set + private String redisPassword; + + @Bean + public Jedis jedis() { + // Validate mandatory properties + if (redisHost == null || redisHost.isEmpty()) { + throw new IllegalArgumentException("Redis host (VALKEY_HOST) is not configured"); + } + if (redisPort <= 0 || redisPort > 65535) { + throw new IllegalArgumentException("Redis port (VALKEY_PORT) is invalid"); + } + + Jedis jedis = new Jedis(redisHost, redisPort); + + // Authenticate if a password is set + if (!redisPassword.isEmpty()) { + jedis.auth(redisPassword); + } + + // Verify the connection to the Redis server + try { + jedis.ping(); + } catch (Exception e) { + throw new RuntimeException( + "Failed to connect to Redis server at " + redisHost + ":" + redisPort, e); + } + + return jedis; + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/LoginInfo.java b/memorystore/valkey/session/app/src/main/java/app/LoginInfo.java new file mode 100644 index 00000000000..343e46da595 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/LoginInfo.java @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Data class for holding login information. */ + +package app; + +public class LoginInfo { + + public String username; + public String password; + + public LoginInfo(String username, String password) { + this.username = username; + this.password = password; + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/Main.java b/memorystore/valkey/session/app/src/main/java/app/Main.java new file mode 100644 index 00000000000..890d5037fcd --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/Main.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Main class for the Spring Boot application. + */ + +package app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Main { + + /** + * Main method for the Spring Boot application. + * + * @param args Command line arguments + */ + public static void main(final String[] args) { + SpringApplication.run(Main.class, args); + } + + /** Dummy method to trick Checkstyle. */ + public void avoidCheckstyleError() { + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/RegisterInfo.java b/memorystore/valkey/session/app/src/main/java/app/RegisterInfo.java new file mode 100644 index 00000000000..6130ec5e93f --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/RegisterInfo.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Data class for holding registration information. */ + +package app; + +public class RegisterInfo { + + public String email; + public String username; + public String password; + + public RegisterInfo(String email, String username, String password) { + this.email = email; + this.username = username; + this.password = password; + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/Utils.java b/memorystore/valkey/session/app/src/main/java/app/Utils.java new file mode 100644 index 00000000000..7279313aa9c --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/Utils.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Utility class for generating secure tokens and managing cookies. */ + +package app; + +import jakarta.servlet.http.Cookie; +import java.security.SecureRandom; +import java.sql.Timestamp; +import java.util.Base64; + +public class Utils { + + public static String generateToken(int tokenByteLength) { + // SecureRandom ensures cryptographic security + SecureRandom secureRandom = new SecureRandom(); + byte[] randomBytes = new byte[tokenByteLength]; + secureRandom.nextBytes(randomBytes); + + // Encode the random bytes into a URL-safe Base64 string + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } + + public static String getTokenFromCookie(Cookie[] cookies) { + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (cookie.getName().equals(Global.TOKEN_COOKIE_NAME)) { + return cookie.getValue(); + } + } + return null; + } + + public static Cookie createCookie(String token) { + Cookie cookie = new Cookie(Global.TOKEN_COOKIE_NAME, token); + cookie.setPath("/"); // Available across the app + cookie.setMaxAge(Global.TOKEN_EXPIRATION); // Set expiration + + return cookie; + } + + public static Timestamp getFutureTimestamp(long seconds) { + long currentTime = System.currentTimeMillis(); + long futureTime = currentTime + (seconds * 1000); + return new Timestamp(futureTime); + } +} diff --git a/memorystore/valkey/session/app/src/main/java/app/VerifyResponse.java b/memorystore/valkey/session/app/src/main/java/app/VerifyResponse.java new file mode 100644 index 00000000000..00cb9650dbc --- /dev/null +++ b/memorystore/valkey/session/app/src/main/java/app/VerifyResponse.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This class is used to create a response object for the verify endpoint. It contains the username + * and expiration timestamp of the token. + */ + +package app; + +import org.json.JSONObject; + +public class VerifyResponse { + + private String username; + private int expirationSecs; + + public VerifyResponse(String username, int expiration) { + this.username = username; + this.expirationSecs = expiration; + } + + public JSONObject toJson() { + JSONObject json = new JSONObject(); + json.put("username", username); + json.put("expirationSecs", expirationSecs); + return json; + } +} diff --git a/memorystore/valkey/session/app/src/main/resources/public/items.json b/memorystore/valkey/session/app/src/main/resources/public/items.json new file mode 100644 index 00000000000..747fd94b759 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/public/items.json @@ -0,0 +1,202 @@ +[ + { + "id": 1, + "name": "Item 1", + "description": "Description for item 1.", + "price": 268.83, + "category": "Home", + "stock": 20, + "rating": 1.9, + "image_url": "https://picsum.photos/id/101/300/200" + }, + { + "id": 2, + "name": "Item 2", + "description": "Description for item 2.", + "price": 438.3, + "category": "Beauty", + "stock": 54, + "rating": 2.4, + "image_url": "https://picsum.photos/id/102/300/200" + }, + { + "id": 3, + "name": "Item 3", + "description": "Description for item 3.", + "price": 432.23, + "category": "Electronics", + "stock": 5, + "rating": 1.6, + "image_url": "https://picsum.photos/id/103/300/200" + }, + { + "id": 4, + "name": "Item 4", + "description": "Description for item 4.", + "price": 118.8, + "category": "Books", + "stock": 90, + "rating": 1.7, + "image_url": "https://picsum.photos/id/104/300/200" + }, + { + "id": 5, + "name": "Item 5", + "description": "Description for item 5.", + "price": 68.23, + "category": "Clothing", + "stock": 44, + "rating": 4.5, + "image_url": "https://picsum.photos/id/100/300/200" + }, + { + "id": 6, + "name": "Item 6", + "description": "Description for item 6.", + "price": 295.92, + "category": "Clothing", + "stock": 34, + "rating": 2.8, + "image_url": "https://picsum.photos/id/106/300/200" + }, + { + "id": 7, + "name": "Item 7", + "description": "Description for item 7.", + "price": 11.74, + "category": "Electronics", + "stock": 75, + "rating": 3.1, + "image_url": "https://picsum.photos/id/107/300/200" + }, + { + "id": 8, + "name": "Item 8", + "description": "Description for item 8.", + "price": 199.74, + "category": "Electronics", + "stock": 85, + "rating": 1.4, + "image_url": "https://picsum.photos/id/108/300/200" + }, + { + "id": 9, + "name": "Item 9", + "description": "Description for item 9.", + "price": 446.12, + "category": "Books", + "stock": 92, + "rating": 4.3, + "image_url": "https://picsum.photos/id/109/300/200" + }, + { + "id": 10, + "name": "Item 10", + "description": "Description for item 10.", + "price": 158.55, + "category": "Toys", + "stock": 76, + "rating": 1.3, + "image_url": "https://picsum.photos/id/110/300/200" + }, + { + "id": 11, + "name": "Item 11", + "description": "Description for item 11.", + "price": 123.06, + "category": "Clothing", + "stock": 10, + "rating": 1.1, + "image_url": "https://picsum.photos/id/111/300/200" + }, + { + "id": 12, + "name": "Item 12", + "description": "Description for item 12.", + "price": 399.79, + "category": "Home", + "stock": 91, + "rating": 3.5, + "image_url": "https://picsum.photos/id/112/300/200" + }, + { + "id": 13, + "name": "Item 13", + "description": "Description for item 13.", + "price": 22.95, + "category": "Clothing", + "stock": 98, + "rating": 1.5, + "image_url": "https://picsum.photos/id/113/300/200" + }, + { + "id": 14, + "name": "Item 14", + "description": "Description for item 14.", + "price": 178.61, + "category": "Beauty", + "stock": 58, + "rating": 4.3, + "image_url": "https://picsum.photos/id/114/300/200" + }, + { + "id": 15, + "name": "Item 15", + "description": "Description for item 15.", + "price": 447.49, + "category": "Beauty", + "stock": 6, + "rating": 4.6, + "image_url": "https://picsum.photos/id/115/300/200" + }, + { + "id": 16, + "name": "Item 16", + "description": "Description for item 16.", + "price": 54.81, + "category": "Books", + "stock": 9, + "rating": 3.2, + "image_url": "https://picsum.photos/id/116/300/200" + }, + { + "id": 17, + "name": "Item 17", + "description": "Description for item 17.", + "price": 272.11, + "category": "Home", + "stock": 97, + "rating": 4.7, + "image_url": "https://picsum.photos/id/117/300/200" + }, + { + "id": 18, + "name": "Item 18", + "description": "Description for item 18.", + "price": 273.65, + "category": "Toys", + "stock": 13, + "rating": 4.1, + "image_url": "https://picsum.photos/id/118/300/200" + }, + { + "id": 19, + "name": "Item 19", + "description": "Description for item 19.", + "price": 300.57, + "category": "Toys", + "stock": 74, + "rating": 1.8, + "image_url": "https://picsum.photos/id/119/300/200" + }, + { + "id": 20, + "name": "Item 20", + "description": "Description for item 20.", + "price": 251.68, + "category": "Beauty", + "stock": 51, + "rating": 2.4, + "image_url": "https://picsum.photos/id/120/300/200" + } +] diff --git a/memorystore/valkey/session/app/src/main/resources/static/basket.js b/memorystore/valkey/session/app/src/main/resources/static/basket.js new file mode 100644 index 00000000000..2e32d422ac0 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/static/basket.js @@ -0,0 +1,246 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +let basket = {}; + +function fetchBasket() { + fetch("http://localhost:8080/api/basket", { + method: "GET", + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "localhost:3000", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Credentials": "true", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load basket: ${response.statusText}`); + } + return response.json(); + }) + .then((data) => { + if (!data) return; + console.log("Loaded basket:", data); + + // returned data is a map + basket = {}; + Object.keys(data).forEach((key) => { + basket[key] = { id: parseInt(key), quantity: parseInt(data[key]) }; + }); + + updateBasketDisplay(); + }) + .catch((error) => { + console.error("Error loading basket:", error); + alert("Failed to load basket. Please try again later."); + }); +} + +function requestAddItem(itemId, quantity) { + let msTaken = new Date().getTime(); + + fetch( + "http://localhost:8080/api/basket/add?itemId=" + + itemId + + "&quantity=" + + quantity, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "localhost:3000", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Credentials": "true", + }, + } + ) + .then((response) => { + msTaken = new Date().getTime() - msTaken; + document.getElementById("response-time").textContent = msTaken; + + if (!response.ok) { + throw new Error(`Failed to add item: ${response.statusText}`); + } + return response.text(); + }) + .then(() => { + if (basket[itemId]) { + basket[itemId].quantity += quantity; + } else { + basket[itemId] = { id: itemId, quantity }; + } + + updateBasketDisplay(); + }) + .catch((error) => { + console.error("Error adding item:", error); + alert("Failed to add item. Please try again later."); + }); +} + +function requestRemoveItem(itemId, quantity) { + let msTaken = new Date().getTime(); + + fetch( + "http://localhost:8080/api/basket/remove?itemId=" + + itemId + + "&quantity=" + + quantity, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "localhost:3000", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Credentials": "true", + }, + } + ) + .then((response) => { + msTaken = new Date().getTime() - msTaken; + document.getElementById("response-time").textContent = msTaken; + + if (!response.ok) { + throw new Error(`Failed to remove item: ${response.statusText}`); + } + return response.text(); + }) + .then(() => { + if (basket[itemId]) { + basket[itemId].quantity -= quantity; + if (basket[itemId].quantity <= 0) { + delete basket[itemId]; + } + } + + updateBasketDisplay(); + }) + .catch((error) => { + console.error("Error removing item:", error); + alert("Failed to remove item. Please try again later."); + }); +} + +function requestClearBasket() { + let msTaken = new Date().getTime(); + + fetch("http://localhost:8080/api/basket/clear", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "localhost:3000", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Credentials": "true", + }, + }) + .then((response) => { + msTaken = new Date().getTime() - msTaken; + document.getElementById("response-time").textContent = msTaken; + + if (!response.ok) { + throw new Error(`Failed to clear basket: ${response.statusText}`); + } + return response.text(); + }) + .then(() => { + basket = {}; + updateBasketDisplay(); + }) + .catch((error) => { + console.error("Error clearing basket:", error); + alert("Failed to clear basket. Please try again later."); + }); +} + +// Add item to basket +function addToBasket(itemId) { + if (!window.items || window.items.length === 0) { + alert("Items are not loaded yet. Please try again later."); + return; + } + + requestAddItem(itemId, 1); +} + +// Remove item from basket +function removeFromBasket(itemId) { + if (!window.items || window.items.length === 0) { + alert("Items are not loaded yet. Please try again later."); + return; + } + + requestRemoveItem(itemId, 1); +} + +// Clear basket +function clearBasket() { + requestClearBasket(); +} + +// Purchase items +function checkoutItems() { + alert("This operation is not supported in the demo."); +} + +// Update basket display +function updateBasketDisplay() { + const basketItems = document.getElementById("basket-items"); + const basketTotal = document.getElementById("basket-total"); + basketItems.innerHTML = ""; + let total = 0; + + if (Object.keys(basket).length === 0) { + basketItems.innerHTML = ` +

Your basket is empty.

+ `; + basketTotal.textContent = "$0.00"; + return; + } + + Object.values(basket).forEach((item) => { + const itemData = window.items.find((i) => i.id === item.id); + total += itemData.price * item.quantity; + + const itemElement = document.createElement("div"); + itemElement.className = "flex justify-between items-center mb-2"; + itemElement.innerHTML = ` +
+

${itemData.name}

+

$${itemData.price.toFixed(2)} x ${ + item.quantity + }

+
+ + `; + + basketItems.appendChild(itemElement); + }); + + basketTotal.textContent = `$${total.toFixed(2)}`; +} + +// Expose functions globally for onclick handlers +window.addToBasket = addToBasket; +window.removeFromBasket = removeFromBasket; +window.clearBasket = clearBasket; +window.checkoutItems = checkoutItems; diff --git a/memorystore/valkey/session/app/src/main/resources/static/main.js b/memorystore/valkey/session/app/src/main/resources/static/main.js new file mode 100644 index 00000000000..635086d4490 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/static/main.js @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +window.items = []; + +let msTaken = new Date().getTime(); + +verifyToken().then((data) => { + msTaken = new Date().getTime() - msTaken; + + if (!data) { + window.location.href = "/login"; + return; + } + + const { username, expirationSecs } = data; + + document.getElementById("response-time").textContent = msTaken; + document.getElementById("session-token").textContent = + document.cookie.split("=")[1]; + document.getElementById("session-expiry").textContent = + formatTime(expirationSecs); + document.querySelector(".username-value").textContent = username; + + document + .querySelectorAll(".loading-required") + .forEach((el) => el.classList.remove("hidden")); + document.querySelector(".loading-required-inverse").classList.add("hidden"); + + // Fetch items from the JSON file + fetch("/items.json") + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to fetch items: ${response.statusText}`); + } + return response.json(); + }) + .then((items) => { + window.items = items; + + // Populate product grid + const productGrid = document.getElementById("product-grid"); + items.forEach((item) => { + const card = document.createElement("div"); + card.className = "bg-white p-4 rounded-lg shadow-md"; + card.innerHTML = ` + ${
+          item.name
+        } +

${item.name}

+

${item.description}

+

$${item.price.toFixed( + 2 + )}

+ + `; + productGrid.appendChild(card); + }); + + fetchBasket(); // Load the basket from the server + updateBasketDisplay(); // Initialize the basket display + + document.getElementById("shopping-interface").classList.remove("hidden"); + }) + .catch((error) => { + console.error("Error loading items:", error); + alert("Failed to load items. Please try again later."); + }); +}); + +function logout() { + fetch("/auth/logout", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: document.cookie.split("=")[1], + }), + }).then(() => { + document.cookie = + "session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + window.location.href = "/login"; + }); +} + +window.logout = logout; diff --git a/memorystore/valkey/session/app/src/main/resources/static/utils.js b/memorystore/valkey/session/app/src/main/resources/static/utils.js new file mode 100644 index 00000000000..286c60c7e00 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/static/utils.js @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function formatTime(seconds) { + if (seconds < 0) { + throw new Error("Seconds cannot be negative"); + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + const timeParts = []; + + if (hours > 0) timeParts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); + if (minutes > 0) + timeParts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`); + if (remainingSeconds > 0 || timeParts.length === 0) { + timeParts.push( + `${remainingSeconds} ${remainingSeconds === 1 ? "second" : "seconds"}` + ); + } + + return timeParts.join(", "); +} diff --git a/memorystore/valkey/session/app/src/main/resources/static/verify.js b/memorystore/valkey/session/app/src/main/resources/static/verify.js new file mode 100644 index 00000000000..7b531d9b21e --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/static/verify.js @@ -0,0 +1,55 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +async function verifyToken() { + // Get from cachingMode from localStorage + const cachingModeLocalStorage = localStorage.getItem("cachingMode"); + let cachingMode = + cachingModeLocalStorage === null + ? true + : cachingModeLocalStorage === "true"; + + // Verify session + const data = await fetch( + "http://localhost:8080/auth/verify?useCaching=" + cachingMode, + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "localhost:3000", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Credentials": "true", + }, + } + ) + .then((response) => { + if (!response.ok) { + return null; + } + + return response.json(); + }) + .then((data) => { + if (data === null) { + return null; + } + + return data; + }); + + return data; +} diff --git a/memorystore/valkey/session/app/src/main/resources/templates/index.html b/memorystore/valkey/session/app/src/main/resources/templates/index.html new file mode 100644 index 00000000000..40ef7c1c6c4 --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/templates/index.html @@ -0,0 +1,200 @@ + + + + + + + Session Management + + + + + + +
+
+ + + +
+ + +
+ +
+
+

Loading products...

+
+ + + +
+ + +
+
+ Response Time: + Calculating... + ms +
+ +
+
+ Session Token: + None +
+
+ Session Expiry: + None +
+
+
+ + + + + + + diff --git a/memorystore/valkey/session/app/src/main/resources/templates/login.html b/memorystore/valkey/session/app/src/main/resources/templates/login.html new file mode 100644 index 00000000000..2d0e9e3216a --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/templates/login.html @@ -0,0 +1,127 @@ + + + + + + + Session Management + + + + +

Loading...

+

Login

+ + + + + + diff --git a/memorystore/valkey/session/app/src/main/resources/templates/register.html b/memorystore/valkey/session/app/src/main/resources/templates/register.html new file mode 100644 index 00000000000..98c2260fd1e --- /dev/null +++ b/memorystore/valkey/session/app/src/main/resources/templates/register.html @@ -0,0 +1,153 @@ + + + + + + + Session Management + + + + +

Loading...

+

Register

+ + + + + + diff --git a/memorystore/valkey/session/app/src/test/java/app/AuthControllerTest.java b/memorystore/valkey/session/app/src/test/java/app/AuthControllerTest.java new file mode 100644 index 00000000000..a585599d781 --- /dev/null +++ b/memorystore/valkey/session/app/src/test/java/app/AuthControllerTest.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; +import static org.mockito.Mockito.verify; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +class AuthControllerTest { + + @Mock + private DataController dataController; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + private AuthController authController; + + @BeforeEach + void setUp() { + dataController = Mockito.mock(DataController.class); + request = Mockito.mock(HttpServletRequest.class); + response = Mockito.mock(HttpServletResponse.class); + authController = new AuthController(dataController); + } + + @Nested + @DisplayName("Testing register() method") + class RegisterTests { + + @Test + @DisplayName("Should return 400 if email is invalid") + void testRegister_InvalidEmail() { + RegisterInfo info = new RegisterInfo("invalidEmail", "username", "password123"); + + ResponseEntity response = authController.register(info); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals(Global.EMAIL_INVALID, response.getBody()); + } + + @Test + @DisplayName("Should return 409 if email is already registered") + void testRegister_EmailAlreadyRegistered() { + given(dataController.checkIfEmailExists("test@example.com")).willReturn(true); + RegisterInfo info = new RegisterInfo("test@example.com", "username", "password123"); + + ResponseEntity response = authController.register(info); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertEquals(Global.EMAIL_ALREADY_REGISTERED, response.getBody()); + } + + @Test + @DisplayName("Should return 200 if registration is successful") + void testRegister_Success() { + RegisterInfo info = new RegisterInfo("test@example.com", "username", "password123"); + + ResponseEntity response = authController.register(info); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(Global.REGISTERED, response.getBody()); + verify(dataController).register(info.email, info.username, info.password); + } + } + + @Nested + @DisplayName("Testing login() method") + class LoginTests { + + @Test + @DisplayName("Should return 401 for invalid credentials") + void testLogin_InvalidCredentials() { + LoginInfo info = new LoginInfo("username", "wrongPassword"); + + given(dataController.login(info.username, info.password)).willReturn(null); + + HttpServletResponse mockResponse = Mockito.mock(HttpServletResponse.class); + ResponseEntity response = authController.login(info, mockResponse); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertEquals(Global.INVALID_CREDENTIALS, response.getBody()); + } + + @Test + @DisplayName("Should return 200 and set cookie for valid credentials") + void testLogin_ValidCredentials() { + LoginInfo info = new LoginInfo("username", "password123"); + String token = "validToken"; + + given(dataController.login(info.username, info.password)).willReturn(token); + + ResponseEntity responseEntity = authController.login(info, response); + + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + verify(response).addCookie(any(Cookie.class)); + } + } + + @Nested + @DisplayName("Testing logout() method") + class LogoutTests { + + @Test + @DisplayName("Should return 401 if token is missing") + void testLogout_NoToken() { + given(request.getCookies()).willReturn(null); + + ResponseEntity responseEntity = authController.logout(request); + + assertEquals(HttpStatus.UNAUTHORIZED, responseEntity.getStatusCode()); + assertEquals(Global.INVALID_TOKEN, responseEntity.getBody()); + } + + @Test + @DisplayName("Should return 200 and logout user if token is valid") + void testLogout_ValidToken() { + Cookie tokenCookie = new Cookie("token", "validToken"); + given(request.getCookies()).willReturn(new Cookie[] { tokenCookie }); + + ResponseEntity responseEntity = authController.logout(request); + + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + assertEquals(Global.LOGGED_OUT, responseEntity.getBody()); + verify(dataController).logout("validToken"); + } + } + + @Nested + @DisplayName("Testing verify() method") + class VerifyTests { + + @Test + @DisplayName("Should return 401 if token is missing") + void testVerify_NoToken() { + given(request.getCookies()).willReturn(null); + + ResponseEntity responseEntity = authController.verify(request, response); + + assertEquals(HttpStatus.UNAUTHORIZED, responseEntity.getStatusCode()); + assertEquals(Global.INVALID_TOKEN, responseEntity.getBody()); + } + + @Test + @DisplayName("Should return 200 and username if token is valid") + void testVerify_ValidToken() { + Cookie tokenCookie = new Cookie("token", "validToken"); + given(request.getCookies()).willReturn(new Cookie[] { tokenCookie }); + given(dataController.verify("validToken")).willReturn("username"); + + ResponseEntity responseEntity = authController.verify(request, response); + + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + verify(response).addCookie(any(Cookie.class)); + } + + @Test + @DisplayName("Should return 401 if token is invalid") + void testVerify_InvalidToken() { + Cookie tokenCookie = new Cookie("token", "invalidToken"); + given(request.getCookies()).willReturn(new Cookie[] { tokenCookie }); + given(dataController.verify("invalidToken")).willReturn(null); + + ResponseEntity responseEntity = authController.verify(request, response); + + assertEquals(HttpStatus.UNAUTHORIZED, responseEntity.getStatusCode()); + assertEquals(Global.INVALID_TOKEN, responseEntity.getBody()); + } + } +} diff --git a/memorystore/valkey/session/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/session/app/src/test/java/app/DataControllerTest.java new file mode 100644 index 00000000000..bf46ef34663 --- /dev/null +++ b/memorystore/valkey/session/app/src/test/java/app/DataControllerTest.java @@ -0,0 +1,260 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.BDDMockito.doThrow; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import redis.clients.jedis.Jedis; + +@ExtendWith(MockitoExtension.class) +class DataControllerTest { + + @Mock private AccountRepository accountRepository; + + @Mock private Jedis jedis; + + private DataController dataController; + + @BeforeEach + void setUp() { + dataController = new DataController(accountRepository, jedis); + } + + @Nested + @DisplayName("Testing register() method") + class RegisterTests { + + @Test + @DisplayName("Should register a new user") + void testRegister() { + String email = "test@example.com"; + String username = "testUser"; + String password = "securePassword"; + + // Action + dataController.register(email, username, password); + + // Verify + verify(accountRepository).registerUser(email, username, password); + } + } + + @Nested + @DisplayName("Testing login() method") + class LoginTests { + + @Test + @DisplayName("Should return token for valid credentials") + void testLogin_ValidCredentials() { + String username = "testUser"; + String password = "securePassword"; + String token = "generatedToken"; + + // Given + given(accountRepository.authenticateUser(username, password)) + .willReturn(Optional.of(1)); // pretend userId = 1 + + // Mock static Utils.generateToken(...) + try (MockedStatic mockedUtils = Mockito.mockStatic(Utils.class)) { + mockedUtils + .when(() -> Utils.generateToken(Global.TOKEN_BYTE_LENGTH)) + .thenReturn(token); + + // Action + String result = dataController.login(username, password); + + // Assert & Verify + assertEquals(token, result); + verify(jedis).set(token, username); + verify(jedis).expire(token, Global.TOKEN_EXPIRATION); + } + } + + @Test + @DisplayName("Should return null for invalid credentials") + void testLogin_InvalidCredentials() { + String username = "testUser"; + String password = "wrongPassword"; + + given(accountRepository.authenticateUser(username, password)) + .willReturn(Optional.empty()); + + String result = dataController.login(username, password); + + assertNull(result); + } + + @Test + @DisplayName("Should throw RuntimeException if Jedis operation fails") + void testLogin_JedisFailure() { + String username = "testUser"; + String password = "securePassword"; + String token = "generatedToken"; + + given(accountRepository.authenticateUser(username, password)) + .willReturn(Optional.of(1)); + + try (MockedStatic mockedUtils = Mockito.mockStatic(Utils.class)) { + mockedUtils + .when(() -> Utils.generateToken(Global.TOKEN_BYTE_LENGTH)) + .thenReturn(token); + + // Force an error in Jedis.set(...) + doThrow(new RuntimeException("Jedis error")).when(jedis).set(token, username); + + // Should throw RuntimeException because Jedis fails + assertThrows( + RuntimeException.class, () -> dataController.login(username, password)); + } + } + } + + @Nested + @DisplayName("Testing logout() method") + class LogoutTests { + + @Test + @DisplayName("Should delete token from Jedis") + void testLogout() { + String token = "testToken"; + + dataController.logout(token); + + // Verify it deletes from Jedis + verify(jedis).del(token); + } + + @Test + @DisplayName("Should throw RuntimeException if Jedis operation fails") + void testLogout_JedisFailure() { + String token = "testToken"; + + // Force an error in Jedis.del(...) + doThrow(new RuntimeException("Jedis error")).when(jedis).del(token); + + assertThrows(RuntimeException.class, () -> dataController.logout(token)); + } + } + + @Nested + @DisplayName("Testing verify() method") + class VerifyTests { + + @Test + @DisplayName("Should return username and extend token expiration if valid") + void testVerify_ValidToken() { + String token = "testToken"; + String username = "testUser"; + + given(jedis.get(token)).willReturn(username); + + String result = dataController.verify(token); + + assertEquals(username, result); + verify(jedis).expire(token, Global.TOKEN_EXPIRATION); + } + + @Test + @DisplayName("Should return null if token is invalid") + void testVerify_InvalidToken() { + String token = "invalidToken"; + + given(jedis.get(token)).willReturn(null); + + String result = dataController.verify(token); + + assertNull(result); + } + + @Test + @DisplayName("Should throw RuntimeException if Jedis operation fails") + void testVerify_JedisFailure() { + String token = "testToken"; + + doThrow(new RuntimeException("Jedis error")).when(jedis).get(token); + + assertThrows(RuntimeException.class, () -> dataController.verify(token)); + } + } + + @Nested + @DisplayName("Testing checkIfEmailExists() method") + class CheckIfEmailExistsTests { + + @Test + @DisplayName("Should return true if email exists") + void testCheckIfEmailExists_True() { + String email = "test@example.com"; + + given(accountRepository.isEmailRegistered(email)).willReturn(true); + + assertTrue(dataController.checkIfEmailExists(email)); + } + + @Test + @DisplayName("Should return false if email does not exist") + void testCheckIfEmailExists_False() { + String email = "nonexistent@example.com"; + + given(accountRepository.isEmailRegistered(email)).willReturn(false); + + assertFalse(dataController.checkIfEmailExists(email)); + } + } + + @Nested + @DisplayName("Testing checkIfUsernameExists() method") + class CheckIfUsernameExistsTests { + + @Test + @DisplayName("Should return true if username exists") + void testCheckIfUsernameExists_True() { + String username = "testUser"; + + given(accountRepository.isUsernameRegistered(username)).willReturn(true); + + assertTrue(dataController.checkIfUsernameExists(username)); + } + + @Test + @DisplayName("Should return false if username does not exist") + void testCheckIfUsernameExists_False() { + String username = "nonexistentUser"; + + given(accountRepository.isUsernameRegistered(username)).willReturn(false); + + assertFalse(dataController.checkIfUsernameExists(username)); + } + } +} diff --git a/memorystore/valkey/session/sample-data/.gitignore b/memorystore/valkey/session/sample-data/.gitignore new file mode 100644 index 00000000000..e9e224cae7f --- /dev/null +++ b/memorystore/valkey/session/sample-data/.gitignore @@ -0,0 +1,84 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store \ No newline at end of file diff --git a/memorystore/valkey/session/sample-data/Dockerfile b/memorystore/valkey/session/sample-data/Dockerfile new file mode 100644 index 00000000000..131e16d0504 --- /dev/null +++ b/memorystore/valkey/session/sample-data/Dockerfile @@ -0,0 +1,24 @@ +# Use an OpenJDK base image +FROM openjdk:17-jdk-slim + +# Install Maven for building the project +RUN apt-get update && apt-get install -y maven + +# Set the working directory +WORKDIR /app + +# Copy Maven project files +COPY pom.xml ./ +COPY src ./src + +# Build the project +RUN mvn clean package -DskipTests + +# Copy the built JAR file to the container +RUN cp target/app-1.0-SNAPSHOT.jar app.jar + +# Expose the application port +EXPOSE 8080 + +# Run the application +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/memorystore/valkey/session/sample-data/docker-compose.yaml b/memorystore/valkey/session/sample-data/docker-compose.yaml new file mode 100644 index 00000000000..ea78aebd9c5 --- /dev/null +++ b/memorystore/valkey/session/sample-data/docker-compose.yaml @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: sessions-app + +services: + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + + postgres: + image: postgres:latest + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + volumes: + - ../app/init.sql:/docker-entrypoint-initdb.d/init.sql + + data: + build: + context: . + dockerfile: Dockerfile + container_name: sample-data + ports: + - "8082:8082" + depends_on: + - postgres + environment: + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password + + app: + build: + context: ../app + dockerfile: Dockerfile + container_name: app + ports: + - "8080:8080" + depends_on: + - valkey + - postgres + - data + environment: + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password diff --git a/memorystore/valkey/session/sample-data/pom.xml b/memorystore/valkey/session/sample-data/pom.xml new file mode 100644 index 00000000000..893f30d6379 --- /dev/null +++ b/memorystore/valkey/session/sample-data/pom.xml @@ -0,0 +1,106 @@ + + + + + + 4.0.0 + + org.example + app + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + com.google.cloud.samples + shared-configuration + 1.2.0 + + + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.3.6 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + com.github.javafaker + javafaker + 1.0.2 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + -parameters + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + true + + + app.Main + + + + + + + + + \ No newline at end of file diff --git a/memorystore/valkey/session/sample-data/src/main/java/app/Main.java b/memorystore/valkey/session/sample-data/src/main/java/app/Main.java new file mode 100644 index 00000000000..696f1882edc --- /dev/null +++ b/memorystore/valkey/session/sample-data/src/main/java/app/Main.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import com.github.javafaker.Faker; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.jdbc.core.JdbcTemplate; + +public class Main { + + /** Maximum number of generated entries. */ + private static final int MAX_GENERATED_ENTRIES = 15000; + /** Faker instance for generating random data. */ + private static final Faker FAKER = new Faker(); + /** Random instance for generating random data. */ + private static final Random RANDOM = new Random(); + /** An hour in ms. */ + private static final long ONE_HOUR = 3600000; + /** Maximum username length. */ + private static final int MAX_USERNAME_LENGTH = 20; + /** Sleep between retrying connections. */ + private static final int RETRY_SLEEP = 5000; + + /** + * Main method to populate the leaderboard with test data. + * + * @param args Command line arguments + * + */ + public static void main(final String[] args) { + // Connect to PostgreSQL + System.out.println("Connecting to PostgreSQL..."); + JdbcTemplate jdbcTemplate = configureJdbcTemplate(); + + // Populate leaderboard with test data + try { + System.out.println("Populating accounts..."); + populateAccounts(jdbcTemplate); + } catch (CannotGetJdbcConnectionException e) { + System.out.println("Failed to connect to the" + + " database. Retrying in 5 seconds..."); + // Sleep for 5 seconds and retry + try { + Thread.sleep(RETRY_SLEEP); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + main(args); + } + } + + private static void populateAccounts(final JdbcTemplate jdbcTemplate) { + String sql = "INSERT INTO account" + + " (email, username, password) VALUES (?, ?, ?)"; + + // Prepare batch arguments + List batchArgs = new ArrayList<>(); + for (int i = 0; i < MAX_GENERATED_ENTRIES; i++) { + String email = FAKER.internet().emailAddress(); + String username = FAKER.name().username(); + username = username.length() > MAX_USERNAME_LENGTH + ? username.substring(0, MAX_USERNAME_LENGTH) : username; + String password = FAKER.internet().password(); + + batchArgs.add(new Object[] {email, username, password}); + } + + // Execute batch update + jdbcTemplate.batchUpdate(sql, batchArgs); + } + + private static JdbcTemplate configureJdbcTemplate() { + String jdbcUrl = System + .getenv() + .getOrDefault( + "DB_URL", "jdbc:postgresql://localhost:5432/postgres"); + String jdbcUsername = System + .getenv() + .getOrDefault("DB_USERNAME", "root"); + String jdbcPassword = System + .getenv() + .getOrDefault("DB_PASSWORD", "password"); + + JdbcTemplate jdbcTemplate = new JdbcTemplate(); + jdbcTemplate.setDataSource( + DataSourceBuilder.create() + .url(jdbcUrl) + .username(jdbcUsername) + .password(jdbcPassword) + .build()); + return jdbcTemplate; + } + + /** Dummy method to trick Checkstyle. */ + public void avoidCheckstyleError() { + } +} diff --git a/memorystore/valkey/session/sample-data/src/main/java/app/package-info.java b/memorystore/valkey/session/sample-data/src/main/java/app/package-info.java new file mode 100644 index 00000000000..624686a41aa --- /dev/null +++ b/memorystore/valkey/session/sample-data/src/main/java/app/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Contains utility for generating random test data for session management app. + */ +package main.java.app;