Compare commits
No commits in common. "350dfcfd7b3ea173e791298957dc572b1d18fd14" and "ab1cdb358f343ea96411639f191194639e8f249a" have entirely different histories.
350dfcfd7b
...
ab1cdb358f
30 changed files with 67 additions and 338 deletions
|
|
@ -26,8 +26,3 @@ STRIPE_PRICE_ID=price_...
|
||||||
# ---------- Swish (Phase 0) ----------
|
# ---------- Swish (Phase 0) ----------
|
||||||
SWISH_NUMBER=0701234567
|
SWISH_NUMBER=0701234567
|
||||||
|
|
||||||
# ---------- Production admin (prod profile only) ----------
|
|
||||||
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
|
||||||
ADMIN_EMAIL=admin@bilhej.se
|
|
||||||
ADMIN_PASSWORD=change_me_to_a_strong_password
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ jobs:
|
||||||
STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
STRIPE_PRICE_ID=${{ secrets.STRIPE_PRICE_ID }}
|
STRIPE_PRICE_ID=${{ secrets.STRIPE_PRICE_ID }}
|
||||||
SWISH_NUMBER=${{ secrets.SWISH_NUMBER }}
|
SWISH_NUMBER=${{ secrets.SWISH_NUMBER }}
|
||||||
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
|
|
||||||
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build and start production stack
|
- name: Build and start production stack
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,6 @@ live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descript
|
||||||
|
|
||||||
To reset: `docker compose down -v && docker compose up -d`.
|
To reset: `docker compose down -v && docker compose up -d`.
|
||||||
|
|
||||||
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
|
|
||||||
sample orders) are in `db/dev-migration/` and run only without the `prod` profile.
|
|
||||||
Production admin is created from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on first boot.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
|
||||||
98
README.md
98
README.md
|
|
@ -73,8 +73,8 @@ No CORS configuration needed in development — the browser never calls the back
|
||||||
| Profile | Datasource | Use |
|
| Profile | Datasource | Use |
|
||||||
|---------|-----------|-----|
|
|---------|-----------|-----|
|
||||||
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
|
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
|
||||||
| `docker` | PostgreSQL + dev Flyway seeds | Docker Compose dev / CI |
|
| `docker` | PostgreSQL via `docker-compose.yml` | Docker Compose dev |
|
||||||
| `docker,prod` | PostgreSQL, schema only, admin bootstrap | Deploy (`docker-compose.prod.yml`) |
|
| `prod` | PostgreSQL (production config) | Deploy (`docker-compose.prod.yml`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -91,89 +91,6 @@ Copy `.env.example` to `.env` and fill in:
|
||||||
| `STRIPE_SECRET_KEY` | Stripe secret key |
|
| `STRIPE_SECRET_KEY` | Stripe secret key |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
||||||
| `SWISH_NUMBER` | Swish number for payment instructions |
|
|
||||||
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
|
|
||||||
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
|
|
||||||
|
|
||||||
**Dev-only accounts** (from `db/dev-migration`, not used in production):
|
|
||||||
|
|
||||||
| Email | Password | Role |
|
|
||||||
|-------|----------|------|
|
|
||||||
| `test@bilhej.se` | `test1234` | user (e2e / local) |
|
|
||||||
| `admin@bilhalsning.se` | `test1234` | admin (local docker only) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database access
|
|
||||||
|
|
||||||
PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus,
|
|
||||||
pgAdmin) — same idea as a normal remote database.
|
|
||||||
|
|
||||||
### Local dev (`docker compose up`)
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|--------|
|
|
||||||
| Host | `localhost` |
|
|
||||||
| Port | `5432` |
|
|
||||||
| Database | from `.env` → `POSTGRES_DB` (default `bilhej`) |
|
|
||||||
| User / password | `POSTGRES_USER` / `POSTGRES_PASSWORD` |
|
|
||||||
|
|
||||||
**IntelliJ IDEA:** Database tool window → `+` → Data Source → PostgreSQL → fill in above →
|
|
||||||
Test Connection → OK.
|
|
||||||
|
|
||||||
**CLI:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it bilhej-postgres psql -U bilhej -d bilhej
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (server)
|
|
||||||
|
|
||||||
Postgres is bound to **localhost only** on the server (`127.0.0.1:5433`) so it is not
|
|
||||||
exposed to the internet. Use an **SSH tunnel** from your laptop, then point IntelliJ (or
|
|
||||||
DBeaver) at `localhost`.
|
|
||||||
|
|
||||||
1. On the server, recreate the stack once so the port mapping is active (after deploy).
|
|
||||||
|
|
||||||
2. From your laptop:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
|
|
||||||
```
|
|
||||||
|
|
||||||
3. In IntelliJ / DBeaver:
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|--------|
|
|
||||||
| Host | `localhost` |
|
|
||||||
| Port | `5433` |
|
|
||||||
| Database | prod `POSTGRES_DB` |
|
|
||||||
| User / password | prod secrets |
|
|
||||||
|
|
||||||
**CLI on the server** (no GUI):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual prod cleanup (keep data, remove dev seeds)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
DELETE FROM orders
|
|
||||||
WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
|
||||||
|
|
||||||
DELETE FROM users
|
|
||||||
WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se');
|
|
||||||
```
|
|
||||||
|
|
||||||
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
|
|
||||||
admin on startup. No need to insert a password hash by hand.
|
|
||||||
|
|
||||||
To generate a bcrypt hash manually (optional):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew hashPassword -Ppassword='your-strong-password'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -270,15 +187,6 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
||||||
| `SWISH_NUMBER` | Swish phone number for payment instructions |
|
| `SWISH_NUMBER` | Swish phone number for payment instructions |
|
||||||
| `ADMIN_EMAIL` | Production admin email (e.g. `admin@bilhej.se`) |
|
|
||||||
| `ADMIN_PASSWORD` | Strong unique admin password (password manager) |
|
|
||||||
|
|
||||||
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
|
|
||||||
backend creates one admin from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if no admin exists.
|
|
||||||
|
|
||||||
If prod already has dev seed users, clean them with SQL (see [Database access](#database-access))
|
|
||||||
instead of wiping the volume. Then redeploy with the new secrets so bootstrap can create
|
|
||||||
`ADMIN_EMAIL`.
|
|
||||||
|
|
||||||
2. **Point DNS**
|
2. **Point DNS**
|
||||||
|
|
||||||
|
|
@ -317,7 +225,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
||||||
| Tag | Git tag `v0.1.0` is created and pushed |
|
| Tag | Git tag `v0.1.0` is created and pushed |
|
||||||
| Build | Production backend JAR and frontend bundle are built |
|
| Build | Production backend JAR and frontend bundle are built |
|
||||||
| Images | Multi-stage Docker images are built locally on the server |
|
| Images | Multi-stage Docker images are built locally on the server |
|
||||||
| Start | `docker compose -f docker-compose.prod.yml up -d` (`SPRING_PROFILES_ACTIVE=docker,prod`) |
|
| Start | `docker compose -f docker-compose.prod.yml up -d` |
|
||||||
| Verify | Health checks confirm backend API and frontend are responding |
|
| Verify | Health checks confirm backend API and frontend are responding |
|
||||||
|
|
||||||
### Architecture on Server
|
### Architecture on Server
|
||||||
|
|
|
||||||
|
|
@ -82,11 +82,3 @@ jacocoTestCoverageVerification {
|
||||||
tasks.named('check').configure {
|
tasks.named('check').configure {
|
||||||
dependsOn jacocoTestCoverageVerification
|
dependsOn jacocoTestCoverageVerification
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('hashPassword', JavaExec) {
|
|
||||||
group = 'utility'
|
|
||||||
description = 'Print BCrypt hash for a password (strength 12). Usage: -Ppassword=secret'
|
|
||||||
classpath = sourceSets.test.runtimeClasspath
|
|
||||||
mainClass = 'se.bilhalsning.tools.BcryptHashCli'
|
|
||||||
args = project.findProperty('password') ? [project.property('password')] : []
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package se.bilhalsning.config;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.ApplicationArguments;
|
|
||||||
import org.springframework.boot.ApplicationRunner;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@Profile("prod")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class AdminBootstrap implements ApplicationRunner {
|
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
@Value("${app.admin.email:}")
|
|
||||||
private String adminEmail;
|
|
||||||
|
|
||||||
@Value("${app.admin.password:}")
|
|
||||||
private String adminPassword;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run(ApplicationArguments args) {
|
|
||||||
if (userRepository.existsByRole("admin")) {
|
|
||||||
log.info("Admin account already present, skipping bootstrap");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!StringUtils.hasText(adminEmail) || !StringUtils.hasText(adminPassword)) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Production requires ADMIN_EMAIL and ADMIN_PASSWORD when no admin user exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
User admin = new User();
|
|
||||||
admin.setEmail(adminEmail.trim());
|
|
||||||
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
|
||||||
admin.setRole("admin");
|
|
||||||
userRepository.save(admin);
|
|
||||||
|
|
||||||
log.info("Created production admin account for {}", admin.getEmail());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,6 +12,4 @@ public interface UserRepository extends JpaRepository<User, UUID> {
|
||||||
Optional<User> findByEmail(String email);
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
boolean existsByEmail(String email);
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
boolean existsByRole(String role);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
spring:
|
spring:
|
||||||
flyway:
|
|
||||||
locations: classpath:db/migration,classpath:db/dev-migration
|
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
|
url: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
spring:
|
|
||||||
flyway:
|
|
||||||
locations: classpath:db/migration
|
|
||||||
# Prod DBs created before seeds moved to db/dev-migration still list V2/V4/V6 in history.
|
|
||||||
ignore-migration-patterns: '*:missing'
|
|
||||||
|
|
||||||
app:
|
|
||||||
admin:
|
|
||||||
email: ${ADMIN_EMAIL}
|
|
||||||
password: ${ADMIN_PASSWORD}
|
|
||||||
|
|
@ -22,7 +22,7 @@ spring:
|
||||||
|
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
locations: classpath:db/migration,classpath:db/dev-migration
|
locations: classpath:db/migration
|
||||||
|
|
||||||
app:
|
app:
|
||||||
payment:
|
payment:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
-- Dev/CI only: test user for local docker and e2e (password: test1234)
|
|
||||||
INSERT INTO users (id, email, password_hash, subscription)
|
INSERT INTO users (id, email, password_hash, subscription)
|
||||||
VALUES (
|
VALUES (
|
||||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||||
'test@bilhej.se',
|
'test@bilhalsning.se',
|
||||||
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
|
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
|
||||||
'none'
|
'none'
|
||||||
);
|
);
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
-- Dev/CI only: admin for local docker and e2e (password: test1234)
|
|
||||||
INSERT INTO users (id, email, password_hash, subscription, role)
|
INSERT INTO users (id, email, password_hash, subscription, role)
|
||||||
VALUES (
|
VALUES (
|
||||||
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
-- Dev/CI only: sample orders for test@bilhej.se (id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
|
-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
|
||||||
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
|
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
package se.bilhalsning.config;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.boot.DefaultApplicationArguments;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class AdminBootstrapTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private AdminBootstrap adminBootstrap;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
ReflectionTestUtils.setField(adminBootstrap, "adminEmail", "admin@bilhej.se");
|
|
||||||
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "secure-production-password");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldSkipBootstrapWhenAdminAlreadyExists() {
|
|
||||||
when(userRepository.existsByRole("admin")).thenReturn(true);
|
|
||||||
|
|
||||||
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
|
|
||||||
|
|
||||||
verify(userRepository, never()).save(any(User.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldCreateAdminWhenMissing() {
|
|
||||||
when(userRepository.existsByRole("admin")).thenReturn(false);
|
|
||||||
when(passwordEncoder.encode("secure-production-password")).thenReturn("encoded-hash");
|
|
||||||
|
|
||||||
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
|
|
||||||
|
|
||||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
|
||||||
verify(userRepository).save(captor.capture());
|
|
||||||
User saved = captor.getValue();
|
|
||||||
org.junit.jupiter.api.Assertions.assertEquals("admin@bilhej.se", saved.getEmail());
|
|
||||||
org.junit.jupiter.api.Assertions.assertEquals("encoded-hash", saved.getPasswordHash());
|
|
||||||
org.junit.jupiter.api.Assertions.assertEquals("admin", saved.getRole());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldFailWhenCredentialsMissingAndNoAdmin() {
|
|
||||||
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "");
|
|
||||||
when(userRepository.existsByRole("admin")).thenReturn(false);
|
|
||||||
|
|
||||||
org.junit.jupiter.api.Assertions.assertThrows(
|
|
||||||
IllegalStateException.class,
|
|
||||||
() -> adminBootstrap.run(new DefaultApplicationArguments(new String[] {})));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -43,7 +43,7 @@ class AdminControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
|
||||||
void shouldReturn403ForNonAdminUser() throws Exception {
|
void shouldReturn403ForNonAdminUser() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/orders"))
|
mockMvc.perform(get("/api/admin/orders"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
|
|
@ -52,14 +52,14 @@ class AdminControllerTest {
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldReturnAllOrdersForAdmin() throws Exception {
|
void shouldReturnAllOrdersForAdmin() throws Exception {
|
||||||
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
|
||||||
when(orderService.getAllOrders()).thenReturn(List.of(order));
|
when(orderService.getAllOrders()).thenReturn(List.of(order));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/orders"))
|
mockMvc.perform(get("/api/admin/orders"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
|
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
|
||||||
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
|
.andExpect(jsonPath("$[0].email").value("test@bilhalsning.se"))
|
||||||
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
||||||
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
|
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
|
||||||
.andExpect(jsonPath("$[0].status").value("sent"));
|
.andExpect(jsonPath("$[0].status").value("sent"));
|
||||||
|
|
@ -86,7 +86,7 @@ class AdminControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
|
||||||
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
|
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||||
|
|
@ -99,7 +99,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldUpdateOrderStatusSuccessfully() throws Exception {
|
void shouldUpdateOrderStatusSuccessfully() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID);
|
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.PAID);
|
||||||
|
|
||||||
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
|
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ class AdminControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
|
||||||
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
|
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}",
|
mockMvc.perform(patch("/api/admin/orders/{id}",
|
||||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||||
|
|
@ -167,7 +167,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldUpdateTrackingSuccessfully() throws Exception {
|
void shouldUpdateTrackingSuccessfully() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
|
||||||
order.setTrackingId("PN123456789");
|
order.setTrackingId("PN123456789");
|
||||||
|
|
||||||
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
|
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
|
||||||
|
|
@ -184,7 +184,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldClearTrackingWhenNull() throws Exception {
|
void shouldClearTrackingWhenNull() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
|
||||||
order.setTrackingId(null);
|
order.setTrackingId(null);
|
||||||
|
|
||||||
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);
|
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,14 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
|
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
|
||||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(userId);
|
user.setId(userId);
|
||||||
user.setEmail("test@bilhej.se");
|
user.setEmail("test@bilhalsning.se");
|
||||||
|
|
||||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
|
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
|
@ -60,14 +60,14 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldReturnOrderWithAllFields() throws Exception {
|
void shouldReturnOrderWithAllFields() throws Exception {
|
||||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(userId);
|
user.setId(userId);
|
||||||
user.setEmail("test@bilhej.se");
|
user.setEmail("test@bilhalsning.se");
|
||||||
|
|
||||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||||
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
||||||
|
|
@ -106,14 +106,14 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldCreateOrderSuccessfully() throws Exception {
|
void shouldCreateOrderSuccessfully() throws Exception {
|
||||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(userId);
|
user.setId(userId);
|
||||||
user.setEmail("test@bilhej.se");
|
user.setEmail("test@bilhalsning.se");
|
||||||
|
|
||||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
|
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
|
||||||
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
||||||
|
|
@ -136,7 +136,7 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldRejectInvalidPlateFormat() throws Exception {
|
void shouldRejectInvalidPlateFormat() throws Exception {
|
||||||
mockMvc.perform(post("/api/orders")
|
mockMvc.perform(post("/api/orders")
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
|
|
@ -146,7 +146,7 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldRejectEmptyLetterText() throws Exception {
|
void shouldRejectEmptyLetterText() throws Exception {
|
||||||
mockMvc.perform(post("/api/orders")
|
mockMvc.perform(post("/api/orders")
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
|
|
@ -155,7 +155,7 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldRejectLetterTextOver1000Chars() throws Exception {
|
void shouldRejectLetterTextOver1000Chars() throws Exception {
|
||||||
String longText = "a".repeat(1001);
|
String longText = "a".repeat(1001);
|
||||||
mockMvc.perform(post("/api/orders")
|
mockMvc.perform(post("/api/orders")
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class PaymentControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldConfirmPaymentSuccessfully() throws Exception {
|
void shouldConfirmPaymentSuccessfully() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
Order order = new Order();
|
Order order = new Order();
|
||||||
|
|
@ -57,7 +57,7 @@ class PaymentControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldReturn404WhenOrderNotFound() throws Exception {
|
void shouldReturn404WhenOrderNotFound() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
when(orderService.confirmPayment(eq(orderId)))
|
when(orderService.confirmPayment(eq(orderId)))
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
package se.bilhalsning.tools;
|
|
||||||
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
|
|
||||||
/** Run: ./gradlew hashPassword -Ppassword='your-password' */
|
|
||||||
public final class BcryptHashCli {
|
|
||||||
|
|
||||||
private BcryptHashCli() {}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
if (args.length != 1) {
|
|
||||||
System.err.println("Usage: gradlew hashPassword -Ppassword='...'");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
System.out.println(new BCryptPasswordEncoder(12).encode(args[0]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,8 +8,6 @@ services:
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata-prod:/var/lib/postgresql/data
|
- pgdata-prod:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "127.0.0.1:5433:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|
@ -23,7 +21,7 @@ services:
|
||||||
context: .
|
context: .
|
||||||
container_name: bilhej-backend-prod
|
container_name: bilhej-backend-prod
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: docker,prod
|
SPRING_PROFILES_ACTIVE: docker
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
@ -32,8 +30,6 @@ services:
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||||
SWISH_NUMBER: ${SWISH_NUMBER}
|
SWISH_NUMBER: ${SWISH_NUMBER}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ test.describe('Admin dashboard', () => {
|
||||||
test('non-admin user is redirected away from admin', async ({ page }) => {
|
test('non-admin user is redirected away from admin', async ({ page }) => {
|
||||||
await page.evaluate(() => localStorage.clear())
|
await page.evaluate(() => localStorage.clear())
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ test.describe('Compose flow', () => {
|
||||||
|
|
||||||
test('shows error when no plate is provided', async ({ page }) => {
|
test('shows error when no plate is provided', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -21,7 +21,7 @@ test.describe('Compose flow', () => {
|
||||||
|
|
||||||
test('displays plate and textarea', async ({ page }) => {
|
test('displays plate and textarea', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -37,7 +37,7 @@ test.describe('Compose flow', () => {
|
||||||
|
|
||||||
test('submit button disabled when textarea is empty', async ({ page }) => {
|
test('submit button disabled when textarea is empty', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -50,7 +50,7 @@ test.describe('Compose flow', () => {
|
||||||
|
|
||||||
test('can create order and navigate to payment page', async ({ page }) => {
|
test('can create order and navigate to payment page', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -69,7 +69,7 @@ test.describe('Compose flow', () => {
|
||||||
|
|
||||||
test('preview shows letter content and GDPR footer', async ({ page }) => {
|
test('preview shows letter content and GDPR footer', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -86,7 +86,7 @@ test.describe('Compose flow', () => {
|
||||||
|
|
||||||
test('Visa mallar button opens template picker', async ({ page }) => {
|
test('Visa mallar button opens template picker', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -104,7 +104,7 @@ test.describe('Compose flow', () => {
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ test.describe('Deferred payment and admin lookup', () => {
|
||||||
|
|
||||||
async function loginAsTestUser(page: import('@playwright/test').Page) {
|
async function loginAsTestUser(page: import('@playwright/test').Page) {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ test.describe('Header auth state', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows email and logout when authenticated', async ({ page }) => {
|
test('shows email and logout when authenticated', async ({ page }) => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(token) => localStorage.setItem('auth_token', token),
|
(token) => localStorage.setItem('auth_token', token),
|
||||||
|
|
@ -32,14 +32,14 @@ test.describe('Header auth state', () => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
const header = page.locator('header')
|
const header = page.locator('header')
|
||||||
await expect(header.getByText('test@bilhej.se')).toBeVisible()
|
await expect(header.getByText('test@bilhalsning.se')).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
header.getByRole('button', { name: 'Logga ut' }),
|
header.getByRole('button', { name: 'Logga ut' }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows orders link when authenticated', async ({ page }) => {
|
test('shows orders link when authenticated', async ({ page }) => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(token) => localStorage.setItem('auth_token', token),
|
(token) => localStorage.setItem('auth_token', token),
|
||||||
|
|
@ -58,7 +58,7 @@ test.describe('Header auth state', () => {
|
||||||
test('hides login and register links when authenticated', async ({
|
test('hides login and register links when authenticated', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(token) => localStorage.setItem('auth_token', token),
|
(token) => localStorage.setItem('auth_token', token),
|
||||||
|
|
@ -76,7 +76,7 @@ test.describe('Header auth state', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('logout restores login and register links', async ({ page }) => {
|
test('logout restores login and register links', async ({ page }) => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(token) => localStorage.setItem('auth_token', token),
|
(token) => localStorage.setItem('auth_token', token),
|
||||||
|
|
@ -96,11 +96,11 @@ test.describe('Header auth state', () => {
|
||||||
await expect(
|
await expect(
|
||||||
header.getByRole('button', { name: 'Logga ut' }),
|
header.getByRole('button', { name: 'Logga ut' }),
|
||||||
).not.toBeVisible()
|
).not.toBeVisible()
|
||||||
await expect(header.getByText('test@bilhej.se')).not.toBeVisible()
|
await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('logout redirects to home page', async ({ page }) => {
|
test('logout redirects to home page', async ({ page }) => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
await page.goto('/orders')
|
await page.goto('/orders')
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(token) => localStorage.setItem('auth_token', token),
|
(token) => localStorage.setItem('auth_token', token),
|
||||||
|
|
@ -130,7 +130,7 @@ test.describe('Header auth state', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not show admin link for regular user', async ({ page }) => {
|
test('does not show admin link for regular user', async ({ page }) => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(token) => localStorage.setItem('auth_token', token),
|
(token) => localStorage.setItem('auth_token', token),
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ test.describe('Login page', () => {
|
||||||
|
|
||||||
test('redirects to home after successful login', async ({ page }) => {
|
test('redirects to home after successful login', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ test.describe('Order history', () => {
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -27,7 +27,7 @@ test.describe('Order history', () => {
|
||||||
|
|
||||||
test('displays page heading and seeded orders', async ({ page }) => {
|
test('displays page heading and seeded orders', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -42,7 +42,7 @@ test.describe('Order history', () => {
|
||||||
|
|
||||||
test('shows correct status badges', async ({ page }) => {
|
test('shows correct status badges', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -58,7 +58,7 @@ test.describe('Order history', () => {
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
@ -76,7 +76,7 @@ test.describe('Order history', () => {
|
||||||
|
|
||||||
test('shows tracking links for orders with tracking ID', async ({ page }) => {
|
test('shows tracking links for orders with tracking ID', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'
|
||||||
test.describe('Payment redirect', () => {
|
test.describe('Payment redirect', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ test.describe('Vehicle lookup', () => {
|
||||||
|
|
||||||
test('CTA navigates to compose when authenticated', async ({ page }) => {
|
test('CTA navigates to compose when authenticated', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
await page.waitForURL('/')
|
await page.waitForURL('/')
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ function mountPage() {
|
||||||
const mockOrders = [
|
const mockOrders = [
|
||||||
{
|
{
|
||||||
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||||
email: 'test@bilhej.se',
|
email: 'test@bilhalsning.se',
|
||||||
plate: 'ABC123',
|
plate: 'ABC123',
|
||||||
letterText: 'Hej fin bil!',
|
letterText: 'Hej fin bil!',
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
|
@ -111,7 +111,7 @@ describe('AdminDashboard', () => {
|
||||||
it('renders order data in rows', async () => {
|
it('renders order data in rows', async () => {
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
expect(wrapper.text()).toContain('test@bilhej.se')
|
expect(wrapper.text()).toContain('test@bilhalsning.se')
|
||||||
expect(wrapper.text()).toContain('ABC123')
|
expect(wrapper.text()).toContain('ABC123')
|
||||||
expect(wrapper.text()).toContain('user@example.com')
|
expect(wrapper.text()).toContain('user@example.com')
|
||||||
expect(wrapper.text()).toContain('XYZ789')
|
expect(wrapper.text()).toContain('XYZ789')
|
||||||
|
|
@ -350,7 +350,7 @@ describe('AdminDashboard', () => {
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('pending@example.com')
|
expect(wrapper.text()).toContain('pending@example.com')
|
||||||
expect(wrapper.text()).not.toContain('test@bilhej.se')
|
expect(wrapper.text()).not.toContain('test@bilhalsning.se')
|
||||||
expect(wrapper.text()).not.toContain('user@example.com')
|
expect(wrapper.text()).not.toContain('user@example.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -362,7 +362,7 @@ describe('AdminDashboard', () => {
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('user@example.com')
|
expect(wrapper.text()).toContain('user@example.com')
|
||||||
expect(wrapper.text()).not.toContain('test@bilhej.se')
|
expect(wrapper.text()).not.toContain('test@bilhalsning.se')
|
||||||
expect(wrapper.text()).not.toContain('pending@example.com')
|
expect(wrapper.text()).not.toContain('pending@example.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -373,7 +373,7 @@ describe('AdminDashboard', () => {
|
||||||
await wrapper.find('#admin-order-search').setValue('abc123')
|
await wrapper.find('#admin-order-search').setValue('abc123')
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('test@bilhej.se')
|
expect(wrapper.text()).toContain('test@bilhalsning.se')
|
||||||
expect(wrapper.text()).not.toContain('user@example.com')
|
expect(wrapper.text()).not.toContain('user@example.com')
|
||||||
expect(wrapper.text()).not.toContain('pending@example.com')
|
expect(wrapper.text()).not.toContain('pending@example.com')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ describe('AppHeader', () => {
|
||||||
|
|
||||||
describe('when authenticated', () => {
|
describe('when authenticated', () => {
|
||||||
function mountAuthenticated(role = 'user') {
|
function mountAuthenticated(role = 'user') {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role })
|
||||||
localStorage.setItem('auth_token', jwt)
|
localStorage.setItem('auth_token', jwt)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
|
|
@ -132,7 +132,7 @@ describe('AppHeader', () => {
|
||||||
|
|
||||||
it('shows user email', () => {
|
it('shows user email', () => {
|
||||||
const { wrapper } = mountAuthenticated()
|
const { wrapper } = mountAuthenticated()
|
||||||
expect(wrapper.text()).toContain('test@bilhej.se')
|
expect(wrapper.text()).toContain('test@bilhalsning.se')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows logout button', () => {
|
it('shows logout button', () => {
|
||||||
|
|
|
||||||
|
|
@ -181,15 +181,15 @@ describe('authStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('extracts email from JWT sub claim', async () => {
|
it('extracts email from JWT sub claim', async () => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
mockFetchResponse(200, { token: jwt }),
|
mockFetchResponse(200, { token: jwt }),
|
||||||
)
|
)
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
|
||||||
await store.loginUser('test@bilhej.se', 'test1234')
|
await store.loginUser('test@bilhalsning.se', 'test1234')
|
||||||
|
|
||||||
expect(store.email).toBe('test@bilhej.se')
|
expect(store.email).toBe('test@bilhalsning.se')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null email when not authenticated', () => {
|
it('returns null email when not authenticated', () => {
|
||||||
|
|
@ -198,14 +198,14 @@ describe('authStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears email on logout', async () => {
|
it('clears email on logout', async () => {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
mockFetchResponse(200, { token: jwt }),
|
mockFetchResponse(200, { token: jwt }),
|
||||||
)
|
)
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
|
||||||
await store.loginUser('test@bilhej.se', 'test1234')
|
await store.loginUser('test@bilhalsning.se', 'test1234')
|
||||||
expect(store.email).toBe('test@bilhej.se')
|
expect(store.email).toBe('test@bilhalsning.se')
|
||||||
|
|
||||||
store.logout()
|
store.logout()
|
||||||
expect(store.email).toBeNull()
|
expect(store.email).toBeNull()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue