264 lines
8.6 KiB
Go
264 lines
8.6 KiB
Go
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)
|
||
}
|
||
}
|