219 lines
7.4 KiB
Go
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
|
|
}
|