mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 18:10:30 +08:00
feat(platform): I1 bootstrap, I2 M1 APIs, OpenAPI SSOT, and CI guards
Deliver dual Spring Boot services (platform API + webhook ingress), JWT auth, Flyway with isolated history tables, customer/project/dictionary endpoints, OpenAPI snapshot under contracts/, RUNBOOK, and CI that runs on services/web/contracts paths plus enforcer + dependency tree ban on craftlabs-auth-bitanswer. Made-with: Cursor
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
||||
package cn.craftlabs.platform.api.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void loginSuccess() throws Exception {
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginFail() throws Exception {
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"x\",\"password\":\"y\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package cn.craftlabs.platform.api.contract;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 将 springdoc 产出的 OpenAPI 固化为仓库内 {@code contracts/openapi/delivery-platform-api.json}。
|
||||
*
|
||||
* <p>更新快照:{@code UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest}(在 {@code
|
||||
* services/delivery-platform-api} 模块目录下执行)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class OpenApiContractSnapshotTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void openApiMatchesCommittedContract() throws Exception {
|
||||
MvcResult result =
|
||||
mockMvc.perform(get("/v3/api-docs"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
String raw = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
JsonNode actual = MAPPER.readTree(raw);
|
||||
String normalized = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(actual);
|
||||
|
||||
Path contractFile = repositoryRoot().resolve("contracts/openapi/delivery-platform-api.json");
|
||||
boolean update = "1".equals(System.getenv("UPDATE_OPENAPI"));
|
||||
|
||||
if (update) {
|
||||
Files.createDirectories(contractFile.getParent());
|
||||
Files.writeString(contractFile, normalized, StandardCharsets.UTF_8);
|
||||
return;
|
||||
}
|
||||
|
||||
assertThat(Files.isRegularFile(contractFile))
|
||||
.as(
|
||||
"缺少契约文件 %s;请执行: cd services/delivery-platform-api && UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest",
|
||||
contractFile)
|
||||
.isTrue();
|
||||
|
||||
JsonNode expected = MAPPER.readTree(Files.readString(contractFile, StandardCharsets.UTF_8));
|
||||
assertThat(actual)
|
||||
.as(
|
||||
"OpenAPI 与快照不一致。若变更为刻意更新契约,请设置 UPDATE_OPENAPI=1 重新导出并提交 %s",
|
||||
contractFile)
|
||||
.isEqualTo(expected);
|
||||
}
|
||||
|
||||
/** Surefire 的 user.dir 为当前模块根(delivery-platform-api)。 */
|
||||
private static Path repositoryRoot() {
|
||||
Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
if (dir.endsWith("delivery-platform-api")) {
|
||||
return dir.getParent().getParent();
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package cn.craftlabs.platform.api.customer;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
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.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class CustomerControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void customerCrudHappyPath() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
String auth = "Bearer " + token;
|
||||
|
||||
String createBody = "{\"name\":\"测试客户\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}";
|
||||
String created =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/customers")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNumber())
|
||||
.andExpect(jsonPath("$.name").value("测试客户"))
|
||||
.andExpect(jsonPath("$.creditCode").value("91110000MA"))
|
||||
.andExpect(jsonPath("$.status").value("ACTIVE"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
long id = objectMapper.readTree(created).get("id").asLong();
|
||||
|
||||
mockMvc.perform(get("/api/v1/customers").header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray())
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.number").value(0))
|
||||
.andExpect(jsonPath("$.size").value(20));
|
||||
|
||||
mockMvc.perform(get("/api/v1/customers").param("keyword", "测试").header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(1));
|
||||
|
||||
mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("测试客户"));
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v1/customers/" + id)
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"已更名\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("已更名"));
|
||||
|
||||
mockMvc.perform(delete("/api/v1/customers/" + id).header("Authorization", auth))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
JsonNode after =
|
||||
objectMapper.readTree(
|
||||
mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString());
|
||||
assertThat(after.get("status").asText()).isEqualTo("INACTIVE");
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.craftlabs.platform.api.dictionary;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class DictionaryControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void listProjectPhaseDictionary() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
mockMvc.perform(
|
||||
get("/api/v1/dictionaries/PROJECT_PHASE")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(3)))
|
||||
.andExpect(jsonPath("$[0].dictCode").value("PLANNING"))
|
||||
.andExpect(jsonPath("$[0].dictLabel").value("规划中"))
|
||||
.andExpect(jsonPath("$[1].dictCode").value("IN_PROGRESS"))
|
||||
.andExpect(jsonPath("$[1].dictLabel").value("进行中"))
|
||||
.andExpect(jsonPath("$[2].dictCode").value("DELIVERED"))
|
||||
.andExpect(jsonPath("$[2].dictLabel").value("已交付"));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package cn.craftlabs.platform.api.ping;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
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.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class PingWithJwtTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void pingRequiresJwt() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/ping")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingWithBearerFromLogin() throws Exception {
|
||||
MvcResult login =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").isString())
|
||||
.andReturn();
|
||||
String token =
|
||||
objectMapper.readTree(login.getResponse().getContentAsString()).get("token").asText();
|
||||
assertThat(token).isNotBlank();
|
||||
|
||||
mockMvc.perform(get("/api/v1/ping").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("ok"));
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cn.craftlabs.platform.api.project;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
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.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class ProjectControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void projectCrudHappyPath() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
String auth = "Bearer " + token;
|
||||
|
||||
String cust =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/customers")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"项目所属客户\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long customerId = objectMapper.readTree(cust).get("id").asLong();
|
||||
|
||||
String projBody =
|
||||
"{\"customerId\":"
|
||||
+ customerId
|
||||
+ ",\"name\":\"交付项目A\",\"phase\":\"PLANNING\"}";
|
||||
String created =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/projects")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(projBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.phase").value("PLANNING"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long projectId = objectMapper.readTree(created).get("id").asLong();
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/projects")
|
||||
.param("customerId", String.valueOf(customerId))
|
||||
.header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.content[0].name").value("交付项目A"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.customerId").value(customerId));
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v1/projects/" + projectId)
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"customerId\":"
|
||||
+ customerId
|
||||
+ ",\"name\":\"交付项目A-改\",\"phase\":\"IN_PROGRESS\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("交付项目A-改"))
|
||||
.andExpect(jsonPath("$.phase").value("IN_PROGRESS"));
|
||||
|
||||
mockMvc.perform(delete("/api/v1/projects/" + projectId).header("Authorization", auth))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package cn.craftlabs.platform.api.support;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
public final class JwtTestSupport {
|
||||
|
||||
private JwtTestSupport() {}
|
||||
|
||||
public static String obtainBearerToken(MockMvc mockMvc, ObjectMapper objectMapper) throws Exception {
|
||||
MvcResult login =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
||||
.andReturn();
|
||||
String body = login.getResponse().getContentAsString();
|
||||
return objectMapper.readTree(body).get("token").asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# 单测不依赖本机 PostgreSQL:H2 模拟 PostgreSQL 语法习惯(版本仍以生产 PG15 为准)
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:craftlabs_platform;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
flyway:
|
||||
enabled: true
|
||||
table: flyway_platform_api
|
||||
|
||||
platform:
|
||||
jwt:
|
||||
secret: unit-test-jwt-secret-at-least-32-chars-ok
|
||||
Reference in New Issue
Block a user