257 lines
7.3 KiB
Go
257 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
ftpserver "goftp.io/server/v2"
|
|
)
|
|
|
|
func makeTestServer(t *testing.T, mutate func(*Config)) *Server {
|
|
t.Helper()
|
|
|
|
root := t.TempDir()
|
|
cfg := Config{
|
|
Addr: ":0",
|
|
DBPath: filepath.Join(root, "app.db"),
|
|
StorageRoot: filepath.Join(root, "users"),
|
|
AppDomain: "file.example.com",
|
|
AllowedHost: "file.example.com",
|
|
CORSOrigin: "https://file.example.com",
|
|
CookieSecure: false,
|
|
MaxBodyBytes: 8 * 1024 * 1024,
|
|
RateLimitPerMin: 1000,
|
|
AuthRateLimitPerMin: 1000,
|
|
JWTSecret: "test-jwt-secret-very-long-value-1234567890",
|
|
AccessTTL: 15 * time.Minute,
|
|
RefreshTTL: 24 * time.Hour,
|
|
ShareDefaultTTL: 24 * time.Hour,
|
|
AdminSessionTTL: 12 * time.Hour,
|
|
AdminLogin: "admin",
|
|
AdminPasswordHash: "sha256:dummy",
|
|
}
|
|
if mutate != nil {
|
|
mutate(&cfg)
|
|
}
|
|
|
|
db, err := openDB(cfg.DBPath)
|
|
if err != nil {
|
|
t.Fatalf("openDB failed: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
if err := migrate(db); err != nil {
|
|
t.Fatalf("migrate failed: %v", err)
|
|
}
|
|
|
|
storage, err := buildStorage(cfg)
|
|
if err != nil {
|
|
t.Fatalf("buildStorage failed: %v", err)
|
|
}
|
|
orm, err := newORMRepo(cfg.DBPath)
|
|
if err != nil {
|
|
t.Fatalf("newORMRepo failed: %v", err)
|
|
}
|
|
|
|
return &Server{
|
|
db: db,
|
|
orm: orm,
|
|
config: cfg,
|
|
storage: storage,
|
|
limiter: newRateLimiter(),
|
|
searchContent: newSearchContentCache(256),
|
|
}
|
|
}
|
|
|
|
func decodeJSONBody[T any](t *testing.T, res *http.Response, out *T) {
|
|
t.Helper()
|
|
defer res.Body.Close()
|
|
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
|
|
t.Fatalf("decode json failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
|
|
for _, c := range cookies {
|
|
if c.Name == name {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestAPILoginRefreshAndMe(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRec := httptest.NewRecorder()
|
|
s.handleLogin(loginRec, loginReq)
|
|
|
|
loginRes := loginRec.Result()
|
|
if loginRes.StatusCode != http.StatusOK {
|
|
t.Fatalf("login status = %d, want %d", loginRes.StatusCode, http.StatusOK)
|
|
}
|
|
if cookieByName(loginRes.Cookies(), "access_token") == nil {
|
|
t.Fatal("login did not set access_token cookie")
|
|
}
|
|
refreshCookie := cookieByName(loginRes.Cookies(), "refresh_token")
|
|
if refreshCookie == nil {
|
|
t.Fatal("login did not set refresh_token cookie")
|
|
}
|
|
|
|
var meResp User
|
|
decodeJSONBody(t, loginRes, &meResp)
|
|
if meResp.ID != user.ID || meResp.Username != user.Username {
|
|
t.Fatalf("login response user mismatch: got %+v want id=%d username=%q", meResp, user.ID, user.Username)
|
|
}
|
|
|
|
refreshReq := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", nil)
|
|
refreshReq.AddCookie(refreshCookie)
|
|
refreshRec := httptest.NewRecorder()
|
|
s.handleRefresh(refreshRec, refreshReq)
|
|
|
|
refreshRes := refreshRec.Result()
|
|
if refreshRes.StatusCode != http.StatusOK {
|
|
t.Fatalf("refresh status = %d, want %d", refreshRes.StatusCode, http.StatusOK)
|
|
}
|
|
newAccess := cookieByName(refreshRes.Cookies(), "access_token")
|
|
if newAccess == nil {
|
|
t.Fatal("refresh did not rotate access_token cookie")
|
|
}
|
|
|
|
meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
|
meReq.AddCookie(newAccess)
|
|
meRec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleMe)).ServeHTTP(meRec, meReq)
|
|
|
|
if meRec.Code != http.StatusOK {
|
|
t.Fatalf("/api/auth/me status = %d, want %d", meRec.Code, http.StatusOK)
|
|
}
|
|
var meAfter User
|
|
if err := json.NewDecoder(meRec.Body).Decode(&meAfter); err != nil {
|
|
t.Fatalf("decode /api/auth/me failed: %v", err)
|
|
}
|
|
if meAfter.Username != "alice" {
|
|
t.Fatalf("/api/auth/me username = %q, want %q", meAfter.Username, "alice")
|
|
}
|
|
}
|
|
|
|
func TestAPIUserProtocolsFTPS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, func(cfg *Config) {
|
|
cfg.FTPSEnabled = true
|
|
cfg.FTPSHost = "0.0.0.0"
|
|
cfg.FTPSPort = 2990
|
|
cfg.FTPSPublicIP = "198.51.100.10"
|
|
cfg.FTPSExplicit = true
|
|
cfg.FTPSForceTLS = true
|
|
})
|
|
|
|
if _, err := s.createUser("bob", "password123", "dracula", "auto"); err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"bob","password":"password123"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRec := httptest.NewRecorder()
|
|
s.handleLogin(loginRec, loginReq)
|
|
if loginRec.Code != http.StatusOK {
|
|
t.Fatalf("login status = %d", loginRec.Code)
|
|
}
|
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
|
if access == nil {
|
|
t.Fatal("missing access token cookie")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/user/protocols", nil)
|
|
req.AddCookie(access)
|
|
rec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleUserProtocols)).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("protocols status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var out userProtocolsResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode response failed: %v", err)
|
|
}
|
|
if out.FTPS == nil {
|
|
t.Fatal("expected FTPS profile in response")
|
|
}
|
|
if out.FTPS.Username != "bob" {
|
|
t.Fatalf("ftps username = %q, want %q", out.FTPS.Username, "bob")
|
|
}
|
|
if out.FTPS.Host != "198.51.100.10" || out.FTPS.Port != 2990 {
|
|
t.Fatalf("ftps endpoint mismatch: got %s:%d", out.FTPS.Host, out.FTPS.Port)
|
|
}
|
|
if !out.FTPS.ExplicitTLS || !out.FTPS.ForceTLS {
|
|
t.Fatal("expected explicit/forced TLS flags to be true")
|
|
}
|
|
}
|
|
|
|
func insertUserWithHash(t *testing.T, db *sql.DB, username, hash string) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(`INSERT INTO users(email, password_hash, theme, color_mode, archive_format) VALUES (?, ?, 'dracula', 'auto', 'zip')`, username, hash)
|
|
if err != nil {
|
|
t.Fatalf("insert user failed: %v", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatalf("LastInsertId failed: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func testFTPContextWithUserID(userID int64) *ftpserver.Context {
|
|
return &ftpserver.Context{Sess: &ftpserver.Session{Data: map[string]interface{}{"filez_user_id": userID}}}
|
|
}
|
|
|
|
func TestFTPDriverPlainTransfer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
hash, err := hashPasswordArgon2ID("password123")
|
|
if err != nil {
|
|
t.Fatalf("hash password failed: %v", err)
|
|
}
|
|
uid := insertUserWithHash(t, s.db, "dave", hash)
|
|
|
|
drv := &ftpUserDriver{db: s.db, root: s.config.StorageRoot}
|
|
ctx := testFTPContextWithUserID(uid)
|
|
|
|
plain := []byte("plain ftp payload")
|
|
if _, err := drv.PutFile(ctx, "/plain.txt", bytes.NewReader(plain), 0); err != nil {
|
|
t.Fatalf("PutFile failed: %v", err)
|
|
}
|
|
|
|
size, rc, err := drv.GetFile(ctx, "/plain.txt", 0)
|
|
if err != nil {
|
|
t.Fatalf("GetFile failed: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
got, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll failed: %v", err)
|
|
}
|
|
if size != int64(len(plain)) || !bytes.Equal(got, plain) {
|
|
t.Fatalf("plain transfer mismatch: size=%d got=%q", size, string(got))
|
|
}
|
|
}
|