test: cover namespace workflow smoke paths

This commit is contained in:
yun-zhi-ztl 2026-03-16 13:13:12 +08:00 committed by Xudong Sun
parent 30d6581710
commit 62979b2dd7
4 changed files with 462 additions and 1 deletions

View file

@ -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

View file

@ -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

257
scripts/namespace-smoke-test.sh Executable file
View file

@ -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

View file

@ -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);
}
}