Files
nl-pms-api/internal/handler/file_test.go
2026-08-15 07:41:11 +08:00

263 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler_test
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
"nl-pms-api/internal/router"
)
const testKey = "test-key"
// newTestServer 起一个内存 SQLite 后端的完整路由,并铺好 users / team_members 假数据:
// 1=admin超管、2=alice团队 10 owner、3=bob团队 10 member
func newTestServer(t *testing.T) (*httptest.Server, *gorm.DB, *config.Config) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.File{}); err != nil {
t.Fatalf("migrate: %v", err)
}
db.Exec(`CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT)`)
db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`)
db.Exec(`INSERT INTO users(id, username) VALUES(1,'admin'),(2,'alice'),(3,'bob')`)
db.Exec(`INSERT INTO team_members(team_id,user_id,role) VALUES(10,2,'owner'),(10,3,'member')`)
cfg := &config.Config{Env: "dev", APIKey: testKey, StorageDir: t.TempDir(), MaxUploadMB: 20}
srv := httptest.NewServer(router.New(cfg, db))
t.Cleanup(srv.Close)
return srv, db, cfg
}
// pngBytes 生成一张纯色 PNGtone 不同则内容sha256不同。
func pngBytes(t *testing.T, tone uint8) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
for x := 0; x < 2; x++ {
for y := 0; y < 2; y++ {
img.Set(x, y, color.RGBA{R: tone, G: 90, B: 60, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
type uploadResp struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Mime string `json:"mime"`
}
func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (*http.Response, uploadResp) {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
fw, err := w.CreateFormFile("file", "test.png")
if err != nil {
t.Fatalf("form file: %v", err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("write: %v", err)
}
_ = w.WriteField("kind", "content")
_ = w.WriteField("userId", userID)
_ = w.WriteField("teamId", teamID)
_ = w.Close()
req, _ := http.NewRequest("POST", url+"/api/v1/files", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
var out uploadResp
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
return resp, out
}
func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []map[string]any) {
t.Helper()
req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&userId="+userID+"&teamId="+teamID, nil)
req.Header.Set("Authorization", "Bearer "+testKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("list: %v", err)
}
defer resp.Body.Close()
var out struct {
Total int64 `json:"total"`
Items []map[string]any `json:"items"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out.Total, out.Items
}
func deleteFile(t *testing.T, base string, id int64, userID string) int {
t.Helper()
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id)+"?userId="+userID, nil)
req.Header.Set("Authorization", "Bearer "+testKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("delete: %v", err)
}
resp.Body.Close()
return resp.StatusCode
}
func jsonNum(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
func TestHealthz(t *testing.T) {
srv, _, _ := newTestServer(t)
resp, err := http.Get(srv.URL + "/healthz")
if err != nil || resp.StatusCode != 200 {
t.Fatalf("healthz: %v %v", err, resp)
}
}
func TestUploadRequiresAuth(t *testing.T) {
srv, _, _ := newTestServer(t)
if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", resp.StatusCode)
}
if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", resp.StatusCode)
}
}
func TestUploadServeAndScopedDedupe(t *testing.T) {
srv, db, cfg := newTestServer(t)
data := pngBytes(t, 200)
resp, out := upload(t, srv.URL, testKey, data, "2", "10")
if resp.StatusCode != 200 || out.Name == "" || out.Mime != "image/png" {
t.Fatalf("upload failed: %d %+v", resp.StatusCode, out)
}
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(out.Name))); err != nil {
t.Fatalf("file not on disk: %v", err)
}
// 公开访问返回原始内容
got, err := http.Get(srv.URL + "/files/" + out.Name)
if err != nil || got.StatusCode != 200 {
t.Fatalf("serve: %v %v", err, got)
}
var served bytes.Buffer
_, _ = served.ReadFrom(got.Body)
if !bytes.Equal(served.Bytes(), data) {
t.Fatal("served content mismatch")
}
// 同归属user=2, team=10重复上传 → 秒传复用同一条记录
_, again := upload(t, srv.URL, testKey, data, "2", "10")
if again.ID != out.ID || again.Name != out.Name {
t.Fatalf("same-owner dedupe failed: %+v vs %+v", again, out)
}
// 不同归属user=3上传同内容 → 独立记录独立文件,删除互不影响
_, other := upload(t, srv.URL, testKey, data, "3", "10")
if other.ID == out.ID || other.Name == out.Name {
t.Fatalf("cross-owner upload should create its own record: %+v", other)
}
var count int64
db.Model(&model.File{}).Count(&count)
if count != 2 {
t.Fatalf("want 2 rows, got %d", count)
}
}
func TestUploadRejectsNonImage(t *testing.T) {
srv, _, _ := newTestServer(t)
if resp, _ := upload(t, srv.URL, testKey, []byte("plain text, not an image"), "2", "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("want 415, got %d", resp.StatusCode)
}
}
func TestListScopes(t *testing.T) {
srv, _, _ := newTestServer(t)
upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10") // alice 团队图 x2
upload(t, srv.URL, testKey, pngBytes(t, 20), "2", "10")
upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "10") // bob 团队图
upload(t, srv.URL, testKey, pngBytes(t, 40), "3", "0") // bob 个人图
if code, total, items := listFiles(t, srv.URL, "mine", "2", "0"); code != 200 || total != 2 || len(items) != 2 {
t.Fatalf("mine(alice): code=%d total=%d n=%d", code, total, len(items))
}
// 团队视角owner 可见团队 3 张(含上传者用户名)
code, total, items := listFiles(t, srv.URL, "team", "2", "10")
if code != 200 || total != 3 {
t.Fatalf("team(owner): code=%d total=%d", code, total)
}
if items[0]["username"] == "" {
t.Fatalf("team items should carry username: %+v", items[0])
}
// member 无权看团队素材
if code, _, _ := listFiles(t, srv.URL, "team", "3", "10"); code != http.StatusForbidden {
t.Fatalf("team(member) should be 403, got %d", code)
}
// all 仅超管
if code, total, _ := listFiles(t, srv.URL, "all", "1", "0"); code != 200 || total != 4 {
t.Fatalf("all(admin): code=%d total=%d", code, total)
}
if code, _, _ := listFiles(t, srv.URL, "all", "2", "0"); code != http.StatusForbidden {
t.Fatalf("all(non-admin) should be 403, got %d", code)
}
}
func TestDeletePermissions(t *testing.T) {
srv, _, cfg := newTestServer(t)
_, aliceTeam := upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10")
_, bobTeam := upload(t, srv.URL, testKey, pngBytes(t, 20), "3", "10")
_, bobOwn := upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "0")
_, aliceOwn := upload(t, srv.URL, testKey, pngBytes(t, 40), "2", "0")
// member 不能删别人的(即使同团队)
if code := deleteFile(t, srv.URL, aliceTeam.ID, "3"); code != http.StatusForbidden {
t.Fatalf("member deleting other's file should be 403, got %d", code)
}
// 团队 owner 可删团队内他人上传的
if code := deleteFile(t, srv.URL, bobTeam.ID, "2"); code != 200 {
t.Fatalf("owner deleting team file should be 200, got %d", code)
}
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(bobTeam.Name))); !os.IsNotExist(err) {
t.Fatal("deleted file should be removed from disk")
}
// owner 不能删团队外的个人文件
if code := deleteFile(t, srv.URL, bobOwn.ID, "2"); code != http.StatusForbidden {
t.Fatalf("owner deleting personal file outside team should be 403, got %d", code)
}
// 本人可删自己的
if code := deleteFile(t, srv.URL, bobOwn.ID, "3"); code != 200 {
t.Fatalf("self delete should be 200, got %d", code)
}
// 超管可删任何
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != 200 {
t.Fatalf("admin delete should be 200, got %d", code)
}
// 已删除 → 404
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != http.StatusNotFound {
t.Fatalf("double delete should be 404, got %d", code)
}
}