Compare commits

..

24 Commits

Author SHA1 Message Date
simple321vip 4cd96ade89 update core up to 2.2.1-SNAPSHOT
continuous-integration/drone/push Build is passing
2026-07-22 23:26:24 +08:00
simple321vip cb852a2bbf Merge branch 'master' of https://gitea.violin-work.online/violin/violin-iam
continuous-integration/drone/push Build is passing
2026-07-22 22:10:32 +08:00
simple321vip 40f057030c ver 2.1 -> 2.2 2026-07-22 22:01:51 +08:00
simple321vip 35f572cb4a Update src/main/resources/application.yml
continuous-integration/drone/push Build is passing
2026-07-14 02:52:13 +00:00
simple321vip a4a1f8b577 Update src/main/resources/application.yml
continuous-integration/drone/push Build is passing
2026-07-14 02:51:45 +00:00
simple321vip 94f86d308d Update .drone.yml
continuous-integration/drone/push Build is passing
2026-07-13 00:56:24 +00:00
simple321vip 3cffd4569a Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-11 09:27:07 +00:00
simple321vip d237887d83 Update pom.xml
continuous-integration/drone/push Build was killed
2026-07-10 04:02:01 +00:00
simple321vip 7f5d0cd511 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:50:27 +00:00
simple321vip 1b1af41047 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:49:23 +00:00
simple321vip 2a9670b666 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:42:37 +00:00
simple321vip 995f0e960e Update .drone.yml
continuous-integration/drone/push Build was killed
2026-07-10 03:39:49 +00:00
simple321vip 962a469c52 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:37:48 +00:00
simple321vip 9a8d39bc23 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:35:59 +00:00
simple321vip ab4c0d8f33 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:31:53 +00:00
simple321vip 805762c110 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:29:08 +00:00
simple321vip e83d1d4fff Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:27:32 +00:00
simple321vip e6474e1648 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:24:22 +00:00
simple321vip 35802353b7 Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:21:54 +00:00
simple321vip 0b09d8ff0a Update .drone.yml
continuous-integration/drone/push Build is failing
2026-07-10 03:20:42 +00:00
simple321vip 1deae876c3 feat: update .drone.yaml
continuous-integration/drone/push Build is failing
2026-07-10 11:10:54 +08:00
simple321vip 762fefacac feat: add k8s example
continuous-integration/drone/push Build is failing
2026-07-10 11:06:06 +08:00
simple321vip 16af9c5f4d feat: add Dockerfile2 2026-07-10 10:59:01 +08:00
simple321vip 844f578601 feat: add Dockerfile 2026-07-10 10:58:27 +08:00
12 changed files with 341 additions and 83 deletions
+5
View File
@@ -6,6 +6,11 @@ name: build-and-deploy-dev
# clone: # clone:
# disable: true # disable: true
host_aliases:
- ip: "192.168.3.49" # ← 替换为 gitea.violin-work.online 的实际 IP
hostnames:
- gitea.violin-work.online
volumes: volumes:
- name: maven-cache - name: maven-cache
claim: claim:
-59
View File
@@ -1,59 +0,0 @@
name: iam-ci
on:
push:
branches: [main]
pull_request:
paths: ['**']
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: violin
POSTGRES_PASSWORD: violin
POSTGRES_DB: violin
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U violin"
--health-interval=5s
--health-timeout=3s
--health-retries=5
env:
VIOLIN_IAM_SERVICE_TOKEN_SECRET: "0011223344556677889900aabbccddeeff"
VIOLIN_JWT_PRIVATE_KEY_PATH: ""
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/violin
SPRING_DATASOURCE_USERNAME: violin
SPRING_DATASOURCE_PASSWORD: violin
VIOLIN_JWT_JWKS_URL: http://localhost:8080/violin-iam/.well-known/jwks.json
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: corretto
java-version: '17'
- name: Cache Maven
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- name: Install violin-parent + violin-common + violin-core
run: |
for m in violin-parent violin-common violin-core; do
echo "--- installing $m ---"
mvn -B -f "$m/pom.xml" -o clean install -DskipTests || mvn -B -f "$m/pom.xml" clean install -DskipTests
done
- name: Build + test violin-iam
run: mvn -B -f violin-iam/pom.xml verify
+1 -3
View File
@@ -67,8 +67,6 @@ context. Concrete gotchas to fix:
## Design notes (do not skip) ## Design notes (do not skip)
## Design notes (do not skip)
### JWT revocation propagation — what we tried, what we picked, and why ### JWT revocation propagation — what we tried, what we picked, and why
The naive question "should we drop `customerId` from the JWT claim so The naive question "should we drop `customerId` from the JWT claim so
@@ -76,6 +74,7 @@ downstream must call IAM every request?" was the wrong frame. Below is
the actual decision tree we walked. the actual decision tree we walked.
#### Problem #### Problem
A user's `t_user.customer_id` is migrated from tenant A to tenant B. The A user's `t_user.customer_id` is migrated from tenant A to tenant B. The
JWT issued at login still carries `customerId=A`. Without propagation JWT issued at login still carries `customerId=A`. Without propagation
the user keeps operating under A's RBAC scope (and SAR cache) for the the user keeps operating under A's RBAC scope (and SAR cache) for the
@@ -146,4 +145,3 @@ push `(sub, oldCustomerId)` into it. Have `PermissionAspect` consult the cache
as a secondary check (after verified gate). Reduce default `expirationMs` as a secondary check (after verified gate). Reduce default `expirationMs`
in `ViolinJwtProperties` from `86_400_000` (24 h) to `3_600_000` (1 h). in `ViolinJwtProperties` from `86_400_000` (24 h) to `3_600_000` (1 h).
These three together close the revocation window to the bound of TTL. These three together close the revocation window to the bound of TTL.
+14
View File
@@ -0,0 +1,14 @@
# ============================================================
# Dockerfile — violin-authfor drone in kubernetes
# ============================================================
FROM gitea.violin-work.online/registry/eclipse-temurin:22-jre
# set timezone
ENV TZ=Asia/Shanghai LANG=C.UTF-8
WORKDIR /app
COPY target/violin-iam-*.jar /app/app.jar
USER 1000
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
+28
View File
@@ -0,0 +1,28 @@
# ============================================================
# ConfigMap 模板 — violin-auth dev 环境(K8s 测试)
# ============================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: violin-auth-config
namespace: violin-dev
labels:
app.kubernetes.io/name: violin-auth
app.kubernetes.io/component: config
environment: dev
data:
SPRING_PROFILES_ACTIVE: "dev"
DB_HOST: "violin-postgres-dev"
DB_PORT: "5432"
DB_NAME: "violin_dev"
DB_USER: "postgres"
AUTHENTIK_ISSUER: "https://auth.violin-work.online"
AUTHENTIK_REDIRECT_URI: "https://dev.violin-home.cn/auth/callback"
AUTHENTIK_SCOPE: "openid profile email"
VIOLIN_CORS_ALLOWED_ORIGINS: "https://dev.violin-home.cn,https://*.violin-work.online,http://localhost:5173"
VIOLIN_SNOWFLAKE_WORKER_ID: "1"
+36
View File
@@ -0,0 +1,36 @@
# ============================================================
# ConfigMap 模板 — violin-auth 生产环境(非敏感配置)
# ============================================================
# 使用方式:
# kubectl apply -f example/configmap-template.yaml
#
# 说明:
# - 非敏感参数放这里,可 commit 进 Git
# - 敏感凭据(密码 / secret)走 secret-template.yaml,绝不 commit 真实值
# - KubeSphere UI 可直接导入此文件
# ============================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: violin-auth-config
namespace: violin-prod
labels:
app.kubernetes.io/name: violin-auth
app.kubernetes.io/component: config
environment: production
data:
SPRING_PROFILES_ACTIVE: "prod"
DB_HOST: "violin-postgres"
DB_PORT: "5432"
DB_NAME: "violin"
DB_USER: "postgres"
AUTHENTIK_ISSUER: "https://auth.violin-work.online"
AUTHENTIK_REDIRECT_URI: "https://www.violin-home.cn/auth/callback"
AUTHENTIK_SCOPE: "openid profile email"
VIOLIN_CORS_ALLOWED_ORIGINS: "https://www.violin-home.cn,https://*.violin-work.online"
VIOLIN_SNOWFLAKE_WORKER_ID: "1"
+27
View File
@@ -0,0 +1,27 @@
# ============================================================
# Secret 模板 — violin-auth dev 环境(K8s 测试)
# ============================================================
# ⚠️ 真实凭据通过 KubeSphere UI 或 CI/CD 注入,不要 commit 真实值
# dev 环境凭据可以和 prod 区分开(推荐),也可以共用(不推荐)
# ============================================================
apiVersion: v1
kind: Secret
metadata:
name: violin-auth-secret
namespace: violin-dev
labels:
app.kubernetes.io/name: violin-auth
app.kubernetes.io/component: secret
environment: dev
type: Opaque
stringData:
# ---- 数据库 ----
DB_PASSWORD: "CHANGE_ME_DEV_POSTGRES_PASSWORD"
# ---- JWT ----
VIOLIN_JWT_SECRET: "CHANGE_ME_DEV_JWT_SECRET_MIN_32_BYTES"
# ---- Authentik OIDCdev application----
AUTHENTIK_CLIENT_ID: "CHANGE_ME_DEV_AUTHENTIK_CLIENT_ID"
AUTHENTIK_CLIENT_SECRET: "CHANGE_ME_DEV_AUTHENTIK_CLIENT_SECRET"
+30
View File
@@ -0,0 +1,30 @@
# ============================================================
# Secret 模板 — violin-auth 生产环境(敏感凭据)
# ============================================================
# ⚠️ 重要:
# - 本文件是 TEMPLATE,真实凭据值必须通过以下方式注入:
# 1. KubeSphere 控制台 → 配置存储 → Secret → 创建(界面填写真实值)
# 2. 或 CI/CD 流水线中通过 Drone Secret / sealed-secrets 动态注入
# - 绝不要把 stringData 里的占位符替换成真实值后 commit
# ============================================================
apiVersion: v1
kind: Secret
metadata:
name: violin-auth-secret
namespace: violin-prod
labels:
app.kubernetes.io/name: violin-auth
app.kubernetes.io/component: secret
environment: production
type: Opaque
stringData:
# ---- 数据库 ----
DB_PASSWORD: "CHANGE_ME_POSTGRES_PASSWORD"
# ---- JWT ----
VIOLIN_JWT_SECRET: "CHANGE_ME_JWT_SECRET_MIN_32_BYTES"
# ---- Authentik OIDC ----
AUTHENTIK_CLIENT_ID: "CHANGE_ME_AUTHENTIK_CLIENT_ID"
AUTHENTIK_CLIENT_SECRET: "CHANGE_ME_AUTHENTIK_CLIENT_SECRET"
+3 -3
View File
@@ -7,11 +7,11 @@
<parent> <parent>
<groupId>cn.violin</groupId> <groupId>cn.violin</groupId>
<artifactId>violin-parent</artifactId> <artifactId>violin-parent</artifactId>
<version>2.1</version> <version>2.2</version>
</parent> </parent>
<artifactId>violin-iam</artifactId> <artifactId>violin-iam</artifactId>
<version>2.1</version> <version>2.2</version>
<name>violin-iam</name> <name>violin-iam</name>
<description>violin IAM service</description> <description>violin IAM service</description>
@@ -19,7 +19,7 @@
<dependency> <dependency>
<groupId>cn.violin</groupId> <groupId>cn.violin</groupId>
<artifactId>violin-core</artifactId> <artifactId>violin-core</artifactId>
<version>2.1</version> <version>2.2.1-SNAPSHOT</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -1,20 +1,27 @@
package cn.violin.iam.controller; package cn.violin.iam.controller;
import cn.violin.core.entity.UserEntity;
import cn.violin.core.iam.CheckRequest; import cn.violin.core.iam.CheckRequest;
import cn.violin.core.iam.CheckResult; import cn.violin.core.iam.CheckResult;
import cn.violin.core.iam.CustomerBindingResponse;
import cn.violin.iam.dto.SubjectAccessReviewRequest; import cn.violin.iam.dto.SubjectAccessReviewRequest;
import cn.violin.iam.dto.SubjectAccessReviewResponse; import cn.violin.iam.dto.SubjectAccessReviewResponse;
import cn.violin.iam.mapper.UserMapper;
import cn.violin.iam.security.ServiceAllowlist; import cn.violin.iam.security.ServiceAllowlist;
import cn.violin.iam.security.ServiceAuthValidator; import cn.violin.iam.security.ServiceAuthValidator;
import cn.violin.iam.service.SubjectAccessReviewService; import cn.violin.iam.service.SubjectAccessReviewService;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/v1/internal") @RequestMapping("/api/v1/internal")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -24,6 +31,7 @@ public class InternalController {
private final SubjectAccessReviewService sarService; private final SubjectAccessReviewService sarService;
private final ServiceAuthValidator serviceAuth; private final ServiceAuthValidator serviceAuth;
private final ServiceAllowlist allowlist; private final ServiceAllowlist allowlist;
private final UserMapper userMapper;
@PostMapping("/check-permission") @PostMapping("/check-permission")
public CheckResult checkPermission(HttpServletRequest httpReq, public CheckResult checkPermission(HttpServletRequest httpReq,
@@ -49,4 +57,44 @@ public class InternalController {
.reason(resp.getReason()) .reason(resp.getReason())
.build(); .build();
} }
/**
* Resolve the authoritative {@code customerId} for a given Authentik {@code sub}.
*
* <p>Service-to-service channel only: caller must present a valid
* {@code X-Service-*} HMAC; {@link ServiceAuthValidator#verifyAndReturnService}
* ensures non-empty service id and {@link ServiceAllowlist} is consulted
* downstream services can decide whether {@code sub} belongs to a customer
* they're allowed to read.</p>
*
* <p>The returned binding carries {@code active=true} when we observed a
* live {@code t_user} row with {@code is_deleted=false} AND a non-null
* {@code customer_id}; otherwise {@code active=false} so callers can
* distinguish "no binding" from "binding to unknown tenant".</p>
*/
@GetMapping("/users/{sub}/customer")
public CustomerBindingResponse resolveCustomer(HttpServletRequest httpReq,
@PathVariable String sub) {
// Reject unauthenticated callers.
serviceAuth.verifyAndReturnService(httpReq);
Optional<UserEntity> opt = userMapper.selectByUserId(sub);
if (opt.isEmpty()) {
return CustomerBindingResponse.builder()
.sub(sub)
.customerId(null)
.active(false)
.build();
}
UserEntity user = opt.get();
String customerId = user.getCustomerId();
boolean active = customerId != null
&& !customerId.isBlank()
&& Boolean.FALSE.equals(user.getIsDeleted());
return CustomerBindingResponse.builder()
.sub(sub)
.customerId(active ? customerId : null)
.active(active)
.build();
}
} }
+18 -18
View File
@@ -31,24 +31,24 @@ violin:
auth: auth:
exclude-paths: /error exclude-paths: /error
anonymous-message-code: AUTHORIZATION_MISSING anonymous-message-code: AUTHORIZATION_MISSING
iam: iam:
service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller} service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller}
url: ${VIOLIN_IAM_URL:http://localhost:8080} url: ${VIOLIN_IAM_URL:http://localhost:8080}
context-path: ${VIOLIN_IAM_CONTEXT_PATH:/violin-iam} context-path: ${VIOLIN_IAM_CONTEXT_PATH:/violin-iam}
service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:} service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:}
service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:} service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:}
profile: ${SPRING_PROFILES_ACTIVE:local} profile: ${SPRING_PROFILES_ACTIVE:local}
oidc: oidc:
client: client:
issuer: ${IAM_OIDC_CLIENT_ISSUER:${AUTHENTIK_ISSUER:}} issuer: ${IAM_OIDC_CLIENT_ISSUER:${AUTHENTIK_ISSUER:}}
client-id: ${IAM_OIDC_CLIENT_CLIENT_ID:${AUTHENTIK_CLIENT_ID:}} client-id: ${IAM_OIDC_CLIENT_CLIENT_ID:${AUTHENTIK_CLIENT_ID:}}
client-secret: ${IAM_OIDC_CLIENT_CLIENT_SECRET:${AUTHENTIK_CLIENT_SECRET:}} client-secret: ${IAM_OIDC_CLIENT_CLIENT_SECRET:${AUTHENTIK_CLIENT_SECRET:}}
redirect-uri: ${IAM_OIDC_CLIENT_REDIRECT_URI:${AUTHENTIK_REDIRECT_URI:}} redirect-uri: ${IAM_OIDC_CLIENT_REDIRECT_URI:${AUTHENTIK_REDIRECT_URI:}}
scope: ${IAM_OIDC_CLIENT_SCOPE:${AUTHENTIK_SCOPE:openid profile email}} scope: ${IAM_OIDC_CLIENT_SCOPE:${AUTHENTIK_SCOPE:openid profile email}}
authentik: authentik:
token-endpoint: ${IAM_OIDC_AUTHENTIK_TOKEN_ENDPOINT:} token-endpoint: ${IAM_OIDC_AUTHENTIK_TOKEN_ENDPOINT:}
userinfo-endpoint: ${IAM_OIDC_AUTHENTIK_USERINFO_ENDPOINT:} userinfo-endpoint: ${IAM_OIDC_AUTHENTIK_USERINFO_ENDPOINT:}
authorize-endpoint: ${IAM_OIDC_AUTHENTIK_AUTHORIZE_ENDPOINT:} authorize-endpoint: ${IAM_OIDC_AUTHENTIK_AUTHORIZE_ENDPOINT:}
management: management:
endpoints: endpoints:
@@ -0,0 +1,131 @@
package cn.violin.iam.controller;
import cn.violin.core.entity.UserEntity;
import cn.violin.core.iam.CustomerBindingResponse;
import cn.violin.iam.dto.SubjectAccessReviewResponse;
import cn.violin.iam.mapper.UserMapper;
import cn.violin.iam.security.ServiceAllowlist;
import cn.violin.iam.security.ServiceAuthValidator;
import cn.violin.iam.service.SubjectAccessReviewService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Pure-unit tests for {@link InternalController}'s binding resolution endpoint
* ({@code GET /api/v1/internal/users/{sub}/customer}).
*
* <p>We bypass Spring MVC and the HMAC validator by mocking the request-time
* dependencies ({@link ServiceAuthValidator} and {@link ServiceAllowlist}).
* The {@link UserMapper} is also a mock, so the test class never touches a database.</p>
*/
class InternalControllerBindingTest {
private UserMapper userMapper;
private SubjectAccessReviewService sarService;
private ServiceAuthValidator serviceAuth;
private ServiceAllowlist allowlist;
private InternalController controller;
@BeforeEach
void setUp() {
userMapper = mock(UserMapper.class);
sarService = mock(SubjectAccessReviewService.class);
serviceAuth = mock(ServiceAuthValidator.class);
allowlist = mock(ServiceAllowlist.class);
controller = new InternalController(sarService, serviceAuth, allowlist, userMapper);
when(sarService.check(any())).thenReturn(
SubjectAccessReviewResponse.builder().allowed(true).reason("ok").build());
when(serviceAuth.verifyAndReturnService(any(HttpServletRequest.class)))
.thenReturn("violin-wiki");
when(allowlist.resolveTenant(eq("violin-wiki"), any())).thenAnswer(inv -> inv.getArgument(1));
}
@Test
void activeBindingReturnsCustomerAndFlag() {
UserEntity user = new UserEntity();
user.setUserId("alice");
user.setCustomerId("tenant-A");
user.setIsDeleted(false);
when(userMapper.selectByUserId("alice")).thenReturn(Optional.of(user));
HttpServletRequest req = mock(HttpServletRequest.class);
CustomerBindingResponse resp = controller.resolveCustomer(req, "alice");
assertTrue(resp.isActive());
assertEquals("alice", resp.getSub());
assertEquals("tenant-A", resp.getCustomerId());
verify(serviceAuth).verifyAndReturnService(req);
}
@Test
void inactiveWhenCustomerIdNull() {
UserEntity user = new UserEntity();
user.setUserId("bob");
user.setCustomerId(null);
user.setIsDeleted(false);
when(userMapper.selectByUserId("bob")).thenReturn(Optional.of(user));
CustomerBindingResponse resp = controller.resolveCustomer(mock(HttpServletRequest.class), "bob");
assertFalse(resp.isActive());
assertEquals("bob", resp.getSub());
assertNull(resp.getCustomerId());
}
@Test
void inactiveWhenUserSoftDeleted() {
UserEntity user = new UserEntity();
user.setUserId("carol");
user.setCustomerId("tenant-X");
user.setIsDeleted(true);
when(userMapper.selectByUserId("carol")).thenReturn(Optional.of(user));
CustomerBindingResponse resp = controller.resolveCustomer(mock(HttpServletRequest.class), "carol");
assertFalse(resp.isActive());
assertNotNull(resp);
assertNull(resp.getCustomerId());
}
@Test
void inactiveWhenUserNotFound() {
when(userMapper.selectByUserId("ghost")).thenReturn(Optional.empty());
CustomerBindingResponse resp = controller.resolveCustomer(mock(HttpServletRequest.class), "ghost");
assertFalse(resp.isActive());
assertEquals("ghost", resp.getSub());
assertNull(resp.getCustomerId());
}
@Test
void verifyAndReturnServiceCalledEvenForUnknownSub() {
// The HMAC gate runs *before* the lookup; cache poisoning risk is mitigated
// by ensuring every request still pays the validator cost.
when(userMapper.selectByUserId("anybody")).thenReturn(Optional.empty());
HttpServletRequest req = mock(HttpServletRequest.class);
controller.resolveCustomer(req, "anybody");
ArgumentCaptor<HttpServletRequest> captor = ArgumentCaptor.forClass(HttpServletRequest.class);
verify(serviceAuth).verifyAndReturnService(captor.capture());
assertEquals(req, captor.getValue());
}
}