forked from baskbuddy/zhangshun
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
236 lines
19 KiB
236 lines
19 KiB
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()<deadline) {
|
|
assertThat(app.isAlive()).as("JAR must remain alive; inspect redacted local evidence").isTrue();
|
|
try {
|
|
if(HTTP.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/health"))
|
|
.timeout(Duration.ofSeconds(2)).GET().build(),HttpResponse.BodyHandlers.discarding()).statusCode()==200) return;
|
|
} catch(java.io.IOException e) { /* 尚未监听,不视为验收成功。 */ }
|
|
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
|
|
}
|
|
throw new AssertionError("JAR readiness timeout");
|
|
}
|
|
private static int freePort() throws Exception {try(ServerSocket socket=new ServerSocket(0)){return socket.getLocalPort();}}
|
|
private static void stop() throws Exception {
|
|
if(app!=null) {app.destroy(); if(!app.waitFor(15,TimeUnit.SECONDS)){app.destroyForcibly();assertThat(app.waitFor(10,TimeUnit.SECONDS)).isTrue();} app=null;}
|
|
}
|
|
private static HttpResponse<String> 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<args.length;i++)p.setObject(i+1,args[i]);
|
|
try(var r=p.executeQuery()){assertThat(r.next()).isTrue();return r.getObject(1);}}
|
|
}
|
|
private static void update(String sql,Object...args) throws Exception {
|
|
try(var p=db.prepareStatement(sql)){for(int i=0;i<args.length;i++)p.setObject(i+1,args[i]);p.executeUpdate();}
|
|
}
|
|
private static Set<String> operations(JsonNode doc) {
|
|
Set<String> out=new TreeSet<>();doc.path("paths").fields().forEachRemaining(p ->
|
|
p.getValue().fieldNames().forEachRemaining(m -> out.add(m+" "+p.getKey())));return out;
|
|
}
|
|
private static Map<String,List<String>> snapshot() throws Exception {
|
|
Map<String,List<String>> 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<String> rows=new ArrayList<>();
|
|
try(var s=db.createStatement();var r=s.executeQuery("select * from "+table)) {
|
|
while(r.next()){List<String> columns=new ArrayList<>();for(int c=1;c<=r.getMetaData().getColumnCount();c++)columns.add(Objects.toString(r.getObject(c),"<null>"));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<Future<Integer>> 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<Long> 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() { }
|
|
}
|
|
|