Compare commits
5 commits
8e495672d3
...
bb4bb0c6c6
| Author | SHA1 | Date | |
|---|---|---|---|
| bb4bb0c6c6 | |||
| ca21c5b659 | |||
| e05f74bd82 | |||
| 491dc99c55 | |||
| 3d4a6daee9 |
34 changed files with 957 additions and 13 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.gradle
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
frontend/node_modules
|
||||||
|
backend/build
|
||||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -152,12 +152,12 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
||||||
- API calls live in `api/` modules, never in components.
|
- API calls live in `api/` modules, never in components.
|
||||||
- Component styles are scoped.
|
- Component styles are scoped.
|
||||||
|
|
||||||
### Backend (Spring Boot 3)
|
### Backend (Spring Boot 4)
|
||||||
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
||||||
- DTOs: prefer Java records. No bare entities in responses.
|
- DTOs: prefer Java records. No bare entities in responses.
|
||||||
- Controllers stay thin. All logic in services.
|
- Controllers stay thin. All logic in services.
|
||||||
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
||||||
- No Lombok beyond `@RequiredArgsConstructor`.
|
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
||||||
|
|
@ -200,6 +200,10 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
||||||
|
|
||||||
## Testing Approach
|
## Testing Approach
|
||||||
|
|
||||||
|
This project follows **Test-Driven Development (TDD)**. Write tests before
|
||||||
|
or alongside implementation. Every feature ticket should include tests in
|
||||||
|
the same PR — never merge code without corresponding tests.
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- JUnit 5 + Mockito for service layer tests.
|
- JUnit 5 + Mockito for service layer tests.
|
||||||
- `@WebMvcTest` for controller tests.
|
- `@WebMvcTest` for controller tests.
|
||||||
|
|
@ -210,7 +214,14 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
||||||
### Frontend
|
### Frontend
|
||||||
- Vitest for composables and utility functions.
|
- Vitest for composables and utility functions.
|
||||||
- Component tests with Vue Test Utils where needed.
|
- Component tests with Vue Test Utils where needed.
|
||||||
- E2E tests deferred to Phase 1.
|
- E2E tests with Playwright in `frontend/e2e/`.
|
||||||
|
|
||||||
|
### E2E (Playwright)
|
||||||
|
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
|
||||||
|
- Requires `docker compose up` (backend + frontend running).
|
||||||
|
- Config: `frontend/playwright.config.ts`.
|
||||||
|
- Tests: `frontend/e2e/*.spec.ts`.
|
||||||
|
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines.
|
||||||
|
|
||||||
### CI (future)
|
### CI (future)
|
||||||
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ async function handleSubmit() {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Backend — Spring Boot 3
|
## 4. Backend — Spring Boot 4
|
||||||
|
|
||||||
### Package Structure
|
### Package Structure
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@ public class OrderController {
|
||||||
- All responses: `ResponseEntity<T>`. Never return bare entities.
|
- All responses: `ResponseEntity<T>`. Never return bare entities.
|
||||||
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
|
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
|
||||||
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
|
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
|
||||||
- No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
|
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
||||||
|
|
||||||
### API Path Conventions
|
### API Path Conventions
|
||||||
|
|
||||||
|
|
@ -289,8 +289,13 @@ public class GlobalExceptionHandler {
|
||||||
|
|
||||||
## 7. Testing
|
## 7. Testing
|
||||||
|
|
||||||
|
This project follows **Test-Driven Development (TDD)**. Write tests before
|
||||||
|
or alongside implementation. Every feature ticket should include tests in
|
||||||
|
the same PR — never merge code without corresponding tests.
|
||||||
|
|
||||||
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
|
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
|
||||||
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
|
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils.
|
||||||
|
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
|
||||||
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
||||||
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
|
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
|
||||||
- All database interaction in tests must go through JPA repositories
|
- All database interaction in tests must go through JPA repositories
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ the user assumes full responsibility for content.
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
|
||||||
| Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
|
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA |
|
||||||
| Database | PostgreSQL 16 |
|
| Database | PostgreSQL 16 |
|
||||||
| Deployment | Docker, Docker Compose |
|
| Deployment | Docker, Docker Compose |
|
||||||
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
|
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
|
||||||
|
|
@ -209,7 +209,7 @@ the user assumes full responsibility for content.
|
||||||
└──────────────────┬───────────────────────┘
|
└──────────────────┬───────────────────────┘
|
||||||
│ REST API calls
|
│ REST API calls
|
||||||
┌──────────────────▼───────────────────────┐
|
┌──────────────────▼───────────────────────┐
|
||||||
│ Spring Boot 3 (Java 21) │
|
│ Spring Boot 4 (Java 21) │
|
||||||
│ Port: 8080 │
|
│ Port: 8080 │
|
||||||
│ ┌────────────┐ ┌────────────────────┐ │
|
│ ┌────────────┐ ┌────────────────────┐ │
|
||||||
│ │ Spring │ │ Service Layer │ │
|
│ │ Spring │ │ Service Layer │ │
|
||||||
|
|
@ -556,7 +556,7 @@ For Phase 0 with manual processing, staying unregistered is workable. If revenue
|
||||||
|
|
||||||
```
|
```
|
||||||
Frontend: Vue.js 3, Vite, Pinia, Vue Router
|
Frontend: Vue.js 3, Vite, Pinia, Vue Router
|
||||||
Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
|
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate
|
||||||
Database: PostgreSQL 16
|
Database: PostgreSQL 16
|
||||||
Deploy: Docker, Docker Compose, Nginx reverse proxy
|
Deploy: Docker, Docker Compose, Nginx reverse proxy
|
||||||
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)
|
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import se.bilhalsning.dto.AuthResponse;
|
import se.bilhalsning.dto.AuthResponse;
|
||||||
|
import se.bilhalsning.dto.LoginRequest;
|
||||||
import se.bilhalsning.dto.RegisterRequest;
|
import se.bilhalsning.dto.RegisterRequest;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
|
|
@ -27,4 +29,11 @@ public class AuthController {
|
||||||
String token = jwtService.generateToken(request.email().toLowerCase().trim());
|
String token = jwtService.generateToken(request.email().toLowerCase().trim());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
|
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
User user = userService.authenticate(request.email(), request.password());
|
||||||
|
String token = jwtService.generateToken(user.getEmail());
|
||||||
|
return ResponseEntity.ok(new AuthResponse(token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record LoginRequest(
|
||||||
|
@NotBlank @Email String email,
|
||||||
|
@NotBlank String password
|
||||||
|
) {}
|
||||||
|
|
@ -14,6 +14,13 @@ public class GlobalExceptionHandler {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(InvalidCredentialsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package se.bilhalsning.exception;
|
||||||
|
|
||||||
|
public class InvalidCredentialsException extends RuntimeException {
|
||||||
|
public InvalidCredentialsException() {
|
||||||
|
super("Felaktig e-post eller lösenord");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -32,7 +33,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = authHeader.substring(7);
|
String token = authHeader.substring(7);
|
||||||
String username = jwtService.extractUsername(token);
|
String username;
|
||||||
|
try {
|
||||||
|
username = jwtService.extractUsername(token);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||||
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
import se.bilhalsning.repository.UserRepository;
|
import se.bilhalsning.repository.UserRepository;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
|
@ -29,4 +30,14 @@ public class UserService {
|
||||||
user.setPasswordHash(passwordEncoder.encode(password));
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public User authenticate(String email, String password) {
|
||||||
|
String normalizedEmail = email.toLowerCase().trim();
|
||||||
|
User user = userRepository.findByEmail(normalizedEmail)
|
||||||
|
.orElseThrow(InvalidCredentialsException::new);
|
||||||
|
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
INSERT INTO users (id, email, password_hash, subscription)
|
||||||
|
VALUES (
|
||||||
|
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||||
|
'test@bilhalsning.se',
|
||||||
|
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
|
||||||
|
'none'
|
||||||
|
);
|
||||||
|
|
@ -13,8 +13,11 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import se.bilhalsning.dto.LoginRequest;
|
||||||
import se.bilhalsning.dto.RegisterRequest;
|
import se.bilhalsning.dto.RegisterRequest;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||||
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
|
|
@ -85,4 +88,59 @@ class AuthControllerTest {
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("user@example.com");
|
||||||
|
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
|
||||||
|
when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token");
|
||||||
|
|
||||||
|
LoginRequest request = new LoginRequest("user@example.com", "password123");
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.token").value("login-jwt-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
|
||||||
|
when(userService.authenticate("user@example.com", "wrongpassword"))
|
||||||
|
.thenThrow(new InvalidCredentialsException());
|
||||||
|
|
||||||
|
LoginRequest request = new LoginRequest("user@example.com", "wrongpassword");
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn400WhenLoginEmailIsBlank() throws Exception {
|
||||||
|
LoginRequest request = new LoginRequest("", "password123");
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn400WhenLoginPasswordIsBlank() throws Exception {
|
||||||
|
LoginRequest request = new LoginRequest("user@example.com", "");
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn400WhenLoginEmailIsInvalid() throws Exception {
|
||||||
|
LoginRequest request = new LoginRequest("not-an-email", "password123");
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
|
import io.jsonwebtoken.MalformedJwtException;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
@ -135,4 +137,40 @@ class JwtAuthenticationFilterTest {
|
||||||
verify(jwtService, never()).isTokenValid(anyString());
|
verify(jwtService, never()).isTokenValid(anyString());
|
||||||
verify(filterChain).doFilter(request, response);
|
verify(filterChain).doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldPassThroughWhenTokenExpired() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("Authorization", "Bearer expired.token");
|
||||||
|
request.setRequestURI("/api/auth/register");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
when(jwtService.extractUsername("expired.token"))
|
||||||
|
.thenThrow(new ExpiredJwtException(null, null, "Token expired"));
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
verify(jwtService, never()).isTokenValid(anyString());
|
||||||
|
verify(userDetailsService, never()).loadUserByUsername(anyString());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldPassThroughWhenTokenMalformed() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("Authorization", "Bearer not.a.jwt");
|
||||||
|
request.setRequestURI("/api/auth/login");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
when(jwtService.extractUsername("not.a.jwt"))
|
||||||
|
.thenThrow(new MalformedJwtException("Invalid JWT"));
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
verify(jwtService, never()).isTokenValid(anyString());
|
||||||
|
verify(userDetailsService, never()).loadUserByUsername(anyString());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import se.bilhalsning.entity.Subscription;
|
import se.bilhalsning.entity.Subscription;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||||
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
import se.bilhalsning.repository.UserRepository;
|
import se.bilhalsning.repository.UserRepository;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
|
@ -118,4 +119,54 @@ class UserServiceTest {
|
||||||
|
|
||||||
verify(userRepository).findByEmail("user@example.com");
|
verify(userRepository).findByEmail("user@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnUserWhenCredentialsAreValid() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("user@example.com");
|
||||||
|
user.setPasswordHash("hashed");
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
|
||||||
|
|
||||||
|
User result = userService.authenticate("user@example.com", "password123");
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("user@example.com", result.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenEmailNotFoundOnAuthenticate() {
|
||||||
|
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(InvalidCredentialsException.class, () ->
|
||||||
|
userService.authenticate("unknown@example.com", "password123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenPasswordDoesNotMatch() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("user@example.com");
|
||||||
|
user.setPasswordHash("hashed");
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("wrongpassword", "hashed")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThrows(InvalidCredentialsException.class, () ->
|
||||||
|
userService.authenticate("user@example.com", "wrongpassword"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNormalizeEmailBeforeAuthenticating() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("user@example.com");
|
||||||
|
user.setPasswordHash("hashed");
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
|
||||||
|
|
||||||
|
userService.authenticate(" User@Example.COM ", "password123");
|
||||||
|
|
||||||
|
verify(userRepository).findByEmail("user@example.com");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ tasks.register('frontendTest', Exec) {
|
||||||
commandLine 'npm', 'run', 'test'
|
commandLine 'npm', 'run', 'test'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register('frontendE2E', Exec) {
|
||||||
|
description = 'Run Playwright E2E tests in Docker (CI mode)'
|
||||||
|
workingDir = file("${rootProject.projectDir}/frontend")
|
||||||
|
commandLine 'npm', 'run', 'test:e2e:ci'
|
||||||
|
}
|
||||||
|
|
||||||
tasks.named('check').configure {
|
tasks.named('check').configure {
|
||||||
dependsOn frontendLint, frontendTest
|
dependsOn frontendLint, frontendTest
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
docker-compose.ci.yml
Normal file
63
docker-compose.ci.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: bilhej-postgres-ci
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
context: .
|
||||||
|
container_name: bilhej-backend-ci
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: docker
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||||
|
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- gradle-cache:/root/.gradle
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
dockerfile: docker/frontend.Dockerfile
|
||||||
|
context: .
|
||||||
|
container_name: bilhej-frontend-ci
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||||
|
container_name: bilhej-playwright
|
||||||
|
ipc: host
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PLAYWRIGHT_BASE_URL: http://frontend:3000
|
||||||
|
volumes:
|
||||||
|
- ./frontend/package.json:/app/package.json
|
||||||
|
- ./frontend/package-lock.json:/app/package-lock.json
|
||||||
|
- ./frontend/playwright.config.ts:/app/playwright.config.ts
|
||||||
|
- ./frontend/e2e:/app/e2e
|
||||||
|
- ./frontend/node_modules:/app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
command: >
|
||||||
|
sh -c "npm ci --ignore-scripts && npx playwright test --reporter=list"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gradle-cache:
|
||||||
|
|
@ -37,6 +37,7 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- backend-gradle-project:/app/.gradle
|
||||||
- gradle-cache:/root/.gradle
|
- gradle-cache:/root/.gradle
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|
@ -56,3 +57,4 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
gradle-cache:
|
gradle-cache:
|
||||||
|
backend-gradle-project:
|
||||||
|
|
|
||||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
|
|
@ -22,3 +22,7 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
|
||||||
48
frontend/e2e/login.spec.ts
Normal file
48
frontend/e2e/login.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Login page', () => {
|
||||||
|
test('can navigate to login page', async ({ page }) => {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows error for invalid credentials', async ({ page }) => {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill('user@example.com')
|
||||||
|
await page.getByLabel('Lösenord').fill('wrongpassword')
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redirects to home after successful login', async ({ page }) => {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill('test@example.com')
|
||||||
|
await page.getByLabel('Lösenord').fill('password123')
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can navigate from login to register', async ({ page }) => {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByRole('link', { name: 'Skapa konto' }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/registrera')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Skapa konto' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('login form has correct input types', async ({ page }) => {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await expect(page.getByLabel('E-postadress')).toHaveAttribute(
|
||||||
|
'type',
|
||||||
|
'email',
|
||||||
|
)
|
||||||
|
await expect(page.getByLabel('Lösenord')).toHaveAttribute(
|
||||||
|
'type',
|
||||||
|
'password',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
56
frontend/e2e/register.spec.ts
Normal file
56
frontend/e2e/register.spec.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Register page', () => {
|
||||||
|
test('can navigate to register page', async ({ page }) => {
|
||||||
|
await page.goto('/registrera')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Skapa konto' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registers a new user and redirects to home', async ({ page }) => {
|
||||||
|
const uniqueEmail = `playwright-${Date.now()}@test.com`
|
||||||
|
|
||||||
|
await page.goto('/registrera')
|
||||||
|
await page.getByLabel('E-postadress').fill(uniqueEmail)
|
||||||
|
await page.getByLabel('Lösenord').first().fill('password123')
|
||||||
|
await page.getByLabel('Bekräfta lösenord').fill('password123')
|
||||||
|
await page.getByRole('button', { name: 'Skapa konto' }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows validation error for invalid email', async ({ page }) => {
|
||||||
|
await page.goto('/registrera')
|
||||||
|
await page.getByLabel('E-postadress').fill('not-an-email')
|
||||||
|
|
||||||
|
await expect(page.getByText('Ange en giltig e-postadress')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows validation error for short password', async ({ page }) => {
|
||||||
|
await page.goto('/registrera')
|
||||||
|
await page.getByLabel('Lösenord').first().fill('short')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText('Lösenordet måste vara minst 8 tecken'),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows validation error for mismatched passwords', async ({ page }) => {
|
||||||
|
await page.goto('/registrera')
|
||||||
|
await page.getByLabel('Lösenord').first().fill('password123')
|
||||||
|
await page.getByLabel('Bekräfta lösenord').fill('different123')
|
||||||
|
|
||||||
|
await expect(page.getByText('Lösenorden matchar inte')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can navigate from register to login', async ({ page }) => {
|
||||||
|
await page.goto('/registrera')
|
||||||
|
await page
|
||||||
|
.getByRole('main')
|
||||||
|
.getByRole('link', { name: 'Logga in' })
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/logga-in')
|
||||||
|
})
|
||||||
|
})
|
||||||
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"vue-router": "^5.0.6"
|
"vue-router": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@rushstack/eslint-patch": "^1.16.1",
|
"@rushstack/eslint-patch": "^1.16.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
|
@ -670,6 +671,22 @@
|
||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.0-rc.17",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||||
|
|
@ -3717,6 +3734,53 @@
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
"lint": "eslint src/ --fix",
|
"lint": "eslint src/ --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|
@ -18,6 +20,7 @@
|
||||||
"vue-router": "^5.0.6"
|
"vue-router": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@rushstack/eslint-patch": "^1.16.1",
|
"@rushstack/eslint-patch": "^1.16.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
|
|
||||||
29
frontend/playwright.config.ts
Normal file
29
frontend/playwright.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
const isCI = !!process.env.PLAYWRIGHT_BASE_URL
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||||
|
headless: true,
|
||||||
|
},
|
||||||
|
...(isCI
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { browserName: 'chromium' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -8,6 +8,16 @@ function createTestRouter() {
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||||
|
{
|
||||||
|
path: '/logga-in',
|
||||||
|
name: 'login',
|
||||||
|
component: { template: '<div>Login</div>' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/registrera',
|
||||||
|
name: 'register',
|
||||||
|
component: { template: '<div>Register</div>' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -43,4 +53,15 @@ describe('AppHeader', () => {
|
||||||
expect(registerLink).toBeTruthy()
|
expect(registerLink).toBeTruthy()
|
||||||
expect(registerLink?.text()).toBe('Registrera')
|
expect(registerLink?.text()).toBe('Registrera')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('has a link to login', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(AppHeader, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
const links = wrapper.findAll('a')
|
||||||
|
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||||
|
expect(loginLink).toBeTruthy()
|
||||||
|
expect(loginLink?.text()).toBe('Logga in')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
148
frontend/src/__tests__/LoginPage.spec.ts
Normal file
148
frontend/src/__tests__/LoginPage.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import LoginPage from '@/pages/LoginPage.vue'
|
||||||
|
|
||||||
|
function mockFetchResponse(status: number, body: unknown) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/logga-in', name: 'login', component: LoginPage },
|
||||||
|
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||||
|
{
|
||||||
|
path: '/registrera',
|
||||||
|
name: 'register',
|
||||||
|
component: { template: '<div>Register</div>' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const pinia = createPinia()
|
||||||
|
router.push('/logga-in')
|
||||||
|
return {
|
||||||
|
router,
|
||||||
|
wrapper: mount(LoginPage, {
|
||||||
|
global: { plugins: [router, pinia] },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
globalThis.fetch = vi.fn()
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(200, { token: 'test-token' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders heading and subtitle', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
expect(wrapper.text()).toContain('Logga in')
|
||||||
|
expect(wrapper.text()).toContain('Ange din e-postadress och ditt lösenord')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders email and password fields', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
expect(wrapper.find('#email').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#password').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render confirm password field', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
expect(wrapper.find('#confirm-password').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables submit when fields are empty', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
const button = wrapper.find('button[type="submit"]')
|
||||||
|
expect(button.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables submit when both fields have values', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await wrapper.find('#email').setValue('test@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
const button = wrapper.find('button[type="submit"]')
|
||||||
|
expect(button.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading text while submitting', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await wrapper.find('#email').setValue('test@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
expect(wrapper.text()).toContain('Loggar in...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls login API and redirects to home on success', async () => {
|
||||||
|
const { wrapper, router } = mountPage()
|
||||||
|
|
||||||
|
await wrapper.find('#email').setValue('test@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/login',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(router.currentRoute.value.name).toBe('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows generic error on login failure', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||||
|
)
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
|
||||||
|
await wrapper.find('#email').setValue('test@example.com')
|
||||||
|
await wrapper.find('#password').setValue('wrongpassword')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not leak specific error messages', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(500, { message: 'Internal server error' }),
|
||||||
|
)
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
|
||||||
|
await wrapper.find('#email').setValue('test@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
|
||||||
|
expect(wrapper.text()).not.toContain('Internal server error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders register link', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
expect(wrapper.text()).toContain('Har du inget konto?')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -14,6 +14,12 @@ describe('Router', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('register')
|
expect(router.currentRoute.value.name).toBe('register')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves /logga-in to LoginPage', async () => {
|
||||||
|
await router.push('/logga-in')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not crash on unknown route', async () => {
|
it('does not crash on unknown route', async () => {
|
||||||
await router.push('/nonexistent')
|
await router.push('/nonexistent')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,64 @@ describe('authStore', () => {
|
||||||
expect(store.isAuthenticated).toBe(false)
|
expect(store.isAuthenticated).toBe(false)
|
||||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sets token on loginUser success', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(200, { token: 'login-token' }),
|
||||||
|
)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.loginUser('user@example.com', 'password123')
|
||||||
|
|
||||||
|
expect(store.token).toBe('login-token')
|
||||||
|
expect(store.isAuthenticated).toBe(true)
|
||||||
|
expect(localStorage.getItem('auth_token')).toBe('login-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when login API fails', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||||
|
)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.loginUser('user@example.com', 'wrongpassword'),
|
||||||
|
).rejects.toThrow('Felaktig e-post eller lösenord')
|
||||||
|
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(store.token).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls login endpoint with correct credentials', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(200, { token: 'login-token' }),
|
||||||
|
)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.loginUser('user@example.com', 'password123')
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/login',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'user@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call register endpoint on login', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
mockFetchResponse(200, { token: 'login-token' }),
|
||||||
|
)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.loginUser('user@example.com', 'password123')
|
||||||
|
|
||||||
|
const calls = vi.mocked(globalThis.fetch).mock.calls
|
||||||
|
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
|
||||||
|
expect(registerCall).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,10 @@ export function register(
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function login(email: string, password: string): Promise<AuthResponse> {
|
||||||
|
return request<AuthResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router'
|
||||||
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
||||||
<nav class="app-header__nav">
|
<nav class="app-header__nav">
|
||||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||||
|
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
|
||||||
<RouterLink to="/registrera" class="app-header__link"
|
<RouterLink to="/registrera" class="app-header__link"
|
||||||
>Registrera</RouterLink
|
>Registrera</RouterLink
|
||||||
>
|
>
|
||||||
|
|
|
||||||
185
frontend/src/pages/LoginPage.vue
Normal file
185
frontend/src/pages/LoginPage.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const isValid = computed(
|
||||||
|
() => email.value.length > 0 && password.value.length > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid.value || submitting.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.loginUser(email.value, password.value)
|
||||||
|
await router.push('/')
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Felaktig e-post eller lösenord'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<h1 class="login__title">Logga in</h1>
|
||||||
|
<p class="login__subtitle">
|
||||||
|
Ange din e-postadress och ditt lösenord för att logga in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="login__form" @submit.prevent="handleSubmit">
|
||||||
|
<div class="login__field">
|
||||||
|
<label for="email" class="login__label">E-postadress</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
class="login__input"
|
||||||
|
placeholder="namn@exempel.se"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login__field">
|
||||||
|
<label for="password" class="login__label">Lösenord</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="login__input"
|
||||||
|
placeholder="Ditt lösenord"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="login__submit"
|
||||||
|
:disabled="!isValid || submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Loggar in...' : 'Logga in' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="login__register-link">
|
||||||
|
Har du inget konto?
|
||||||
|
<RouterLink to="/registrera">Skapa konto</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login {
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__title {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__subtitle {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #cbd5e0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__input:focus {
|
||||||
|
border-color: #4299e1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__api-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #fed7d7;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #c53030;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
background: #4299e1;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__submit:hover:not(:disabled) {
|
||||||
|
background: #3182ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__register-link {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__register-link a {
|
||||||
|
color: #4299e1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__register-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,6 +4,7 @@ import ComposePage from '@/pages/ComposePage.vue'
|
||||||
import AboutPage from '@/pages/AboutPage.vue'
|
import AboutPage from '@/pages/AboutPage.vue'
|
||||||
import ContactPage from '@/pages/ContactPage.vue'
|
import ContactPage from '@/pages/ContactPage.vue'
|
||||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||||
|
import LoginPage from '@/pages/LoginPage.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -23,6 +24,11 @@ const router = createRouter({
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: RegisterPage,
|
component: RegisterPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/logga-in',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginPage,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/om',
|
path: '/om',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { register } from '@/api/auth'
|
import { register, login } from '@/api/auth'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||||
|
|
@ -22,9 +22,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
setToken(response.token)
|
setToken(response.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginUser(email: string, password: string): Promise<void> {
|
||||||
|
const response = await login(email, password)
|
||||||
|
setToken(response.token)
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearToken()
|
clearToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, isAuthenticated, registerUser, logout }
|
return { token, isAuthenticated, registerUser, loginUser, logout }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,6 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['src/__tests__/setup.ts'],
|
setupFiles: ['src/__tests__/setup.ts'],
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,9 @@
|
||||||
"permission": {
|
"permission": {
|
||||||
"edit": "ask",
|
"edit": "ask",
|
||||||
"bash": "ask"
|
"bash": "ask"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"websearch": true,
|
||||||
|
"codesearch": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue