diff --git a/Makefile b/Makefile index 41d11504..91faf34f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test clean web-install dev-server dev-server-restart dev-web build-web test-web typecheck-web lint-web generate-api db-reset validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down +.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test clean web-install dev-server dev-server-restart dev-web build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid @@ -117,6 +117,9 @@ dev-server-restart: ## 重启后端开发服务器 echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ exit 1 +namespace-smoke: ## 运行命名空间工作流 smoke test + ./scripts/namespace-smoke-test.sh $(DEV_API_URL) + dev-down: ## 停止本地开发环境 $(DEV_COMPOSE) down --remove-orphans diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index 156075ef..c5f6dd4d 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -60,6 +60,7 @@ Two mock users are available in local mode (no password needed): | `SERVICE=frontend make dev-logs` | Tail frontend logs | | `make dev-all-reset` | Full reset (clears data volumes) | | `make dev-server-restart` | Restart backend after Java changes | +| `make namespace-smoke` | Run namespace workflow smoke test | | `make db-reset` | Reset database only | ### Claude + Codex parallel workflow diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh new file mode 100755 index 00000000..ae2b7b59 --- /dev/null +++ b/scripts/namespace-smoke-test.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +USER_COOKIE="$(mktemp)" +ADMIN_COOKIE="$(mktemp)" +SLUG="nsmoke$(date +%s)" + +cleanup() { + rm -f "$USER_COOKIE" "$ADMIN_COOKIE" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +csrf_token() { + local cookie_file="$1" + awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1 +} + +bootstrap_csrf() { + local cookie_file="$1" + local user_id="$2" + curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +value = data +for part in expr.split('.'): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +USER_HEADERS=(-H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE") +ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE") + +echo "=== Namespace Workflow Smoke Test ===" +echo "Target: $BASE_URL" +echo "Slug: $SLUG" +echo + +bootstrap_csrf "$USER_COOKIE" "local-user" +bootstrap_csrf "$ADMIN_COOKIE" "local-admin" + +USER_CSRF="$(csrf_token "$USER_COOKIE")" +ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")" + +if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then + echo "Could not bootstrap CSRF tokens" + exit 1 +fi + +CREATE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces" \ + -d "{\"slug\":\"$SLUG\",\"displayName\":\"Namespace Smoke $SLUG\",\"description\":\"namespace workflow smoke test\"}")" +assert_code "Owner can create namespace" "$CREATE_RESPONSE" "0" +NAMESPACE_ID="$(json_field "$CREATE_RESPONSE" "data.id")" + +MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Owner can list my namespaces" "$MINE_RESPONSE" "0" +if JSON_INPUT="$MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +match = next((item for item in items if item["slug"] == slug), None) +if not match: + raise SystemExit(1) +if match["currentUserRole"] != "OWNER": + raise SystemExit(2) +if match["status"] != "ACTIVE": + raise SystemExit(3) +PY +then + pass "Created namespace shows up as ACTIVE owner namespace" +else + fail "Created namespace should appear in owner namespace list with OWNER role" +fi + +ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Other user can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +PY +then + pass "Namespace is not visible to unrelated users in my namespaces" +else + fail "Unrelated user should not see team namespace in my namespaces" +fi + +FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Unrelated user cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" + +CANDIDATES_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" +assert_code "Owner can search namespace member candidates" "$CANDIDATES_RESPONSE" "0" +if JSON_INPUT="$CANDIDATES_RESPONSE" python3 - <<'PY' +import json +import os +import sys +data = json.loads(os.environ["JSON_INPUT"]) +ids = {item["userId"] for item in data["data"]} +raise SystemExit(0 if "local-admin" in ids else 1) +PY +then + pass "Candidate search returns local-admin" +else + fail "Candidate search should include local-admin" +fi + +ADD_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-admin","role":"MEMBER"}')" +assert_code "Owner can add namespace members" "$ADD_MEMBER_RESPONSE" "0" + +MEMBERS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" +assert_code "Owner can list namespace members" "$MEMBERS_RESPONSE" "0" +if JSON_INPUT="$MEMBERS_RESPONSE" python3 - <<'PY' +import json +import os +import sys +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"]["items"] +ids = {item["userId"] for item in items} +raise SystemExit(0 if {"local-user", "local-admin"}.issubset(ids) else 1) +PY +then + pass "Member list shows owner and invited admin user" +else + fail "Member list should contain owner and invited user" +fi + +REVIEWS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +assert_code "Owner can open namespace review list" "$REVIEWS_RESPONSE" "0" + +PROMOTE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin/role" \ + -d '{"role":"ADMIN"}')" +assert_code "Owner can promote member to admin" "$PROMOTE_RESPONSE" "0" + +ADMIN_FREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Namespace admin can freeze namespace" "$ADMIN_FREEZE_RESPONSE" "0" +if [[ "$(json_field "$ADMIN_FREEZE_RESPONSE" "data.status")" == "FROZEN" ]]; then + pass "Freeze changes namespace status to FROZEN" +else + fail "Freeze should set namespace status to FROZEN" +fi + +ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-user","role":"MEMBER"}')" +assert_code "Frozen namespace rejects member mutation" "$ADD_WHILE_FROZEN_RESPONSE" "400" + +ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/unfreeze")" +assert_code "Namespace admin can unfreeze namespace" "$ADMIN_UNFREEZE_RESPONSE" "0" + +ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ + -d '{"reason":"smoke"}')" +assert_code "Namespace admin cannot archive namespace" "$ADMIN_ARCHIVE_RESPONSE" "403" + +OWNER_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ + -d '{"reason":"smoke"}')" +assert_code "Owner can archive namespace" "$OWNER_ARCHIVE_RESPONSE" "0" +if [[ "$(json_field "$OWNER_ARCHIVE_RESPONSE" "data.status")" == "ARCHIVED" ]]; then + pass "Archive changes namespace status to ARCHIVED" +else + fail "Archive should set namespace status to ARCHIVED" +fi + +OWNER_RESTORE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/restore")" +assert_code "Owner can restore archived namespace" "$OWNER_RESTORE_RESPONSE" "0" +if [[ "$(json_field "$OWNER_RESTORE_RESPONSE" "data.status")" == "ACTIVE" ]]; then + pass "Restore changes namespace status back to ACTIVE" +else + fail "Restore should set namespace status back to ACTIVE" +fi + +REMOVE_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin")" +assert_code "Owner can remove namespace admin" "$REMOVE_MEMBER_RESPONSE" "0" + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java new file mode 100644 index 00000000..167880fc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -0,0 +1,200 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +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.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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 +@ActiveProfiles("test") +class NamespaceWorkflowContractTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceService namespaceService; + + @MockBean + private NamespaceGovernanceService namespaceGovernanceService; + + @MockBean + private NamespaceMemberService namespaceMemberService; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private NamespaceMemberCandidateService namespaceMemberCandidateService; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void namespaceWorkflowEndpoints_shareExpectedEnvelopeShapes() throws Exception { + Namespace namespace = namespace(7L, "team-flow", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM); + Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN); + setMemberId(adminMember, 11L); + + given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1"))) + .willReturn(namespace); + given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace); + given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any())) + .willReturn(frozen); + given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any())) + .willReturn(archived); + given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10)) + .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); + given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMember); + given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class))) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember))); + given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMember); + + mockMvc.perform(post("/api/web/namespaces") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"slug\":\"team-flow\",\"displayName\":\"Team Flow\",\"description\":\"workflow\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-flow")); + + mockMvc.perform(get("/api/web/namespaces/team-flow/member-candidates") + .param("search", "admin") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].userId").value("user-admin")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/members") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-admin")); + + mockMvc.perform(get("/api/web/namespaces/team-flow/members") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")); + + mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/freeze") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("FROZEN")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/archive") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + + mockMvc.perform(delete("/api/web/namespaces/team-flow/members/user-admin") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Member removed successfully")); + } + + private RequestPostProcessor auth(String userId) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + Set.of() + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + return authentication(authenticationToken); + } + + private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { + Namespace namespace = new Namespace(slug, "Team Flow", "owner-1"); + setNamespaceId(namespace, id); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private void setNamespaceId(Namespace namespace, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", id); + } + + private void setMemberId(NamespaceMember member, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(member, "id", id); + } +}