Files
nl-pms-api/internal/handler/file_test.go

263 lines
8.9 KiB
Go
Raw Normal View History

2026-08-15 07:41:11 +08:00
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)
}
}