Scout/internal/httpapi/projects_access_test.go
2026-08-31 21:19:15 +08:00

219 lines
7.4 KiB
Go

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
}