Public Access
Compare commits
7
Commits
395b8e6f99
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4701cebece | ||
|
|
c124d0e5d2 | ||
|
|
c362e7047e | ||
|
|
734ff2e07c | ||
|
|
d842d9e865 | ||
|
|
d924c09953 | ||
|
|
d9b816904a |
No files matched your search
@@ -1,22 +0,0 @@
|
|||||||
# docker-compose.dev.yml
|
|
||||||
services:
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# LOCAL DATABASE FOR DEVELOPMENT
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
tbp-db-local:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
container_name: tbp_db_local
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
# Map the container port 5432 to your local machine port 5432
|
|
||||||
- "5432:5432"
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=tbp_auth
|
|
||||||
- POSTGRES_USER=tbp_user
|
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
# Use a local volume to persist data across container restarts
|
|
||||||
- tbp_local_db_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
tbp_local_db_data:
|
|
||||||
@@ -86,11 +86,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.micrometer</groupId>
|
|
||||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- testing -->
|
<!-- testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
# To Be Played – Auth Service
|
# To Be Played – Auth Service
|
||||||
|
|
||||||
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the
|
Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||||
public profile, and it signs the JWTs that the Core service trusts.
|
by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It handles registration, login, user
|
||||||
|
settings and the public profile, and it signs the JWTs that the Core service trusts.
|
||||||
|
|
||||||
To Be Played is split into three repositories:
|
To Be Played is split into four repositories:
|
||||||
|
|
||||||
| Repository | Role |
|
| Repository | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
|
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
|
||||||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||||
| ToBePlayed-Frontend | React single-page app |
|
| ToBePlayed-Frontend | React single-page app |
|
||||||
|
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -25,14 +27,16 @@ To Be Played is split into three repositories:
|
|||||||
wrong password, and every failed login gets the same error.
|
wrong password, and every failed login gets the same error.
|
||||||
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
||||||
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
||||||
public key as a JWK, so Core finds the right key after a rotation.
|
public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends
|
||||||
|
every access token signed with the old key at once. The frontend renews them with the refresh cookie.
|
||||||
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
|
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
|
||||||
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
||||||
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
||||||
token that comes back revokes all tokens of that login. Logout revokes them too.
|
token that comes back revokes all tokens of that login. Logout revokes them too.
|
||||||
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
||||||
frontend origin. A `Bearer` header still works for API clients and tests.
|
frontend origin. A `Bearer` header still works for API clients and tests.
|
||||||
- User settings: display name (unique, case-insensitive), language, dark mode.
|
- User settings: display name (unique, case-insensitive), language, dark mode, and the chart form per stat group
|
||||||
|
(`stat_charts`, V31): bars, pie, donut, line or list. `StatCharts` holds the allowed forms per group.
|
||||||
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
||||||
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
||||||
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
||||||
@@ -174,19 +178,21 @@ To Be Played is split into three repositories:
|
|||||||
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
||||||
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||||
|
| `PUT` | `/api/users/settings/stat-chart` (`{group, chart}`; `chart` null = back to the default; `204`; `400 VALIDATION_FAILED` for an unknown group or form) | access token |
|
||||||
|
| `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token |
|
||||||
|
| `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
|
| `GET` | `/.well-known/jwks.json` (the current public key as a JWK, `Cache-Control: max-age=300`) | public |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
|
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
||||||
- PostgreSQL 17, Flyway migrations
|
- PostgreSQL 17, Flyway migrations
|
||||||
- JJWT with Ed25519 keys
|
- JJWT with Ed25519 keys
|
||||||
- Spring Boot Actuator + Micrometer (Prometheus)
|
- Spring Boot Actuator (`/actuator/health` only)
|
||||||
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
||||||
- CI: Forgejo Actions, multi-stage Docker image
|
- CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -248,8 +254,8 @@ sequenceDiagram
|
|||||||
B->>A: POST /api/auth/login
|
B->>A: POST /api/auth/login
|
||||||
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
||||||
B->>C: API call with tbp_access
|
B->>C: API call with tbp_access
|
||||||
C->>A: GET /.well-known/jwks.json (new kid only)
|
C->>A: GET /.well-known/jwks.json (unknown kid only)
|
||||||
A-->>C: Public keys (Ed25519)
|
A-->>C: Current public key (Ed25519, one JWK)
|
||||||
C-->>B: 200, or 401 when the access token expired
|
C-->>B: 200, or 401 when the access token expired
|
||||||
B->>A: POST /api/auth/refresh with tbp_refresh
|
B->>A: POST /api/auth/refresh with tbp_refresh
|
||||||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||||||
@@ -261,8 +267,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||||||
can read them.
|
can read them.
|
||||||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them
|
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core
|
||||||
again only for an unknown `kid`.
|
loads it again only for an unknown `kid`.
|
||||||
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
||||||
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
||||||
|
|
||||||
@@ -330,7 +336,7 @@ sequenceDiagram
|
|||||||
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
||||||
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
||||||
end
|
end
|
||||||
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
|
Note over A: after 10 tries FAILED and a warning in the log, SENT rows are removed after 30 days
|
||||||
```
|
```
|
||||||
|
|
||||||
### Registration flow
|
### Registration flow
|
||||||
@@ -394,16 +400,21 @@ sequenceDiagram
|
|||||||
|
|
||||||
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
||||||
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
|
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
|
||||||
(`AccountStatePublisher`); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer
|
(`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
|
||||||
nor a database connection waits for Core; a failed call only warns.
|
cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display
|
||||||
|
name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name:
|
||||||
|
`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket:
|
||||||
|
`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile
|
||||||
|
hides (#27). The call runs on the task executor, so neither the answer nor a database
|
||||||
|
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant A as Auth
|
participant A as Auth
|
||||||
participant C as Core
|
participant C as Core
|
||||||
A->>A: link / unlink / block / unblock / name change (commit)
|
A->>A: link / unlink / block / unblock / name change (commit)
|
||||||
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt}
|
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
|
||||||
C->>C: blocked_user, account_role_change, Steam ID in user_steam_sync, display name in account_name
|
C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name
|
||||||
Note over A,C: nightly 04:15
|
Note over A,C: nightly 04:15
|
||||||
A->>C: PUT /internal/accounts/states (every user)
|
A->>C: PUT /internal/accounts/states (every user)
|
||||||
```
|
```
|
||||||
@@ -422,7 +433,7 @@ other, then start them in this order (Core also starts without Auth and finds it
|
|||||||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||||||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||||||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||||||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
|
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
|
||||||
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||||||
|
|
||||||
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
||||||
@@ -442,18 +453,24 @@ Needs Java 25, Docker and your own keys.
|
|||||||
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
||||||
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
||||||
|
|
||||||
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the
|
||||||
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
environment. `local` must not be combined with `prod`.
|
||||||
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
|
|
||||||
local port.
|
Only `tbp_access` gets the domain from `APP_AUTH_COOKIE_DOMAIN` (blank = host-only; production sets the parent
|
||||||
|
domain, so Core gets it too). `tbp_refresh` (`Path=/api/auth`) and `tbp_steam_link` are always host-only. The cookies
|
||||||
|
are `Secure` (`app.auth.cookie-secure`, default `true`). Chrome and Firefox accept `Secure` cookies on
|
||||||
|
`http://localhost`; Safari drops them, so the `local` profile sets `cookie-secure: false`. With your own keys on Safari
|
||||||
|
set `APP_AUTH_COOKIE_SECURE=false`. Open the frontend on `localhost`, not `127.0.0.1`.
|
||||||
|
|
||||||
Configuration comes from environment variables:
|
Configuration comes from environment variables:
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
||||||
| `JWT_KID` | Key id written into the token header |
|
| `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank |
|
||||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
| `APP_AUTH_COOKIE_DOMAIN` | Domain of `tbp_access`, blank = host-only |
|
||||||
|
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the nightly cleanup deletes nothing (it still sends the account states to Core); the 7-day purge of unconfirmed registrations runs anyway |
|
||||||
|
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
|
||||||
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
||||||
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||||||
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
||||||
@@ -470,6 +487,13 @@ openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
|
|||||||
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Request timing log
|
||||||
|
|
||||||
|
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
|
||||||
|
duration, e.g. `GET /api/users/me 200 12 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
|
||||||
|
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
|
||||||
|
that leaves the filter chain is logged as `500`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package auth_service.configuration;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import org.springframework.web.servlet.HandlerMapping;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One INFO line per request: method, handler pattern, status, duration. The pattern (e.g. {@code /api/games/{appid}})
|
||||||
|
* stands for the path, so the line holds no ids, query, user or credentials. A request no handler took shows {@code -}.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class RequestTimingFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(RequestTimingFilter.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(
|
||||||
|
@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull FilterChain filterChain
|
||||||
|
) throws ServletException, IOException {
|
||||||
|
long start = System.nanoTime();
|
||||||
|
int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
status = response.getStatus();
|
||||||
|
} finally {
|
||||||
|
// An exception that leaves the chain ends as a 500; the container sets it only after this filter.
|
||||||
|
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||||
|
LOG.info("{} {} {} {} ms", request.getMethod(), pattern != null ? pattern : "-", status,
|
||||||
|
(System.nanoTime() - start) / 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The health check calls the actuator every 30 s; its lines would flood the log.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
|
||||||
|
return request.getRequestURI().startsWith("/actuator/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
package auth_service.modules.account.model;
|
package auth_service.modules.account.model;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One user as Core copies it.
|
* One user as Core copies it.
|
||||||
*
|
*
|
||||||
* @param roleChangedAt null when the role never changed on the admin page
|
* @param roleChangedAt null when the role never changed on the admin page
|
||||||
|
* @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser}
|
||||||
|
* @param publicProfile may be followed in Core, see {@link auth_service.shared.account.PublicUser}
|
||||||
|
* @param profileSections the profile parts the user shows ({@link auth_service.shared.account.ProfileSections}),
|
||||||
|
* null without a settings row
|
||||||
*/
|
*/
|
||||||
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) {
|
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
|
||||||
|
boolean listed, boolean publicProfile, List<String> profileSections) {
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,9 @@ import auth_service.modules.account.model.CoreAccountState;
|
|||||||
import auth_service.shared.account.ActiveAdmins;
|
import auth_service.shared.account.ActiveAdmins;
|
||||||
import auth_service.shared.account.ActiveUser;
|
import auth_service.shared.account.ActiveUser;
|
||||||
import auth_service.shared.account.EmailAddress;
|
import auth_service.shared.account.EmailAddress;
|
||||||
|
import auth_service.shared.account.ListedUser;
|
||||||
|
import auth_service.shared.account.ProfileSections;
|
||||||
|
import auth_service.shared.account.PublicUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
@@ -11,9 +14,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.sql.Array;
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -113,18 +119,33 @@ public class AccountRepository {
|
|||||||
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
|
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
|
||||||
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
|
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
|
||||||
!rs.getBoolean("enabled"),
|
!rs.getBoolean("enabled"),
|
||||||
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant());
|
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(),
|
||||||
|
rs.getBoolean("listed"), rs.getBoolean("public_profile"), sections(rs.getArray("profile_tiles")));
|
||||||
|
|
||||||
|
// Stored as "ID:SIZE", see PublicProfileRepository.tile.
|
||||||
|
private static List<String> sections(Array storedTiles) throws SQLException {
|
||||||
|
if (storedTiles == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ProfileSections.of(Arrays.stream((String[]) storedTiles.getArray())
|
||||||
|
.map(stored -> stored.replaceFirst(":[^:]*$", ""))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, "
|
||||||
|
+ "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed, "
|
||||||
|
+ "COALESCE(" + PublicUser.of("u", "s") + ", FALSE) AS public_profile, s.profile_tiles "
|
||||||
|
+ "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id";
|
||||||
|
|
||||||
public Optional<CoreAccountState> findState(long userId) {
|
public Optional<CoreAccountState> findState(long userId) {
|
||||||
return jdbc.query("SELECT id, steam_id, display_name, enabled, role_changed_at FROM users WHERE id = ?",
|
return jdbc.query(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst();
|
||||||
STATE_ROW, userId).stream().findFirst();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every user, also the flagged ones.
|
* Every user, also the flagged ones.
|
||||||
*/
|
*/
|
||||||
public List<CoreAccountState> findAllStates() {
|
public List<CoreAccountState> findAllStates() {
|
||||||
return jdbc.query("SELECT id, steam_id, display_name, enabled, role_changed_at FROM users ORDER BY id", STATE_ROW);
|
return jdbc.query(STATE_SELECT + " ORDER BY u.id", STATE_ROW);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import auth_service.modules.account.exception.LastAdminException;
|
|||||||
import auth_service.modules.account.exception.TooManyAttemptsException;
|
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||||
import auth_service.modules.account.repo.AccountRepository;
|
import auth_service.modules.account.repo.AccountRepository;
|
||||||
import auth_service.shared.account.AccountDeleter;
|
import auth_service.shared.account.AccountDeleter;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
import auth_service.shared.mail.AccountMailer;
|
import auth_service.shared.mail.AccountMailer;
|
||||||
import auth_service.shared.security.AttemptLimiter;
|
import auth_service.shared.security.AttemptLimiter;
|
||||||
import auth_service.shared.security.OpaqueToken;
|
import auth_service.shared.security.OpaqueToken;
|
||||||
@@ -38,18 +39,20 @@ public class AccountService implements AccountDeleter {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final CoreAccountService coreAccountService;
|
private final CoreAccountService coreAccountService;
|
||||||
private final AccountMailer accountMailer;
|
private final AccountMailer accountMailer;
|
||||||
|
private final AccountStatePublisher accountStatePublisher;
|
||||||
private final TransactionOperations transactions;
|
private final TransactionOperations transactions;
|
||||||
private final Duration retention;
|
private final Duration retention;
|
||||||
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
|
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
|
||||||
|
|
||||||
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
|
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
|
||||||
CoreAccountService coreAccountService, AccountMailer accountMailer,
|
CoreAccountService coreAccountService, AccountMailer accountMailer,
|
||||||
TransactionOperations transactions,
|
AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
|
||||||
@Value("${app.account-cleanup.retention}") Duration retention) {
|
@Value("${app.account-cleanup.retention}") Duration retention) {
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.coreAccountService = coreAccountService;
|
this.coreAccountService = coreAccountService;
|
||||||
this.accountMailer = accountMailer;
|
this.accountMailer = accountMailer;
|
||||||
|
this.accountStatePublisher = accountStatePublisher;
|
||||||
this.transactions = transactions;
|
this.transactions = transactions;
|
||||||
this.retention = retention;
|
this.retention = retention;
|
||||||
}
|
}
|
||||||
@@ -133,8 +136,10 @@ public class AccountService implements AccountDeleter {
|
|||||||
try {
|
try {
|
||||||
coreAccountService.markDeleted(List.of(userId));
|
coreAccountService.markDeleted(List.of(userId));
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId,
|
log.warn("Core did not take the delete of user {}, the state push tries again: {}", userId,
|
||||||
e.getMessage());
|
e.getMessage());
|
||||||
|
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,46 @@ import auth_service.modules.account.repo.AccountRepository;
|
|||||||
import auth_service.shared.account.AccountStatePublisher;
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core
|
* Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core
|
||||||
* (up to 13 s) would keep it from the pool.
|
* (up to 13 s) would keep it from the pool. A failed send is tried again later; the scheduler thread only hands the
|
||||||
|
* send back to the executor. Retries live in memory, so the nightly cleanup stays the fallback.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CoreAccountStatePublisher implements AccountStatePublisher {
|
public class CoreAccountStatePublisher implements AccountStatePublisher {
|
||||||
|
|
||||||
|
static final List<Duration> RETRY_DELAYS = List.of(Duration.ofSeconds(10), Duration.ofMinutes(1),
|
||||||
|
Duration.ofMinutes(5));
|
||||||
|
|
||||||
|
private static final int LOCK_STRIPES = 64;
|
||||||
|
|
||||||
|
private final Object[] sendLocks = new Object[LOCK_STRIPES];
|
||||||
private final AccountRepository accountRepository;
|
private final AccountRepository accountRepository;
|
||||||
private final CoreAccountService coreAccountService;
|
private final CoreAccountService coreAccountService;
|
||||||
private final Executor executor;
|
private final Executor executor;
|
||||||
|
private final TaskScheduler scheduler;
|
||||||
|
|
||||||
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
|
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
|
||||||
@Qualifier("applicationTaskExecutor") Executor executor) {
|
@Qualifier("applicationTaskExecutor") Executor executor,
|
||||||
|
@Qualifier("taskScheduler") TaskScheduler scheduler) {
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.coreAccountService = coreAccountService;
|
this.coreAccountService = coreAccountService;
|
||||||
this.executor = executor;
|
this.executor = executor;
|
||||||
|
this.scheduler = scheduler;
|
||||||
|
for (int i = 0; i < LOCK_STRIPES; i++) {
|
||||||
|
sendLocks[i] = new Object();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -36,20 +52,30 @@ public class CoreAccountStatePublisher implements AccountStatePublisher {
|
|||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
@Override
|
@Override
|
||||||
public void afterCommit() {
|
public void afterCommit() {
|
||||||
executor.execute(() -> send(userId));
|
executor.execute(() -> send(userId, 0));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
executor.execute(() -> send(userId));
|
executor.execute(() -> send(userId, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void send(long userId) {
|
private void send(long userId, int attempt) {
|
||||||
try {
|
try {
|
||||||
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
|
// Read and send under one lock per user: the send that runs last carries the newest state.
|
||||||
|
synchronized (sendLocks[(int) (userId % LOCK_STRIPES)]) {
|
||||||
|
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
|
||||||
|
}
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
|
if (attempt < RETRY_DELAYS.size()) {
|
||||||
e.getMessage());
|
log.warn("Core did not take the state of user {} (try {}), trying again later: {}", userId,
|
||||||
|
attempt + 1, e.getMessage());
|
||||||
|
scheduler.schedule(() -> executor.execute(() -> send(userId, attempt + 1)),
|
||||||
|
Instant.now().plus(RETRY_DELAYS.get(attempt)));
|
||||||
|
} else {
|
||||||
|
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,16 +2,14 @@ package auth_service.modules.profile.service;
|
|||||||
|
|
||||||
import auth_service.modules.profile.model.PublicProfile;
|
import auth_service.modules.profile.model.PublicProfile;
|
||||||
import auth_service.modules.profile.repo.PublicProfileRepository;
|
import auth_service.modules.profile.repo.PublicProfileRepository;
|
||||||
|
import auth_service.shared.account.ProfileSections;
|
||||||
import auth_service.shared.security.ProfileTicketIssuer;
|
import auth_service.shared.security.ProfileTicketIssuer;
|
||||||
import auth_service.shared.security.Public;
|
import auth_service.shared.security.Public;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Public
|
@Public
|
||||||
@@ -20,8 +18,6 @@ public class PublicProfileService {
|
|||||||
|
|
||||||
public record TicketedProfile(PublicProfile profile, String ticket) {}
|
public record TicketedProfile(PublicProfile profile, String ticket) {}
|
||||||
|
|
||||||
private static final String STATS_PREFIX = "STATS_";
|
|
||||||
|
|
||||||
private final PublicProfileRepository publicProfileRepository;
|
private final PublicProfileRepository publicProfileRepository;
|
||||||
private final ProfileTicketIssuer profileTicketIssuer;
|
private final ProfileTicketIssuer profileTicketIssuer;
|
||||||
|
|
||||||
@@ -33,15 +29,9 @@ public class PublicProfileService {
|
|||||||
.map(profile -> new TicketedProfile(profile, ticket(profile)));
|
.map(profile -> new TicketedProfile(profile, ticket(profile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
|
|
||||||
private String ticket(PublicProfile profile) {
|
private String ticket(PublicProfile profile) {
|
||||||
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id)
|
List<String> tileIds = profile.tiles().stream().map(PublicProfile.Tile::id).toList();
|
||||||
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX)));
|
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(),
|
||||||
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList();
|
ProfileSections.statGroups(tileIds), ProfileSections.of(tileIds));
|
||||||
List<String> sections = new ArrayList<>(byStats.get(false));
|
|
||||||
if (!statGroups.isEmpty()) {
|
|
||||||
sections.add("STATS");
|
|
||||||
}
|
|
||||||
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package auth_service.modules.stats.api;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.api.dto.PeopleDto;
|
||||||
|
import auth_service.modules.stats.api.mapper.ApiMapper;
|
||||||
|
import auth_service.modules.stats.model.UserCountKey;
|
||||||
|
import auth_service.modules.stats.service.UserListService;
|
||||||
|
import auth_service.shared.security.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserListController {
|
||||||
|
|
||||||
|
private final UserListService userListService;
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
public ResponseEntity<PeopleDto> getUsers(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestParam UserCountKey key,
|
||||||
|
@RequestParam(defaultValue = "0") int page) {
|
||||||
|
return ResponseEntity.ok(ApiMapper.toApi(userListService.users(principal.getIdAsLong(), key, page)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth_service.modules.stats.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who stands behind a number. Never an id, avatar or Steam ID (#26): a private user is only in privateCount, the
|
||||||
|
* viewer only in includesYou.
|
||||||
|
*/
|
||||||
|
public record PeopleDto(List<Person> listed, long listedTotal, long privateCount, boolean includesYou, int page,
|
||||||
|
int totalPages) {
|
||||||
|
|
||||||
|
public record Person(String displayName) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package auth_service.modules.stats.api.mapper;
|
package auth_service.modules.stats.api.mapper;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.api.dto.PeopleDto;
|
||||||
import auth_service.modules.stats.api.dto.PublicStatsDto;
|
import auth_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
|
import auth_service.modules.stats.model.PeopleList;
|
||||||
import auth_service.modules.stats.model.PublicStats;
|
import auth_service.modules.stats.model.PublicStats;
|
||||||
|
|
||||||
public abstract class ApiMapper {
|
public abstract class ApiMapper {
|
||||||
private ApiMapper() {
|
private ApiMapper() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PeopleDto toApi(PeopleList people) {
|
||||||
|
return new PeopleDto(people.listed().stream().map(PeopleDto.Person::new).toList(), people.listedTotal(),
|
||||||
|
people.privateCount(), people.includesYou(), people.page(), people.totalPages());
|
||||||
|
}
|
||||||
|
|
||||||
public static PublicStatsDto toApi(PublicStats stats) {
|
public static PublicStatsDto toApi(PublicStats stats) {
|
||||||
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
|
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.stats.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param others the users of a set without the viewer
|
||||||
|
* @param listed those of them shown by name
|
||||||
|
*/
|
||||||
|
public record PeopleCounts(long others, long listed, boolean includesYou) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth_service.modules.stats.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of a user list: names of the listed users only, the rest as a count.
|
||||||
|
*/
|
||||||
|
public record PeopleList(List<String> listed, long listedTotal, long privateCount, boolean includesYou, int page,
|
||||||
|
int totalPages) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.modules.stats.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user numbers of the landing page; each has a list behind it.
|
||||||
|
*/
|
||||||
|
public enum UserCountKey {
|
||||||
|
TOTAL_USERS,
|
||||||
|
STEAM_LINKED
|
||||||
|
}
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
package auth_service.modules.stats.repo;
|
package auth_service.modules.stats.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.model.PeopleCounts;
|
||||||
|
import auth_service.modules.stats.model.UserCountKey;
|
||||||
import auth_service.shared.account.ActiveUser;
|
import auth_service.shared.account.ActiveUser;
|
||||||
|
import auth_service.shared.account.ListedUser;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class StatsRepository {
|
public class StatsRepository {
|
||||||
|
|
||||||
|
// One WHERE per number: the count and its list read the same set.
|
||||||
|
private static final Map<UserCountKey, String> SETS = Map.of(
|
||||||
|
UserCountKey.TOTAL_USERS, ActiveUser.of("u"),
|
||||||
|
UserCountKey.STEAM_LINKED, ActiveUser.of("u") + " AND u.steam_id IS NOT NULL");
|
||||||
|
|
||||||
|
// LEFT JOIN: a user without a settings row is in the number, and private.
|
||||||
|
private static final String FROM = " FROM users u LEFT JOIN user_settings s ON s.user_id = u.id WHERE ";
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -16,13 +30,32 @@ public class StatsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public long countTotalUsers() {
|
public long countTotalUsers() {
|
||||||
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class);
|
return count(UserCountKey.TOTAL_USERS);
|
||||||
return v != null ? v : 0L;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUsersWithSteamLinked() {
|
public long countUsersWithSteamLinked() {
|
||||||
Long v = jdbc.queryForObject(
|
return count(UserCountKey.STEAM_LINKED);
|
||||||
"SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class);
|
}
|
||||||
|
|
||||||
|
private long count(UserCountKey key) {
|
||||||
|
Long v = jdbc.queryForObject("SELECT COUNT(*)" + FROM + SETS.get(key), Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PeopleCounts countUsers(UserCountKey key, long viewerId) {
|
||||||
|
return jdbc.queryForObject("SELECT COUNT(*) FILTER (WHERE u.id <> ?) AS others, "
|
||||||
|
+ "COUNT(*) FILTER (WHERE u.id <> ? AND " + ListedUser.of("u", "s") + ") AS listed, "
|
||||||
|
+ "COALESCE(BOOL_OR(u.id = ?), FALSE) AS you" + FROM + SETS.get(key),
|
||||||
|
(rs, row) -> new PeopleCounts(rs.getLong("others"), rs.getLong("listed"), rs.getBoolean("you")),
|
||||||
|
viewerId, viewerId, viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the display name and nothing else, so no id can reach a response.
|
||||||
|
*/
|
||||||
|
public List<String> listedUserNames(UserCountKey key, long viewerId, int offset, int limit) {
|
||||||
|
return jdbc.queryForList("SELECT u.display_name" + FROM + SETS.get(key) + " AND u.id <> ? AND "
|
||||||
|
+ ListedUser.of("u", "s") + " ORDER BY LOWER(u.display_name), u.id LIMIT ? OFFSET ?",
|
||||||
|
String.class, viewerId, limit, offset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package auth_service.modules.stats.service;
|
||||||
|
|
||||||
|
import auth_service.modules.stats.model.PeopleCounts;
|
||||||
|
import auth_service.modules.stats.model.PeopleList;
|
||||||
|
import auth_service.modules.stats.model.UserCountKey;
|
||||||
|
import auth_service.modules.stats.repo.StatsRepository;
|
||||||
|
import auth_service.shared.security.RequiresRole;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not cached: an opt-out shows on the next load.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserListService {
|
||||||
|
|
||||||
|
static final int PAGE_SIZE = 40;
|
||||||
|
static final int MAX_PAGE = 250;
|
||||||
|
|
||||||
|
private final StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public PeopleList users(long viewerId, UserCountKey key, int page) {
|
||||||
|
int clamped = Math.clamp(page, 0, MAX_PAGE);
|
||||||
|
PeopleCounts counts = statsRepository.countUsers(key, viewerId);
|
||||||
|
int totalPages = (int) ((counts.listed() + PAGE_SIZE - 1) / PAGE_SIZE);
|
||||||
|
List<String> names = statsRepository.listedUserNames(key, viewerId, clamped * PAGE_SIZE, PAGE_SIZE);
|
||||||
|
return new PeopleList(names, counts.listed(), counts.others() - counts.listed(), counts.includesYou(), clamped,
|
||||||
|
totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import auth_service.shared.security.UserPrincipal;
|
|||||||
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
||||||
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
|
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
|
||||||
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
|
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
|
||||||
|
import auth_service.modules.user.api.dto.request.OwnerListsRequest;
|
||||||
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
|
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
|
||||||
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
|
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
|
||||||
import auth_service.modules.user.api.dto.request.StatChartRequest;
|
import auth_service.modules.user.api.dto.request.StatChartRequest;
|
||||||
@@ -72,6 +73,13 @@ public class UserController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/settings/owner-lists")
|
||||||
|
public ResponseEntity<Void> updateOwnerLists(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody OwnerListsRequest request) {
|
||||||
|
this.userService.updateOwnerLists(principal.getIdAsLong(), request.enabled());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/welcome-seen")
|
@PutMapping(value = "/settings/welcome-seen")
|
||||||
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record OwnerListsRequest(@NotNull Boolean enabled) {
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
|
||||||
|
boolean showInOwnerLists,
|
||||||
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
|
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
|
||||||
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
||||||
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
|
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public abstract class ApiMapper {
|
|||||||
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
|
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
|
||||||
.language(user.getSettings().getLanguage())
|
.language(user.getSettings().getLanguage())
|
||||||
.publicProfile(user.getSettings().isPublicProfile())
|
.publicProfile(user.getSettings().isPublicProfile())
|
||||||
|
.showInOwnerLists(user.getSettings().isShowInOwnerLists())
|
||||||
.appearance(toApi(user.getSettings().getAppearance()))
|
.appearance(toApi(user.getSettings().getAppearance()))
|
||||||
.profileStyle(toApi(user.getSettings().getProfileStyle()))
|
.profileStyle(toApi(user.getSettings().getProfileStyle()))
|
||||||
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
|
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class UserSettings {
|
|||||||
private String language;
|
private String language;
|
||||||
private String theme;
|
private String theme;
|
||||||
private boolean publicProfile;
|
private boolean publicProfile;
|
||||||
|
/** Listed by name only together with publicProfile, see ListedUser. */
|
||||||
|
private boolean showInOwnerLists;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Appearance appearance = Appearance.DEFAULT;
|
private Appearance appearance = Appearance.DEFAULT;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class UserSettingsEntity {
|
|||||||
@Column(name = "public_profile", nullable = false)
|
@Column(name = "public_profile", nullable = false)
|
||||||
private boolean publicProfile;
|
private boolean publicProfile;
|
||||||
|
|
||||||
|
@Column(name = "show_in_owner_lists", nullable = false)
|
||||||
|
private boolean showInOwnerLists;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String accent;
|
private String accent;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public abstract class EntityMapper {
|
|||||||
.language(settings.getLanguage())
|
.language(settings.getLanguage())
|
||||||
.theme(settings.getTheme())
|
.theme(settings.getTheme())
|
||||||
.publicProfile(settings.isPublicProfile())
|
.publicProfile(settings.isPublicProfile())
|
||||||
|
.showInOwnerLists(settings.isShowInOwnerLists())
|
||||||
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
|
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
|
||||||
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(),
|
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(),
|
||||||
settings.isReduceMotion()))
|
settings.isReduceMotion()))
|
||||||
@@ -61,6 +62,7 @@ public abstract class EntityMapper {
|
|||||||
target.setLanguage(settings.getLanguage());
|
target.setLanguage(settings.getLanguage());
|
||||||
target.setTheme(settings.getTheme());
|
target.setTheme(settings.getTheme());
|
||||||
target.setPublicProfile(settings.isPublicProfile());
|
target.setPublicProfile(settings.isPublicProfile());
|
||||||
|
target.setShowInOwnerLists(settings.isShowInOwnerLists());
|
||||||
Appearance appearance = settings.getAppearance();
|
Appearance appearance = settings.getAppearance();
|
||||||
target.setAccent(appearance.accent());
|
target.setAccent(appearance.accent());
|
||||||
target.setCorners(appearance.corners());
|
target.setCorners(appearance.corners());
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import java.time.Instant;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -53,15 +54,18 @@ public class UserService {
|
|||||||
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
||||||
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
|
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
|
||||||
}
|
}
|
||||||
boolean renamed = displayName != null && userRepository.findById(userId)
|
// The listing in Core depends on the name and the public profile.
|
||||||
.map(user -> !displayName.equals(user.getDisplayName())).orElse(true);
|
boolean listingChanged = userRepository.findById(userId)
|
||||||
|
.map(user -> !Objects.equals(displayName, user.getDisplayName())
|
||||||
|
|| user.getSettings().isPublicProfile() != userSettings.isPublicProfile())
|
||||||
|
.orElse(displayName != null || userSettings.isPublicProfile());
|
||||||
userRepository.update(userId, user -> {
|
userRepository.update(userId, user -> {
|
||||||
user.setDisplayName(displayName);
|
user.setDisplayName(displayName);
|
||||||
user.getSettings().setLanguage(userSettings.getLanguage());
|
user.getSettings().setLanguage(userSettings.getLanguage());
|
||||||
user.getSettings().setTheme(userSettings.getTheme());
|
user.getSettings().setTheme(userSettings.getTheme());
|
||||||
user.getSettings().setPublicProfile(userSettings.isPublicProfile());
|
user.getSettings().setPublicProfile(userSettings.isPublicProfile());
|
||||||
});
|
});
|
||||||
if (renamed) {
|
if (listingChanged) {
|
||||||
accountStatePublisher.publish(userId);
|
accountStatePublisher.publish(userId);
|
||||||
}
|
}
|
||||||
log.info("User settings updated for user {}", userId);
|
log.info("User settings updated for user {}", userId);
|
||||||
@@ -126,6 +130,7 @@ public class UserService {
|
|||||||
@CacheEvict(value = "users", key = "#userId")
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
public void updateProfileTiles(long userId, List<Tile> tiles) {
|
public void updateProfileTiles(long userId, List<Tile> tiles) {
|
||||||
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
|
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +159,14 @@ public class UserService {
|
|||||||
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
|
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
public void updateOwnerLists(long userId, boolean enabled) {
|
||||||
|
userRepository.update(userId, user -> user.getSettings().setShowInOwnerLists(enabled));
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresRole(Role.USER)
|
@RequiresRole(Role.USER)
|
||||||
@Transactional
|
@Transactional
|
||||||
@CacheEvict(value = "users", key = "#userId")
|
@CacheEvict(value = "users", key = "#userId")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package auth_service.shared.account;
|
package auth_service.shared.account;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure only warns,
|
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure is retried 3
|
||||||
* because the nightly cleanup sends every user again.
|
* times, then the nightly cleanup sends every user again.
|
||||||
*/
|
*/
|
||||||
public interface AccountStatePublisher {
|
public interface AccountStatePublisher {
|
||||||
void publish(long userId);
|
void publish(long userId);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only rule that decides who is shown by name in owner and user lists (#26). Everyone else is private. The
|
||||||
|
* settings alias may come from a LEFT JOIN: no settings row is not listed.
|
||||||
|
*/
|
||||||
|
public final class ListedUser {
|
||||||
|
|
||||||
|
private ListedUser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String of(String user, String settings) {
|
||||||
|
return PublicUser.of(user, settings) + " AND " + settings + ".show_in_owner_lists";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile parts a user shows, by the names of the profile ticket. Core knows sections, not tiles: every
|
||||||
|
* {@code STATS_} tile is one group of the single STATS section.
|
||||||
|
*/
|
||||||
|
public final class ProfileSections {
|
||||||
|
|
||||||
|
private static final String STATS_PREFIX = "STATS_";
|
||||||
|
private static final String STATS = "STATS";
|
||||||
|
// The names Core stores; a tile id of any other kind is a user's own choice and says nothing to Core.
|
||||||
|
private static final Set<String> KNOWN = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", STATS);
|
||||||
|
|
||||||
|
private ProfileSections() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tileIds the tile ids without their size
|
||||||
|
*/
|
||||||
|
public static List<String> of(List<String> tileIds) {
|
||||||
|
List<String> sections = new ArrayList<>(tileIds.stream().filter(KNOWN::contains).toList());
|
||||||
|
if (tileIds.stream().anyMatch(id -> id.startsWith(STATS_PREFIX)) && !sections.contains(STATS)) {
|
||||||
|
sections.add(STATS);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tileIds the tile ids without their size
|
||||||
|
* @return the stat groups of Core, one per {@code STATS_} tile
|
||||||
|
*/
|
||||||
|
public static List<String> statGroups(List<String> tileIds) {
|
||||||
|
return tileIds.stream().filter(id -> id.startsWith(STATS_PREFIX)).map(id -> id.substring(STATS_PREFIX.length()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule Core uses to allow follows (#27): active, public profile on, with a display name. It matches
|
||||||
|
* {@code PublicProfileRepository.findPublicByDisplayName}. The settings alias may come from a LEFT JOIN: no settings
|
||||||
|
* row is not public.
|
||||||
|
*/
|
||||||
|
public final class PublicUser {
|
||||||
|
|
||||||
|
private PublicUser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String of(String user, String settings) {
|
||||||
|
return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + user + ".display_name IS NOT NULL";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Core item #3: the new UP_NEXT dashboard tile, added once at the end of every saved layout. A NULL layout
|
||||||
|
-- already shows the Frontend default, which has it. One-shot: a user who removes the tile later keeps it removed.
|
||||||
|
UPDATE user_settings
|
||||||
|
SET dashboard_tiles = array_append(dashboard_tiles, 'UP_NEXT:M')
|
||||||
|
WHERE dashboard_tiles IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM unnest(dashboard_tiles) t WHERE t LIKE 'UP_NEXT:%');
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Opt-in to be shown by name in owner and user lists (#26). Off for every existing user; a user is listed only
|
||||||
|
-- together with the public profile.
|
||||||
|
ALTER TABLE user_settings ADD COLUMN show_in_owner_lists BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package auth_service.configuration;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Level;
|
||||||
|
import ch.qos.logback.classic.Logger;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import ch.qos.logback.core.read.ListAppender;
|
||||||
|
import jakarta.servlet.ServletRequest;
|
||||||
|
import jakarta.servlet.ServletResponse;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.mock.web.MockFilterChain;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.web.servlet.HandlerMapping;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class RequestTimingFilterTest {
|
||||||
|
|
||||||
|
private final Logger logger = (Logger) LoggerFactory.getLogger(RequestTimingFilter.class);
|
||||||
|
private final ListAppender<ILoggingEvent> lines = new ListAppender<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void capture() {
|
||||||
|
lines.start();
|
||||||
|
logger.addAppender(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void release() {
|
||||||
|
logger.detachAppender(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String run(MockHttpServletRequest request, int status) throws Exception {
|
||||||
|
new RequestTimingFilter().doFilter(request, new MockHttpServletResponse(), new MockFilterChain() {
|
||||||
|
@Override
|
||||||
|
public void doFilter(ServletRequest req, ServletResponse res) {
|
||||||
|
((MockHttpServletResponse) res).setStatus(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertThat(lines.list).hasSize(1);
|
||||||
|
assertThat(lines.list.get(0).getLevel()).isEqualTo(Level.INFO);
|
||||||
|
return lines.list.get(0).getFormattedMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logsMethodPatternStatusAndMillisButNotThePathOrQuery() throws Exception {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/games/4711");
|
||||||
|
request.setQueryString("q=abc");
|
||||||
|
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/games/{appid}");
|
||||||
|
|
||||||
|
assertThat(run(request, 200)).matches("GET /api/games/\\{appid} 200 \\d+ ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logsADashForARequestNoHandlerTook() throws Exception {
|
||||||
|
assertThat(run(new MockHttpServletRequest("POST", "/some/path/42"), 404)).matches("POST - 404 \\d+ ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logsStatus500WhenTheChainThrows() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/x/1");
|
||||||
|
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/x/{id}");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new RequestTimingFilter().doFilter(request,
|
||||||
|
new MockHttpServletResponse(), (req, res) -> { throw new IllegalStateException("boom"); }))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
|
||||||
|
assertThat(lines.list).hasSize(1);
|
||||||
|
assertThat(lines.list.get(0).getFormattedMessage()).matches("GET /api/x/\\{id} 500 \\d+ ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logsNothingForTheActuator() throws Exception {
|
||||||
|
new RequestTimingFilter().doFilter(new MockHttpServletRequest("GET", "/actuator/health"),
|
||||||
|
new MockHttpServletResponse(), new MockFilterChain());
|
||||||
|
|
||||||
|
assertThat(lines.list).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,7 +298,8 @@ class SessionFlowTest extends AbstractIntegrationTest {
|
|||||||
.header("Access-Control-Request-Headers", "x-tbp-client"))
|
.header("Access-Control-Request-Headers", "x-tbp-client"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
|
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
|
||||||
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
|
.andExpect(header().string("Access-Control-Allow-Credentials", "true"))
|
||||||
|
.andExpect(header().string("Access-Control-Max-Age", "3600"));
|
||||||
|
|
||||||
mockMvc.perform(options("/api/auth/refresh")
|
mockMvc.perform(options("/api/auth/refresh")
|
||||||
.header("Origin", "https://evil.andreas-dahm.eu")
|
.header("Origin", "https://evil.andreas-dahm.eu")
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package auth_service.modules.account.api;
|
||||||
|
|
||||||
|
import auth_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
|
import auth_service.shared.api.AuthCookies;
|
||||||
|
import auth_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.timeout;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A listed user (#26) who deletes the account. Core is not reachable in tests, like Core down in production.
|
||||||
|
*/
|
||||||
|
class DeletedAccountListingTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final String EMAIL = "delete-listed@example.com";
|
||||||
|
private static final String PASSWORD = "correct horse battery";
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AccountStatePublisher accountStatePublisher;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private long userId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seedListedUser() {
|
||||||
|
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
|
||||||
|
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, ?, true, ?) "
|
||||||
|
+ "RETURNING id", Long.class, EMAIL, passwordEncoder.encode(PASSWORD), "DeleteListed");
|
||||||
|
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile, show_in_owner_lists) "
|
||||||
|
+ "VALUES (?, 'de', 'dark', true, true)", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanUp() {
|
||||||
|
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteWhileCoreIsDown_PushesTheUnlistedState() throws Exception {
|
||||||
|
Cookie access = mockMvc.perform(post("/api/auth/login")
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||||
|
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/users/me")
|
||||||
|
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||||
|
.cookie(access)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"" + PASSWORD + "\"}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(accountStatePublisher, timeout(2000)).publish(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,12 +56,46 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
|
|||||||
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
|
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
|
||||||
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
|
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
|
||||||
|
|
||||||
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null));
|
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false,
|
||||||
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null),
|
List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")));
|
||||||
new CoreAccountState(bob, null, null, false, null));
|
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false,
|
||||||
|
List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")),
|
||||||
|
new CoreAccountState(bob, null, null, false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")));
|
||||||
assertThat(repository.findState(-1L)).isEmpty();
|
assertThat(repository.findState(-1L)).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findStates_ListedOnlyForAnActivePublicOptedInUserWithAName() {
|
||||||
|
long listed = insertUser("listed@example.com");
|
||||||
|
jdbc.update("update users set display_name = 'Listed' where id = ?", listed);
|
||||||
|
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", listed);
|
||||||
|
assertThat(repository.findState(listed)).get().extracting(CoreAccountState::listed).isEqualTo(true);
|
||||||
|
|
||||||
|
String[] breakers = {"update users set enabled = false where id = ?",
|
||||||
|
"update users set deleted_at = now() where id = ?",
|
||||||
|
"update users set status = 'PENDING' where id = ?",
|
||||||
|
"update users set display_name = null where id = ?",
|
||||||
|
"update user_settings set public_profile = false where user_id = ?",
|
||||||
|
"update user_settings set show_in_owner_lists = false where user_id = ?"};
|
||||||
|
for (String breaker : breakers) {
|
||||||
|
jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Listed' where id = ?", listed);
|
||||||
|
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", listed);
|
||||||
|
jdbc.update(breaker, listed);
|
||||||
|
|
||||||
|
assertThat(repository.findState(listed)).as(breaker).get().extracting(CoreAccountState::listed).isEqualTo(false);
|
||||||
|
assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == listed)
|
||||||
|
.extracting(CoreAccountState::listed).containsExactly(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllStates_CarryTheListedFlag() {
|
||||||
|
jdbc.update("update users set display_name = 'Alice' where id = ?", alice);
|
||||||
|
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", alice);
|
||||||
|
|
||||||
|
assertThat(repository.findAllStates()).extracting(CoreAccountState::listed).containsExactly(true, false);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findStates_CarryTheRoleChangeTime() {
|
void findStates_CarryTheRoleChangeTime() {
|
||||||
jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice);
|
jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice);
|
||||||
@@ -240,4 +274,48 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(activeTokens(alice)).isZero();
|
assertThat(activeTokens(alice)).isZero();
|
||||||
assertThat(activeTokens(bob)).isEqualTo(2);
|
assertThat(activeTokens(bob)).isEqualTo(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findStates_PublicProfileOnlyForAnActivePublicUserWithAName_NoOptInNeeded() {
|
||||||
|
long open = insertUser("open@example.com");
|
||||||
|
jdbc.update("update users set display_name = 'Open' where id = ?", open);
|
||||||
|
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = false where user_id = ?", open);
|
||||||
|
assertThat(repository.findState(open)).get().extracting(CoreAccountState::publicProfile).isEqualTo(true);
|
||||||
|
|
||||||
|
String[] breakers = {"update users set enabled = false where id = ?",
|
||||||
|
"update users set deleted_at = now() where id = ?",
|
||||||
|
"update users set status = 'PENDING' where id = ?",
|
||||||
|
"update users set display_name = null where id = ?",
|
||||||
|
"update user_settings set public_profile = false where user_id = ?"};
|
||||||
|
for (String breaker : breakers) {
|
||||||
|
jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Open' where id = ?", open);
|
||||||
|
jdbc.update("update user_settings set public_profile = true where user_id = ?", open);
|
||||||
|
jdbc.update(breaker, open);
|
||||||
|
|
||||||
|
assertThat(repository.findState(open)).as(breaker).get().extracting(CoreAccountState::publicProfile).isEqualTo(false);
|
||||||
|
assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == open)
|
||||||
|
.extracting(CoreAccountState::publicProfile).containsExactly(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findStates_ProfileSectionsAreTheTileIdsWithAllStatsTilesCollapsedToOne() {
|
||||||
|
jdbc.update("update user_settings set profile_tiles = '{SUMMARY:L,STATS_BACKLOG:M,PLAYING:S,STATS_PLAYTIME:M}' where user_id = ?",
|
||||||
|
alice);
|
||||||
|
|
||||||
|
assertThat(repository.findState(alice)).get().extracting(CoreAccountState::profileSections)
|
||||||
|
.isEqualTo(List.of("SUMMARY", "PLAYING", "STATS"));
|
||||||
|
assertThat(repository.findAllStates()).extracting(CoreAccountState::profileSections)
|
||||||
|
.containsExactly(List.of("SUMMARY", "PLAYING", "STATS"), List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findStates_UserWithoutASettingsRow_HasNoSectionsAndIsNotPublic() {
|
||||||
|
jdbc.update("delete from user_settings where user_id = ?", alice);
|
||||||
|
|
||||||
|
assertThat(repository.findState(alice)).get().satisfies(state -> {
|
||||||
|
assertThat(state.profileSections()).isNull();
|
||||||
|
assertThat(state.publicProfile()).isFalse();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
class AccountCleanupServiceTest extends AbstractUnitTest {
|
class AccountCleanupServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null));
|
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false, false, null));
|
||||||
|
|
||||||
private static final Duration RETENTION = Duration.ofDays(30);
|
private static final Duration RETENTION = Duration.ofDays(30);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import auth_service.modules.account.exception.InvalidPasswordException;
|
|||||||
import auth_service.modules.account.exception.LastAdminException;
|
import auth_service.modules.account.exception.LastAdminException;
|
||||||
import auth_service.modules.account.exception.TooManyAttemptsException;
|
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||||
import auth_service.modules.account.repo.AccountRepository;
|
import auth_service.modules.account.repo.AccountRepository;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
import auth_service.shared.mail.AccountMailer;
|
import auth_service.shared.mail.AccountMailer;
|
||||||
import auth_service.shared.security.OpaqueToken;
|
import auth_service.shared.security.OpaqueToken;
|
||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
@@ -51,12 +52,15 @@ class AccountServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private AccountMailer accountMailer;
|
private AccountMailer accountMailer;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccountStatePublisher accountStatePublisher;
|
||||||
|
|
||||||
private AccountService service;
|
private AccountService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer,
|
service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer,
|
||||||
TransactionOperations.withoutTransaction(), Duration.ofDays(30));
|
accountStatePublisher, TransactionOperations.withoutTransaction(), Duration.ofDays(30));
|
||||||
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
|
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
|
||||||
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
|
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
|
||||||
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1);
|
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1);
|
||||||
@@ -141,6 +145,7 @@ class AccountServiceTest extends AbstractUnitTest {
|
|||||||
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right"));
|
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right"));
|
||||||
verify(accountRepository).flagDeleted(eq(USER_ID), any());
|
verify(accountRepository).flagDeleted(eq(USER_ID), any());
|
||||||
verify(accountMailer).accountDeleted(eq(USER_ID), any());
|
verify(accountMailer).accountDeleted(eq(USER_ID), any());
|
||||||
|
verify(accountStatePublisher).publish(USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ class CoreAccountServiceTest extends AbstractUnitTest {
|
|||||||
expect("replaceStates");
|
expect("replaceStates");
|
||||||
|
|
||||||
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
|
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
|
||||||
Instant.parse("2026-10-04T12:00:00Z"))));
|
Instant.parse("2026-10-04T12:00:00Z"), true, true, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))));
|
||||||
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null)));
|
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))));
|
||||||
|
|
||||||
core.verify();
|
core.verify();
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-2
@@ -5,24 +5,37 @@ import auth_service.modules.account.repo.AccountRepository;
|
|||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
||||||
|
|
||||||
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null);
|
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false, false, null);
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private AccountRepository accountRepository;
|
private AccountRepository accountRepository;
|
||||||
@@ -32,10 +45,13 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
private CoreAccountStatePublisher publisher;
|
private CoreAccountStatePublisher publisher;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TaskScheduler scheduler;
|
||||||
|
|
||||||
// Runs each task at once, so the test sees the call.
|
// Runs each task at once, so the test sees the call.
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run);
|
publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run, scheduler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -71,6 +87,58 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
|||||||
assertDoesNotThrow(() -> publisher.publish(7L));
|
assertDoesNotThrow(() -> publisher.publish(7L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coreDownOnce_RetriesAfter10sAndSendsTheCurrentState() {
|
||||||
|
CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true, false, null);
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE), Optional.of(newer));
|
||||||
|
doThrow(new RestClientException("Core down")).doNothing().when(coreAccountService).applyStates(any());
|
||||||
|
ArgumentCaptor<Runnable> retry = ArgumentCaptor.forClass(Runnable.class);
|
||||||
|
ArgumentCaptor<Instant> at = ArgumentCaptor.forClass(Instant.class);
|
||||||
|
|
||||||
|
Instant before = Instant.now();
|
||||||
|
publisher.publish(7L);
|
||||||
|
verify(scheduler).schedule(retry.capture(), at.capture());
|
||||||
|
assertThat(Duration.between(before, at.getValue())).isBetween(Duration.ofSeconds(9), Duration.ofSeconds(11));
|
||||||
|
retry.getValue().run();
|
||||||
|
|
||||||
|
verify(coreAccountService).applyStates(List.of(STATE));
|
||||||
|
verify(coreAccountService).applyStates(List.of(newer));
|
||||||
|
verify(accountRepository, times(2)).findState(7L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coreDownFourTimes_StopsAfterThreeRetries() {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
|
||||||
|
doThrow(new RestClientException("Core down")).when(coreAccountService).applyStates(any());
|
||||||
|
List<Runnable> tasks = new ArrayList<>();
|
||||||
|
List<Duration> delays = new ArrayList<>();
|
||||||
|
doAnswer(call -> {
|
||||||
|
tasks.add(call.getArgument(0));
|
||||||
|
delays.add(Duration.between(Instant.now(), call.<Instant>getArgument(1)));
|
||||||
|
return null;
|
||||||
|
}).when(scheduler).schedule(any(Runnable.class), any(Instant.class));
|
||||||
|
|
||||||
|
publisher.publish(7L);
|
||||||
|
for (int next = 0; next < 3; next++) {
|
||||||
|
tasks.get(next).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(tasks).hasSize(3);
|
||||||
|
verify(coreAccountService, times(4)).applyStates(any());
|
||||||
|
assertThat(delays.get(0)).isBetween(Duration.ofSeconds(9), Duration.ofSeconds(11));
|
||||||
|
assertThat(delays.get(1)).isBetween(Duration.ofSeconds(55), Duration.ofSeconds(61));
|
||||||
|
assertThat(delays.get(2)).isBetween(Duration.ofSeconds(295), Duration.ofSeconds(301));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_SchedulesNoRetry() {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
|
||||||
|
|
||||||
|
publisher.publish(7L);
|
||||||
|
|
||||||
|
verifyNoInteractions(scheduler);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownUser_SendsNothing() {
|
void unknownUser_SendsNothing() {
|
||||||
when(accountRepository.findState(7L)).thenReturn(Optional.empty());
|
when(accountRepository.findState(7L)).thenReturn(Optional.empty());
|
||||||
@@ -79,4 +147,29 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(coreAccountService, never()).applyStates(any());
|
verify(coreAccountService, never()).applyStates(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parallelSendsOfOneUser_NeverRunAtTheSameTime() throws Exception {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
|
||||||
|
AtomicInteger running = new AtomicInteger();
|
||||||
|
AtomicInteger mostRunning = new AtomicInteger();
|
||||||
|
doAnswer(call -> {
|
||||||
|
mostRunning.accumulateAndGet(running.incrementAndGet(), Math::max);
|
||||||
|
Thread.sleep(50);
|
||||||
|
running.decrementAndGet();
|
||||||
|
return null;
|
||||||
|
}).when(coreAccountService).applyStates(any());
|
||||||
|
ExecutorService threads = Executors.newFixedThreadPool(4);
|
||||||
|
CoreAccountStatePublisher parallel = new CoreAccountStatePublisher(accountRepository, coreAccountService,
|
||||||
|
threads, scheduler);
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
parallel.publish(7L);
|
||||||
|
}
|
||||||
|
threads.shutdown();
|
||||||
|
assertThat(threads.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||||
|
|
||||||
|
verify(coreAccountService, times(4)).applyStates(any());
|
||||||
|
assertThat(mostRunning.get()).isEqualTo(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package auth_service.modules.profile.service;
|
||||||
|
|
||||||
|
import auth_service.modules.profile.model.PublicProfile;
|
||||||
|
import auth_service.modules.profile.repo.PublicProfileRepository;
|
||||||
|
import auth_service.shared.security.ProfileTicketIssuer;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class PublicProfileServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PublicProfileRepository publicProfileRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ProfileTicketIssuer profileTicketIssuer;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private PublicProfileService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findPublicProfile_TicketCarriesTheSectionsAndTheStatGroups() {
|
||||||
|
PublicProfile profile = new PublicProfile(7L, Role.USER, "lobo", null,
|
||||||
|
new PublicProfile.Style(null, null, null, null, Map.of()),
|
||||||
|
List.of(new PublicProfile.Tile("SUMMARY", "M"), new PublicProfile.Tile("STATS_BACKLOG", "M"),
|
||||||
|
new PublicProfile.Tile("STATS_PLAYTIME", "S")));
|
||||||
|
when(publicProfileRepository.findPublicByDisplayName("lobo")).thenReturn(Optional.of(profile));
|
||||||
|
when(profileTicketIssuer.generateProfileTicket(anyLong(), any(), anyList(), anyList())).thenReturn("ticket");
|
||||||
|
|
||||||
|
service.findPublicProfile("lobo");
|
||||||
|
|
||||||
|
verify(profileTicketIssuer).generateProfileTicket(7L, Role.USER, List.of("BACKLOG", "PLAYTIME"),
|
||||||
|
List.of("SUMMARY", "STATS"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package auth_service.modules.stats.api;
|
||||||
|
|
||||||
|
import auth_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import auth_service.modules.stats.model.UserCountKey;
|
||||||
|
import auth_service.modules.stats.repo.StatsRepository;
|
||||||
|
import auth_service.shared.api.AuthCookies;
|
||||||
|
import auth_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
class UserListControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final String PASSWORD = "correct horse battery";
|
||||||
|
private static final String VIEWER = "viewer@owner.test";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StatsRepository statsRepository;
|
||||||
|
|
||||||
|
private final JsonMapper json = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
private void insertUser(String email, String name, String status, boolean enabled, boolean deleted,
|
||||||
|
boolean publicProfile, boolean optIn, Long steamId) {
|
||||||
|
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, display_name, steam_id, deleted_at) "
|
||||||
|
+ "VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? THEN now() ELSE NULL END) RETURNING id", Long.class,
|
||||||
|
email, passwordEncoder.encode(PASSWORD), enabled, status, name, steamId, deleted);
|
||||||
|
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile, show_in_owner_lists) "
|
||||||
|
+ "VALUES (?, 'en', 'dark', ?, ?)", id, publicProfile, optIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("DELETE FROM users WHERE email LIKE '%@owner.test'");
|
||||||
|
insertUser(VIEWER, "TheViewer", "ACTIVE", true, false, true, true, 76561198000000001L);
|
||||||
|
insertUser("listed@owner.test", "VisibleLobo", "ACTIVE", true, false, true, true, 76561198000000002L);
|
||||||
|
insertUser("a@owner.test", "HiddenOptOut", "ACTIVE", true, false, true, false, 76561198000000003L);
|
||||||
|
insertUser("b@owner.test", "HiddenNotPublic", "ACTIVE", true, false, false, true, 76561198000000004L);
|
||||||
|
insertUser("c@owner.test", "HiddenBlocked", "ACTIVE", false, false, true, true, 76561198000000005L);
|
||||||
|
insertUser("d@owner.test", "HiddenDeleted", "ACTIVE", true, true, true, true, 76561198000000006L);
|
||||||
|
insertUser("e@owner.test", "HiddenPending", "PENDING", true, false, true, true, 76561198000000007L);
|
||||||
|
insertUser("f@owner.test", null, "ACTIVE", true, false, true, true, 76561198000000008L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie login() throws Exception {
|
||||||
|
Cookie access = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"" + VIEWER + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||||
|
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
|
||||||
|
return new Cookie(AuthCookies.ACCESS_COOKIE, access.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String list(UserCountKey key) throws Exception {
|
||||||
|
return mockMvc.perform(get("/api/stats/users").param("key", key.name()).cookie(login()))
|
||||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutLogin_Answers401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/stats/users").param("key", "TOTAL_USERS")).andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privateUsersNeverAppearByNameOrIdAndEveryoneIsCounted() throws Exception {
|
||||||
|
for (UserCountKey key : UserCountKey.values()) {
|
||||||
|
String body = list(key);
|
||||||
|
JsonNode root = json.readTree(body);
|
||||||
|
|
||||||
|
assertThat(body).doesNotContain("Hidden", "TheViewer", "@owner.test");
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
root.get("listed").forEach(person -> {
|
||||||
|
assertThat(person.propertyNames()).containsExactly("displayName");
|
||||||
|
names.add(person.get("displayName").asString());
|
||||||
|
});
|
||||||
|
assertThat(names).containsExactly("VisibleLobo");
|
||||||
|
assertThat(root.get("includesYou").asBoolean()).isTrue();
|
||||||
|
long expected = key == UserCountKey.TOTAL_USERS ? statsRepository.countTotalUsers()
|
||||||
|
: statsRepository.countUsersWithSteamLinked();
|
||||||
|
assertThat(root.get("listedTotal").asLong() + root.get("privateCount").asLong() + 1).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -387,6 +387,33 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
.andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE"));
|
.andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCurrentUser_SendsShowInOwnerLists() throws Exception {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
|
||||||
|
);
|
||||||
|
when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com")
|
||||||
|
.settings(UserSettings.builder().language("en").theme("dark").showInOwnerLists(true).build()).build());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.settings.showInOwnerLists").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateOwnerLists_Answers204AndPassesTheFlag_MissingEnabledIs400() throws Exception {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
|
new UserPrincipal("1", null), null, Collections.emptyList()));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/users/settings/owner-lists").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"enabled\":true}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
mockMvc.perform(put("/api/users/settings/owner-lists").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
verify(userService).updateOwnerLists(1L, true);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception {
|
void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception {
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
|||||||
@@ -253,6 +253,25 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
|
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void showInOwnerLists_IsOffForANewUserAndRoundTrips() {
|
||||||
|
var userEntity = persisted(UserEntity.builder()
|
||||||
|
.email("owners@example.com")
|
||||||
|
.password("password")
|
||||||
|
.enabled(true)
|
||||||
|
.registrationDate(LocalDateTime.now())
|
||||||
|
.build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
assertFalse(findByEmail("owners@example.com").orElseThrow().getSettings().isShowInOwnerLists());
|
||||||
|
|
||||||
|
userRepository.update(userEntity.getId(), user -> user.getSettings().setShowInOwnerLists(true));
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertTrue(findByEmail("owners@example.com").orElseThrow().getSettings().isShowInOwnerLists());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateSettings_KeepsTheAppearance() {
|
void updateSettings_KeepsTheAppearance() {
|
||||||
var userEntity = persisted(UserEntity.builder()
|
var userEntity = persisted(UserEntity.builder()
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles());
|
assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfileTiles_PublishesTheAccountStateOnce() {
|
||||||
|
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
|
||||||
|
|
||||||
|
verify(accountStatePublisher, times(1)).publish(1L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateStatChart_PutsTheFormOfTheGroup() {
|
void updateStatChart_PutsTheFormOfTheGroup() {
|
||||||
userService.updateStatChart(1L, "PLATFORMS", "PIE");
|
userService.updateStatChart(1L, "PLATFORMS", "PIE");
|
||||||
@@ -153,7 +160,7 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
@Test
|
@Test
|
||||||
void updateUserSettings_UnchangedDisplayName_PublishesNothing() {
|
void updateUserSettings_UnchangedDisplayName_PublishesNothing() {
|
||||||
var userSettings = UserSettings.builder().language("en").build();
|
var userSettings = UserSettings.builder().language("en").build();
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(User.builder().id(1L).displayName("Lobo").build()));
|
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", false)));
|
||||||
|
|
||||||
userService.updateUserSettings(1L, "Lobo", userSettings);
|
userService.updateUserSettings(1L, "Lobo", userSettings);
|
||||||
|
|
||||||
@@ -161,6 +168,60 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
verifyNoInteractions(accountStatePublisher);
|
verifyNoInteractions(accountStatePublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static User stored(String displayName, boolean publicProfile) {
|
||||||
|
return User.builder().id(1L).displayName(displayName)
|
||||||
|
.settings(UserSettings.builder().publicProfile(publicProfile).build()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserSettings_PublicProfileChange_Publishes() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
|
||||||
|
|
||||||
|
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().language("en").publicProfile(false).build());
|
||||||
|
|
||||||
|
verify(accountStatePublisher).publish(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserSettings_NameRemoval_Publishes() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", false)));
|
||||||
|
|
||||||
|
userService.updateUserSettings(1L, null, UserSettings.builder().language("en").build());
|
||||||
|
|
||||||
|
verify(accountStatePublisher).publish(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserSettings_ThemeOnly_PublishesNothing() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
|
||||||
|
|
||||||
|
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().theme("light").publicProfile(true).build());
|
||||||
|
|
||||||
|
verifyNoInteractions(accountStatePublisher);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void updateUserSettings_KeepsShowInOwnerLists() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
|
||||||
|
|
||||||
|
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().publicProfile(true).build());
|
||||||
|
|
||||||
|
ArgumentCaptor<Consumer<User>> change = ArgumentCaptor.forClass(Consumer.class);
|
||||||
|
verify(userRepository).update(eq(1L), change.capture());
|
||||||
|
User user = User.builder().id(1L).settings(UserSettings.builder().showInOwnerLists(true).build()).build();
|
||||||
|
change.getValue().accept(user);
|
||||||
|
assertTrue(user.getSettings().isShowInOwnerLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateOwnerLists_StoresAndPublishes() {
|
||||||
|
userService.updateOwnerLists(1L, true);
|
||||||
|
|
||||||
|
assertTrue(appliedChanges(1).getFirst().getSettings().isShowInOwnerLists());
|
||||||
|
verify(accountStatePublisher).publish(1L);
|
||||||
|
}
|
||||||
|
|
||||||
private static final long STEAM_ID = 76561198000000001L;
|
private static final long STEAM_ID = 76561198000000001L;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ProfileSectionsTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_KeepsThePartsInOrder() {
|
||||||
|
assertThat(ProfileSections.of(List.of("SUMMARY", "PLAYING", "FINISHED"))).containsExactly("SUMMARY", "PLAYING", "FINISHED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_CollapsesEveryStatsTileToOneStatsSectionAtTheEnd() {
|
||||||
|
assertThat(ProfileSections.of(List.of("STATS_A", "SUMMARY", "STATS_B"))).containsExactly("SUMMARY", "STATS");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_NoTilesNoSections() {
|
||||||
|
assertThat(ProfileSections.of(List.of())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_DropsTileIdsThatCoreDoesNotKnow() {
|
||||||
|
assertThat(ProfileSections.of(List.of("SUMMARY", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "OWN_TILE", "STATS")))
|
||||||
|
.containsExactly("SUMMARY", "STATS");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,12 +37,12 @@
|
|||||||
"applyStates": {
|
"applyStates": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/internal/accounts/states",
|
"path": "/internal/accounts/states",
|
||||||
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]}
|
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true, "publicProfile": true, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
|
||||||
},
|
},
|
||||||
"replaceStates": {
|
"replaceStates": {
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/internal/accounts/states",
|
"path": "/internal/accounts/states",
|
||||||
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]}
|
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false, "publicProfile": false, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
|
||||||
},
|
},
|
||||||
"purge": {
|
"purge": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
|||||||
Reference in new issue
Block a user