Files
nl-pms-api/internal/controller/file_test.go
2026-08-15 17:04:47 +08:00

264 lines
8.6 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 controller_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/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
"nl-pms-api/internal/router"
)
const testSecret = "test-jwt-secret"
// 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, map[int64]string) {
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{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`)
db.Exec(`INSERT INTO users(id, username, password_hash, created_at) VALUES(1,'admin','x','t'),(2,'alice','x','t'),(3,'bob','x','t')`)
db.Exec(`INSERT INTO team_members(team_id,user_id,role) VALUES(10,2,'owner'),(10,3,'member')`)
cfg := &config.Config{
Env: "dev",
JWTSecret: testSecret,
StorageDir: t.TempDir(),
MaxUploadMB: 20,
AccessTTLHours: 2,
RefreshTTLDays: 30,
}
tokens := map[int64]string{}
for id, name := range map[int64]string{1: "admin", 2: "alice", 3: "bob"} {
tok, err := commonservice.IssueAccess(testSecret, id, name, 2)
if err != nil {
t.Fatalf("issue token: %v", err)
}
tokens[id] = tok
}
srv := httptest.NewServer(router.New(cfg, db))
t.Cleanup(srv.Close)
return srv, db, cfg, tokens
}
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, token string, data []byte, 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("teamId", teamID)
_ = w.Close()
req, _ := http.NewRequest("POST", url+"/api/v1/files", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
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, token, scope, teamID string) (int, int64, []map[string]any) {
t.Helper()
req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&teamId="+teamID, nil)
req.Header.Set("Authorization", "Bearer "+token)
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, token string, id int64) int {
t.Helper()
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id), nil)
req.Header.Set("Authorization", "Bearer "+token)
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), "0"); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", resp.StatusCode)
}
if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "0"); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", resp.StatusCode)
}
}
func TestUploadServeAndScopedDedupe(t *testing.T) {
srv, db, cfg, tokens := newTestServer(t)
data := pngBytes(t, 200)
resp, out := upload(t, srv.URL, tokens[2], data, "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")
}
_, again := upload(t, srv.URL, tokens[2], data, "10")
if again.ID != out.ID || again.Name != out.Name {
t.Fatalf("same-owner dedupe failed: %+v vs %+v", again, out)
}
_, other := upload(t, srv.URL, tokens[3], data, "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, _, _, tokens := newTestServer(t)
if resp, _ := upload(t, srv.URL, tokens[2], []byte("plain text, not an image"), "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("want 415, got %d", resp.StatusCode)
}
}
func TestListScopes(t *testing.T) {
srv, _, _, tokens := newTestServer(t)
upload(t, srv.URL, tokens[2], pngBytes(t, 10), "10")
upload(t, srv.URL, tokens[2], pngBytes(t, 20), "10")
upload(t, srv.URL, tokens[3], pngBytes(t, 30), "10")
upload(t, srv.URL, tokens[3], pngBytes(t, 40), "0")
if code, total, items := listFiles(t, srv.URL, tokens[2], "mine", "0"); code != 200 || total != 2 || len(items) != 2 {
t.Fatalf("mine(alice): code=%d total=%d n=%d", code, total, len(items))
}
code, total, items := listFiles(t, srv.URL, tokens[2], "team", "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])
}
if code, _, _ := listFiles(t, srv.URL, tokens[3], "team", "10"); code != http.StatusForbidden {
t.Fatalf("team(member) should be 403, got %d", code)
}
if code, total, _ := listFiles(t, srv.URL, tokens[1], "all", "0"); code != 200 || total != 4 {
t.Fatalf("all(admin): code=%d total=%d", code, total)
}
if code, _, _ := listFiles(t, srv.URL, tokens[2], "all", "0"); code != http.StatusForbidden {
t.Fatalf("all(non-admin) should be 403, got %d", code)
}
}
func TestDeletePermissions(t *testing.T) {
srv, _, cfg, tokens := newTestServer(t)
_, aliceTeam := upload(t, srv.URL, tokens[2], pngBytes(t, 10), "10")
_, bobTeam := upload(t, srv.URL, tokens[3], pngBytes(t, 20), "10")
_, bobOwn := upload(t, srv.URL, tokens[3], pngBytes(t, 30), "0")
_, aliceOwn := upload(t, srv.URL, tokens[2], pngBytes(t, 40), "0")
if code := deleteFile(t, srv.URL, tokens[3], aliceTeam.ID); code != http.StatusForbidden {
t.Fatalf("member deleting other's file should be 403, got %d", code)
}
if code := deleteFile(t, srv.URL, tokens[2], bobTeam.ID); 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")
}
if code := deleteFile(t, srv.URL, tokens[2], bobOwn.ID); code != http.StatusForbidden {
t.Fatalf("owner deleting personal file outside team should be 403, got %d", code)
}
if code := deleteFile(t, srv.URL, tokens[3], bobOwn.ID); code != 200 {
t.Fatalf("self delete should be 200, got %d", code)
}
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != 200 {
t.Fatalf("admin delete should be 200, got %d", code)
}
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != http.StatusNotFound {
t.Fatalf("double delete should be 404, got %d", code)
}
}