package httpapi import ( "bytes" "context" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "scout/internal/access" "scout/internal/registry" "scout/internal/users" ) func TestProjectMembersAndTokensAuthorizeScopedOperations(t *testing.T) { root := t.TempDir() legacy, err := registry.New(root) if err != nil { t.Fatal(err) } projects, err := registry.NewProjectRegistry(root) if err != nil { t.Fatal(err) } if _, _, err := projects.Ensure("ssl", "SSL", "证书项目"); err != nil { t.Fatal(err) } accessStore, err := access.New(filepath.Join(root, "project-access.json")) if err != nil { t.Fatal(err) } userStore, err := users.New(filepath.Join(root, "users.json")) if err != nil { t.Fatal(err) } admin, err := userStore.EnsureAdmin("admin@example.com", "管理员", "admin-pass-123") if err != nil { t.Fatal(err) } viewer, err := userStore.Register("viewer@example.com", "查看者", "viewer-pass-123") if err != nil { t.Fatal(err) } adminSession, _, err := userStore.CreateSession(admin.ID, time.Hour) if err != nil { t.Fatal(err) } viewerSession, _, err := userStore.CreateSession(viewer.ID, time.Hour) if err != nil { t.Fatal(err) } server := httptest.NewServer((&Server{Registry: legacy, Projects: projects, Access: accessStore, Users: userStore}).Handler()) defer server.Close() memberResponse := jsonRequest(t, http.MethodPost, server.URL+"/api/v1/projects/ssl/members", adminSession, map[string]string{ "user_id": viewer.ID, "role": access.RoleViewer, }) if memberResponse.StatusCode != http.StatusCreated { body, _ := io.ReadAll(memberResponse.Body) t.Fatalf("添加项目成员失败:%d %s", memberResponse.StatusCode, body) } memberResponse.Body.Close() publicResponse := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/packages", nil) if publicResponse.StatusCode != http.StatusUnauthorized { t.Fatalf("启用项目权限后匿名读取状态错误:%d", publicResponse.StatusCode) } publicResponse.Body.Close() viewerResponse := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/packages", map[string]string{"Authorization": "Bearer " + viewerSession}) if viewerResponse.StatusCode != http.StatusOK { t.Fatalf("项目查看成员读取状态错误:%d", viewerResponse.StatusCode) } viewerResponse.Body.Close() uploadResponse := projectMultipartRequest(t, server.URL+"/api/v1/projects/ssl/packages/upload", viewerSession, "secure", "1.0.0", "secure.pkg", "secure-content") if uploadResponse.StatusCode != http.StatusForbidden { t.Fatalf("项目查看成员上传状态错误:%d", uploadResponse.StatusCode) } uploadResponse.Body.Close() tokenResponse := jsonRequest(t, http.MethodPost, server.URL+"/api/v1/projects/ssl/tokens", adminSession, map[string]string{ "name": "客户端只读令牌", "role": access.RoleViewer, }) if tokenResponse.StatusCode != http.StatusCreated { t.Fatalf("创建项目令牌失败:%d", tokenResponse.StatusCode) } var created struct { Token access.ProjectToken `json:"token"` Secret string `json:"secret"` } if err := json.NewDecoder(tokenResponse.Body).Decode(&created); err != nil { t.Fatal(err) } tokenResponse.Body.Close() if created.Secret == "" || created.Token.SecretHash != "" { t.Fatal("项目令牌响应不符合只显示一次明文的要求") } tokenRead := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/packages", map[string]string{"X-Project-Token": created.Secret}) if tokenRead.StatusCode != http.StatusOK { t.Fatalf("项目只读令牌读取状态错误:%d", tokenRead.StatusCode) } tokenRead.Body.Close() developerTokenResponse := jsonRequest(t, http.MethodPost, server.URL+"/api/v1/projects/ssl/tokens", adminSession, map[string]string{ "name": "客户端开发令牌", "role": access.RoleDeveloper, }) if developerTokenResponse.StatusCode != http.StatusCreated { t.Fatalf("创建项目开发令牌失败:%d", developerTokenResponse.StatusCode) } var developerCreated struct { Token access.ProjectToken `json:"token"` Secret string `json:"secret"` } if err := json.NewDecoder(developerTokenResponse.Body).Decode(&developerCreated); err != nil { t.Fatal(err) } developerTokenResponse.Body.Close() developerUpload := projectMultipartRequestWithHeader(t, server.URL+"/api/v1/projects/ssl/packages/upload", "X-Project-Token", developerCreated.Secret, "token-upload", "1.0.0", "token-upload.pkg", "token-upload-content") if developerUpload.StatusCode != http.StatusCreated { body, _ := io.ReadAll(developerUpload.Body) developerUpload.Body.Close() t.Fatalf("项目开发令牌上传失败:%d %s", developerUpload.StatusCode, body) } developerUpload.Body.Close() crossProject := getWithHeaders(t, server.URL+"/api/v1/projects/smail/packages", map[string]string{"X-Project-Token": created.Secret}) if crossProject.StatusCode != http.StatusNotFound { crossProject.Body.Close() t.Fatalf("不存在项目的令牌请求状态错误:%d", crossProject.StatusCode) } crossProject.Body.Close() tokensResponse := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/tokens", map[string]string{"Authorization": "Bearer " + adminSession}) if tokensResponse.StatusCode != http.StatusOK { t.Fatalf("查询项目令牌失败:%d", tokensResponse.StatusCode) } data, _ := io.ReadAll(tokensResponse.Body) tokensResponse.Body.Close() if bytes.Contains(data, []byte(created.Secret)) { t.Fatal("项目令牌明文不应出现在列表响应") } } func jsonRequest(t *testing.T, method, target, token string, value any) *http.Response { t.Helper() body, err := json.Marshal(value) if err != nil { t.Fatal(err) } request, err := http.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body)) if err != nil { t.Fatal(err) } request.Header.Set("Content-Type", "application/json") if token != "" { request.Header.Set("Authorization", "Bearer "+token) } response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } return response } func getWithHeaders(t *testing.T, target string, headers map[string]string) *http.Response { t.Helper() request, err := http.NewRequest(http.MethodGet, target, nil) if err != nil { t.Fatal(err) } for key, value := range headers { request.Header.Set(key, value) } response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } return response } func projectMultipartRequest(t *testing.T, target, token, name, version, fileName, content string) *http.Response { return projectMultipartRequestWithHeader(t, target, "Authorization", "Bearer "+token, name, version, fileName, content) } func projectMultipartRequestWithHeader(t *testing.T, target, headerName, headerValue, name, version, fileName, content string) *http.Response { t.Helper() body := &bytes.Buffer{} writer := multipart.NewWriter(body) _ = writer.WriteField("name", name) _ = writer.WriteField("version", version) _ = writer.WriteField("platform", "linux") _ = writer.WriteField("architecture", "amd64") file, err := writer.CreateFormFile("file", fileName) if err != nil { t.Fatal(err) } _, _ = file.Write([]byte(content)) _ = writer.Close() request, err := http.NewRequest(http.MethodPost, target, body) if err != nil { t.Fatal(err) } request.Header.Set("Content-Type", writer.FormDataContentType()) request.Header.Set(headerName, headerValue) response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } return response }