package com.mydreamjob.auth.identity; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.lifecycle.Startables; import java.net.ServerSocket; import java.net.URI; import java.net.http.*; import java.nio.file.*; import java.sql.*; import java.time.Duration; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.locks.LockSupport; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.*; /** 手工验收入口:构建后用 exec:java 执行,启动最新 JAR 和三个隔离真实中间件。 */ public final class UnifiedIdentityJarAcceptanceProbe { private static final ObjectMapper JSON=new ObjectMapper(); private static final HttpClient HTTP=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); private static final String PASSWORD="IdentityProbe!2026#A"; private static int checks; private static Process app; private static int port; private static Connection db; public static void main(String[] args) throws Exception { Path evidence=Path.of("../docs/acceptance/unified-identity"); Files.createDirectories(evidence); try(var mysql=new MySQLContainer<>("mysql:8.4").withDatabaseName("identity_blackbox") .withUsername("identity_probe").withPassword("identity-probe-container-only"); var redis=new GenericContainer<>("redis:7.4-alpine").withExposedPorts(6379); var rabbit=new GenericContainer<>("rabbitmq:3.13-management-alpine").withExposedPorts(5672,15672) .withEnv("RABBITMQ_DEFAULT_USER","identity_probe") .withEnv("RABBITMQ_DEFAULT_PASS","identity-probe-container-only")) { Startables.deepStart(Stream.of(mysql,redis,rabbit)).join(); try(Connection connection=DriverManager.getConnection(mysql.getJdbcUrl(),mysql.getUsername(),mysql.getPassword())) { db=connection; try { start(mysql,redis,rabbit,false,evidence.resolve("jar-disabled.log")); JsonNode company=login("19900009601"); call("POST","/auth/identity/select",company.path("accessToken").asText(), "{\"identity\":\"COMPANY\",\"company\":{\"name\":\"统一身份黑盒测试企业\"}}",503); assertThat(one("select role from users where phone=?","19900009601")).isEqualTo("UNASSIGNED"); checks++; stop(); start(mysql,redis,rabbit,true,evidence.resolve("jar-enabled.log")); JsonNode runtime=call("GET","/v3/api-docs",null,null,200); JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; assertThat(runtime.path("paths").size()).isEqualTo(198); checks++; assertThat(operations(runtime)).hasSize(241); checks++; JsonNode pending=login("19900009602"); assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++; assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; assertThat(one("select count(*) from user_roles where user_id=?",pending.path("userId").asLong())).isEqualTo(0L); checks++; String pendingAccess=pending.path("accessToken").asText(); var before=snapshot(); for(String uri:List.of("/profile/me","/companies/me/onboarding","/notifications","/assessments/types","/company/candidates")) { JsonNode denied=call("GET",uri,pendingAccess,null,403); assertThat(denied.path("code").asInt()).isEqualTo(10401); checks++; } call("POST","/jobs",pendingAccess,"{}",403); assertThat(snapshot()).isEqualTo(before); checks++; call("GET","/jobs",pendingAccess,null,200); assertThat(call("GET","/auth/me",pendingAccess,null,200).path("data").path("identitySelectionRequired").asBoolean()).isTrue(); checks++; // 仅隔离验收库准备 BCrypt 夹具;不伪造真实短信服务商投递。 String hash=new BCryptPasswordEncoder().encode(PASSWORD); update("update users set password_hash=?, password_changed_at=now() where id=?",hash,pending.path("userId").asLong()); JsonNode passwordLogin=call("POST","/auth/password/login",null, JSON.writeValueAsString(Map.of("phone","19900009602","password",PASSWORD)),200).path("data"); assertThat(passwordLogin.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; JsonNode student=call("POST","/auth/identity/select",passwordLogin.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}",200).path("data"); assertThat(student.path("role").asText()).isEqualTo("STUDENT"); checks++; assertThat(JSON.readTree(Base64.getUrlDecoder().decode(student.path("accessToken").asText().split("\\.")[1])).path("role").asText()).isEqualTo("STUDENT"); checks++; assertThat(one("select role from user_roles where user_id=?",pending.path("userId").asLong())).isEqualTo("STUDENT"); checks++; for(JsonNode old:List.of(pending,passwordLogin)) { call("GET","/auth/me",old.path("accessToken").asText(),null,401); call("POST","/auth/refresh",null,JSON.writeValueAsString(Map.of("refreshToken",old.path("refreshToken").asText())),401); } call("GET","/auth/me",student.path("accessToken").asText(),null,200); JsonNode repeated=call("POST","/auth/identity/select",student.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}",200).path("data"); assertThat(repeated.has("accessToken")).isFalse(); checks++; call("POST","/auth/identity/select",student.path("accessToken").asText(), "{\"identity\":\"COMPANY\",\"company\":{\"name\":\"拒绝切换企业\"}}",409); JsonNode selectedCompany=call("POST","/auth/identity/select",company.path("accessToken").asText(), "{\"identity\":\"COMPANY\",\"company\":{\"name\":\"统一身份黑盒测试企业\"}}",200).path("data"); assertThat(selectedCompany.path("onboardingStatus").asText()).isEqualTo("UNVERIFIED"); checks++; assertThat(one("select role from user_roles where user_id=?",company.path("userId").asLong())).isEqualTo("COMPANY"); checks++; assertThat(one("select verified from companies where owner_user_id=?",company.path("userId").asLong())).isEqualTo(false); checks++; assertThat(call("GET","/companies/me/onboarding",selectedCompany.path("accessToken").asText(),null,200) .path("data").path("onboardingStatus").asText()).isEqualTo("UNVERIFIED"); checks++; update("update users set password_hash=?, password_changed_at=now() where id=?",hash,company.path("userId").asLong()); JsonNode companyPasswordLogin=call("POST","/auth/password/login",null, JSON.writeValueAsString(Map.of("phone","19900009601","password",PASSWORD)),200).path("data"); assertThat(companyPasswordLogin.path("role").asText()).isEqualTo("COMPANY"); checks++; assertThat(companyPasswordLogin.path("identitySelectionRequired").asBoolean()).isFalse(); checks++; assertThat(JSON.readTree(Base64.getUrlDecoder().decode(companyPasswordLogin.path("accessToken").asText().split("\\.")[1])).path("role").asText()).isEqualTo("COMPANY"); checks++; call("GET","/company/candidates",selectedCompany.path("accessToken").asText(),null,403); JsonNode race=login("19900009603"); concurrentSelect(race); assertThat(one("select count(*) from user_roles where user_id=?",race.path("userId").asLong())).isEqualTo(1L); checks++; assertThat(one("select count(*) from auth_tokens where user_id=? and revoked=0 and consumed_at is null",race.path("userId").asLong())).isEqualTo(1L); checks++; String rabbitUrl="http://"+rabbit.getHost()+":"+rabbit.getMappedPort(15672)+"/api/queues"; String basic="Basic "+Base64.getEncoder().encodeToString("identity_probe:identity-probe-container-only".getBytes(java.nio.charset.StandardCharsets.UTF_8)); JsonNode queues=JSON.readTree(HTTP.send(HttpRequest.newBuilder(URI.create(rabbitUrl)).header("Authorization",basic).GET().build(),HttpResponse.BodyHandlers.ofString()).body()); assertThat(queues.size()).isEqualTo(4); checks++; for(JsonNode q:queues) { assertThat(q.has("messages")).isTrue(); assertThat(q.path("messages").asLong()).isZero(); assertThat(q.path("messages_ready").asLong()).isZero(); assertThat(q.path("messages_unacknowledged").asLong()).isZero(); checks++; } cleanup(); assertThat(one("select count(*) from users where phone in (?,?,?)","19900009601","19900009602","19900009603")).isEqualTo(0L); checks++; stop(); for(String name:List.of("jar-disabled.log","jar-enabled.log")) { String log=Files.readString(evidence.resolve(name)); assertThat(log).doesNotContain(PASSWORD,hash,pendingAccess,student.path("accessToken").asText(), "19900009601","19900009602","19900009603"); checks++; } Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of( "checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3", "paths",198,"operations",241,"smsDelivery","mock sender; not real provider", "fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle"))); System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 198 paths/241 operations; fixture residual 0"); } finally {stop();} } } } private static void start(MySQLContainer mysql,GenericContainer redis,GenericContainer rabbit,boolean enabled,Path log) throws Exception { port=freePort(); int management=freePort(); var builder=new ProcessBuilder(Path.of(System.getProperty("java.home"),"bin","java.exe").toString(), "-Dfile.encoding=UTF-8","-jar","target/mydreamjob-server-0.0.1-SNAPSHOT.jar","--server.port="+port, "--server.address=127.0.0.1","--management.server.port="+management); var env=builder.environment(); env.put("SPRING_PROFILES_ACTIVE",""); env.put("DB_URL",mysql.getJdbcUrl()); env.put("DB_USERNAME",mysql.getUsername()); env.put("DB_PASSWORD",mysql.getPassword()); env.put("REDIS_HOST",redis.getHost()); env.put("REDIS_PORT",redis.getMappedPort(6379).toString()); env.put("REDIS_PASSWORD",""); env.put("RABBITMQ_HOST",rabbit.getHost()); env.put("RABBITMQ_PORT",rabbit.getMappedPort(5672).toString()); env.put("RABBITMQ_USERNAME","identity_probe"); env.put("RABBITMQ_PASSWORD","identity-probe-container-only"); env.put("RABBITMQ_VHOST","/"); env.put("JWT_SECRET","identity-probe-legacy-key-test-only-0001"); env.put("JWT_ACCESS_SECRET","identity-probe-access-key-test-only-0001"); env.put("JWT_REFRESH_SECRET","identity-probe-refresh-key-test-only-0001"); env.put("JWT_TOKEN_HASH_SECRET","identity-probe-hash-key-test-only-0001"); env.put("DELETION_PHONE_HASH_SECRET","identity-probe-deletion-key-test-only-0001"); env.put("PASSWORD_LOGIN_ENABLED","true"); env.put("PASSWORD_RATE_KEY_SECRET","identity-probe-password-key-test-only-0001"); env.put("COMPANY_REGISTRATION_ENABLED",Boolean.toString(enabled)); env.put("ADMIN_BOOTSTRAP_ENABLED","false"); env.put("SMS_MOCK_ENABLED","true"); env.put("OFFER_SCHEDULER_ENABLED","false"); env.put("MATCH_NOTIFICATION_SCHEDULER_ENABLED","false"); env.put("SCORING_REFRESH_ENABLED","false"); env.put("SCORING_REFRESH_COMPENSATION_ENABLED","false"); env.put("RESUME_STORAGE_DIR","./target/identity-probe-storage/resumes"); env.put("RESUME_EXPORT_DIR","./target/identity-probe-storage/resume-exports"); env.put("DATA_EXPORT_DIR","./target/identity-probe-storage/data-exports"); app=builder.redirectErrorStream(true).redirectOutput(log.toFile()).start(); long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(90); while(System.nanoTime() send(String method,String uri,String token,String body) throws Exception { var b=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1"+uri)).timeout(Duration.ofSeconds(15)); if(token!=null)b.header("Authorization","Bearer "+token); if(body!=null)b.header("Content-Type","application/json"); return HTTP.send(b.method(method,body==null?HttpRequest.BodyPublishers.noBody():HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); } private static JsonNode call(String method,String uri,String token,String body,int expected) throws Exception { var response=send(method,uri,token,body); assertThat(response.statusCode()).as("%s %s",method,uri).isEqualTo(expected); checks++; JsonNode result=JSON.readTree(response.body()); if(expected>=400) assertThat(response.body()).doesNotContain("Exception","jdbc:","passwordHash","storagePath","at com.mydreamjob"); return result; } private static JsonNode login(String phone) throws Exception { assertThat(one("select count(*) from users where phone=?",phone)).isEqualTo(0L); call("POST","/auth/sms/send?phone="+phone+"&purpose=login",null,null,200); String code=(String) one("select code from sms_verification_codes where phone=? and purpose='login' order by id desc limit 1",phone); return call("POST","/auth/login",null,JSON.writeValueAsString(Map.of("phone",phone,"code",code,"purpose","login")),200).path("data"); } private static Object one(String sql,Object...args) throws Exception { try(var p=db.prepareStatement(sql)){for(int i=0;i operations(JsonNode doc) { Set out=new TreeSet<>();doc.path("paths").fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> out.add(m+" "+p.getKey())));return out; } private static Map> snapshot() throws Exception { Map> result=new TreeMap<>(); for(String table:List.of("users","user_roles","companies","auth_tokens","notifications","messages","audit_logs", "jobs","applications","offers","interviews","company_verifications","sms_verification_codes")) { List rows=new ArrayList<>(); try(var s=db.createStatement();var r=s.executeQuery("select * from "+table)) { while(r.next()){List columns=new ArrayList<>();for(int c=1;c<=r.getMetaData().getColumnCount();c++)columns.add(Objects.toString(r.getObject(c),""));rows.add(JSON.writeValueAsString(columns));} } Collections.sort(rows);result.put(table,rows); }return result; } private static void concurrentSelect(JsonNode pending) throws Exception { var executor=Executors.newFixedThreadPool(2);var ready=new CountDownLatch(2);var start=new CountDownLatch(1); try { List> requests=new ArrayList<>(); for(int i=0;i<2;i++) requests.add(executor.submit(() -> {ready.countDown();assertThat(start.await(10,TimeUnit.SECONDS)).isTrue(); return send("POST","/auth/identity/select",pending.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}").statusCode();})); assertThat(ready.await(10,TimeUnit.SECONDS)).isTrue();start.countDown(); assertThat(List.of(requests.get(0).get(30,TimeUnit.SECONDS),requests.get(1).get(30,TimeUnit.SECONDS))).containsExactlyInAnyOrder(200,401);checks++; } finally {start.countDown();executor.shutdownNow();} } private static void cleanup() throws Exception { try(var p=db.prepareStatement("select id,phone from users where phone in (?,?,?)")) { for(int i=1;i<=3;i++)p.setString(i,"1990000960"+i); List ids=new ArrayList<>();try(var r=p.executeQuery()){while(r.next())ids.add(r.getLong(1));} for(Long id:ids){update("delete from auth_tokens where user_id=?",id);update("delete from companies where owner_user_id=?",id);update("delete from user_roles where user_id=?",id);update("delete from users where id=?",id);} for(int i=1;i<=3;i++)update("delete from sms_verification_codes where phone=?","1990000960"+i); } } private UnifiedIdentityJarAcceptanceProbe() { } }