Compare commits

...

4 commits

Author SHA1 Message Date
350dfcfd7b Document production database access and admin setup.
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 1m54s
CI / E2E browser tests (push) Failing after 1m49s
Operators need IntelliJ-style GUI access to Docker Postgres and clear
steps for manual prod cleanup without wiping volumes.

- Add Database access section with IntelliJ, DBeaver, and SSH tunnel steps
- Document dev-only accounts, manual SQL cleanup, and hashPassword task
- Note Flyway dev-migration split and admin bootstrap in AGENTS.md
2026-05-21 15:14:14 +02:00
93ece8128a Use bilhej.se domain for dev test user email.
Aligns seeded and test login addresses with production branding while
keeping admin@bilhalsning.se for local docker admin seed only.

- Change test@bilhalsning.se to test@bilhej.se in dev migration and all tests
2026-05-21 15:14:11 +02:00
75911dfffa Separate dev database seeds from production and bootstrap prod admin.
Production must not ship test users, demo orders, or test1234. Dev and CI
still need seeded users for e2e. Prod creates one admin from deploy secrets.

- Move V2/V4/V6 seed migrations to db/dev-migration
- Add application-prod.yml with schema-only Flyway and ignore-missing for moved seeds
- Add AdminBootstrap to create admin from ADMIN_EMAIL and ADMIN_PASSWORD
- Wire docker,prod profile, deploy secrets, and localhost:5433 for SSH DB access
- Add hashPassword Gradle task for optional manual bcrypt generation
2026-05-21 15:14:03 +02:00
4385f43b08 Add application-prod.yml for production Flyway config 2026-05-21 15:13:56 +02:00
30 changed files with 338 additions and 67 deletions

View file

@ -26,3 +26,8 @@ STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
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

View file

@ -38,6 +38,8 @@ jobs:
STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PRICE_ID=${{ secrets.STRIPE_PRICE_ID }}
SWISH_NUMBER=${{ secrets.SWISH_NUMBER }}
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
EOF
- name: Build and start production stack

View file

@ -76,6 +76,10 @@ live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descript
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

View file

@ -73,8 +73,8 @@ No CORS configuration needed in development — the browser never calls the back
| Profile | Datasource | Use |
|---------|-----------|-----|
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
| `docker` | PostgreSQL via `docker-compose.yml` | Docker Compose dev |
| `prod` | PostgreSQL (production config) | Deploy (`docker-compose.prod.yml`) |
| `docker` | PostgreSQL + dev Flyway seeds | Docker Compose dev / CI |
| `docker,prod` | PostgreSQL, schema only, admin bootstrap | Deploy (`docker-compose.prod.yml`) |
---
@ -91,6 +91,89 @@ Copy `.env.example` to `.env` and fill in:
| `STRIPE_SECRET_KEY` | Stripe secret key |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `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'
```
---
@ -187,6 +270,15 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
| `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**
@ -225,7 +317,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| Tag | Git tag `v0.1.0` is created and pushed |
| Build | Production backend JAR and frontend bundle are built |
| Images | Multi-stage Docker images are built locally on the server |
| Start | `docker compose -f docker-compose.prod.yml up -d` |
| Start | `docker compose -f docker-compose.prod.yml up -d` (`SPRING_PROFILES_ACTIVE=docker,prod`) |
| Verify | Health checks confirm backend API and frontend are responding |
### Architecture on Server

View file

@ -82,3 +82,11 @@ jacocoTestCoverageVerification {
tasks.named('check').configure {
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')] : []
}

View file

@ -0,0 +1,50 @@
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());
}
}

View file

@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
boolean existsByRole(String role);
}

View file

@ -1,4 +1,7 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/dev-migration
datasource:
url: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
driver-class-name: org.postgresql.Driver

View file

@ -0,0 +1,10 @@
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}

View file

@ -22,7 +22,7 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration
locations: classpath:db/migration,classpath:db/dev-migration
app:
payment:

View file

@ -1,7 +1,8 @@
-- Dev/CI only: test user for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription)
VALUES (
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'test@bilhalsning.se',
'test@bilhej.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none'
);

View file

@ -1,3 +1,4 @@
-- Dev/CI only: admin for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription, role)
VALUES (
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',

View file

@ -1,4 +1,4 @@
-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
-- Dev/CI only: sample orders for test@bilhej.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)
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'),

View file

@ -0,0 +1,72 @@
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[] {})));
}
}

View file

@ -43,7 +43,7 @@ class AdminControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
@WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden());
@ -52,14 +52,14 @@ class AdminControllerTest {
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnAllOrdersForAdmin() throws Exception {
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhej.se", OrderStatus.SENT);
when(orderService.getAllOrders()).thenReturn(List.of(order));
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
.andExpect(jsonPath("$[0].email").value("test@bilhalsning.se"))
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent"));
@ -86,7 +86,7 @@ class AdminControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
@WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
@ -99,7 +99,7 @@ class AdminControllerTest {
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.PAID);
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID);
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
@ -154,7 +154,7 @@ class AdminControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
@WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
@ -167,7 +167,7 @@ class AdminControllerTest {
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateTrackingSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
@ -184,7 +184,7 @@ class AdminControllerTest {
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldClearTrackingWhenNull() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
order.setTrackingId(null);
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);

View file

@ -42,14 +42,14 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhalsning.se");
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
@ -60,14 +60,14 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrderWithAllFields() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhalsning.se");
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
@ -106,14 +106,14 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldCreateOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhalsning.se");
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
@ -136,7 +136,7 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
@ -146,7 +146,7 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldRejectEmptyLetterText() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
@ -155,7 +155,7 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldRejectLetterTextOver1000Chars() throws Exception {
String longText = "a".repeat(1001);
mockMvc.perform(post("/api/orders")

View file

@ -39,7 +39,7 @@ class PaymentControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldConfirmPaymentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
@ -57,7 +57,7 @@ class PaymentControllerTest {
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.confirmPayment(eq(orderId)))

View file

@ -0,0 +1,17 @@
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]));
}
}

View file

@ -8,6 +8,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata-prod:/var/lib/postgresql/data
ports:
- "127.0.0.1:5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
@ -21,7 +23,7 @@ services:
context: .
container_name: bilhej-backend-prod
environment:
SPRING_PROFILES_ACTIVE: docker
SPRING_PROFILES_ACTIVE: docker,prod
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@ -30,6 +32,8 @@ services:
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
SWISH_NUMBER: ${SWISH_NUMBER}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
depends_on:
postgres:
condition: service_healthy

View file

@ -20,7 +20,7 @@ test.describe('Admin dashboard', () => {
test('non-admin user is redirected away from admin', async ({ page }) => {
await page.evaluate(() => localStorage.clear())
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -9,7 +9,7 @@ test.describe('Compose flow', () => {
test('shows error when no plate is provided', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -21,7 +21,7 @@ test.describe('Compose flow', () => {
test('displays plate and textarea', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -37,7 +37,7 @@ test.describe('Compose flow', () => {
test('submit button disabled when textarea is empty', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -50,7 +50,7 @@ test.describe('Compose flow', () => {
test('can create order and navigate to payment page', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -69,7 +69,7 @@ test.describe('Compose flow', () => {
test('preview shows letter content and GDPR footer', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -86,7 +86,7 @@ test.describe('Compose flow', () => {
test('Visa mallar button opens template picker', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -104,7 +104,7 @@ test.describe('Compose flow', () => {
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -16,7 +16,7 @@ test.describe('Deferred payment and admin lookup', () => {
async function loginAsTestUser(page: import('@playwright/test').Page) {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -23,7 +23,7 @@ test.describe('Header auth state', () => {
})
test('shows email and logout when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -32,14 +32,14 @@ test.describe('Header auth state', () => {
await page.goto('/')
const header = page.locator('header')
await expect(header.getByText('test@bilhalsning.se')).toBeVisible()
await expect(header.getByText('test@bilhej.se')).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).toBeVisible()
})
test('shows orders link when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -58,7 +58,7 @@ test.describe('Header auth state', () => {
test('hides login and register links when authenticated', async ({
page,
}) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -76,7 +76,7 @@ test.describe('Header auth state', () => {
})
test('logout restores login and register links', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -96,11 +96,11 @@ test.describe('Header auth state', () => {
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible()
await expect(header.getByText('test@bilhej.se')).not.toBeVisible()
})
test('logout redirects to home page', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders')
await page.evaluate(
(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 }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),

View file

@ -17,7 +17,7 @@ test.describe('Login page', () => {
test('redirects to home after successful login', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()

View file

@ -11,7 +11,7 @@ test.describe('Order history', () => {
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -27,7 +27,7 @@ test.describe('Order history', () => {
test('displays page heading and seeded orders', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -42,7 +42,7 @@ test.describe('Order history', () => {
test('shows correct status badges', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -58,7 +58,7 @@ test.describe('Order history', () => {
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -76,7 +76,7 @@ test.describe('Order history', () => {
test('shows tracking links for orders with tracking ID', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'
test.describe('Payment redirect', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -34,7 +34,7 @@ test.describe('Vehicle lookup', () => {
test('CTA navigates to compose when authenticated', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -37,7 +37,7 @@ function mountPage() {
const mockOrders = [
{
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
email: 'test@bilhalsning.se',
email: 'test@bilhej.se',
plate: 'ABC123',
letterText: 'Hej fin bil!',
status: 'sent',
@ -111,7 +111,7 @@ describe('AdminDashboard', () => {
it('renders order data in rows', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('test@bilhalsning.se')
expect(wrapper.text()).toContain('test@bilhej.se')
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('user@example.com')
expect(wrapper.text()).toContain('XYZ789')
@ -350,7 +350,7 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('pending@example.com')
expect(wrapper.text()).not.toContain('test@bilhalsning.se')
expect(wrapper.text()).not.toContain('test@bilhej.se')
expect(wrapper.text()).not.toContain('user@example.com')
})
@ -362,7 +362,7 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('user@example.com')
expect(wrapper.text()).not.toContain('test@bilhalsning.se')
expect(wrapper.text()).not.toContain('test@bilhej.se')
expect(wrapper.text()).not.toContain('pending@example.com')
})
@ -373,7 +373,7 @@ describe('AdminDashboard', () => {
await wrapper.find('#admin-order-search').setValue('abc123')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('test@bilhalsning.se')
expect(wrapper.text()).toContain('test@bilhej.se')
expect(wrapper.text()).not.toContain('user@example.com')
expect(wrapper.text()).not.toContain('pending@example.com')
})

View file

@ -119,7 +119,7 @@ describe('AppHeader', () => {
describe('when authenticated', () => {
function mountAuthenticated(role = 'user') {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role })
const jwt = makeJwt({ sub: 'test@bilhej.se', role })
localStorage.setItem('auth_token', jwt)
const pinia = createPinia()
setActivePinia(pinia)
@ -132,7 +132,7 @@ describe('AppHeader', () => {
it('shows user email', () => {
const { wrapper } = mountAuthenticated()
expect(wrapper.text()).toContain('test@bilhalsning.se')
expect(wrapper.text()).toContain('test@bilhej.se')
})
it('shows logout button', () => {

View file

@ -181,15 +181,15 @@ describe('authStore', () => {
})
it('extracts email from JWT sub claim', async () => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('test@bilhalsning.se', 'test1234')
await store.loginUser('test@bilhej.se', 'test1234')
expect(store.email).toBe('test@bilhalsning.se')
expect(store.email).toBe('test@bilhej.se')
})
it('returns null email when not authenticated', () => {
@ -198,14 +198,14 @@ describe('authStore', () => {
})
it('clears email on logout', async () => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('test@bilhalsning.se', 'test1234')
expect(store.email).toBe('test@bilhalsning.se')
await store.loginUser('test@bilhej.se', 'test1234')
expect(store.email).toBe('test@bilhej.se')
store.logout()
expect(store.email).toBeNull()