- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回 - agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编 - 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值 - 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知 - API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
131 lines
3.8 KiB
Go
131 lines
3.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fonu/fonu/internal/auth"
|
|
"github.com/fonu/fonu/internal/db"
|
|
)
|
|
|
|
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
|
|
t.Helper()
|
|
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
svc := auth.New(conn)
|
|
created, err := svc.CreateAPIToken(context.Background(), "test", scope, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("create token: %v", err)
|
|
}
|
|
return svc, created.Token
|
|
}
|
|
|
|
func okTokenHandler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
|
|
}
|
|
|
|
func TestRequireAuthAcceptsReadTokenForGet(t *testing.T) {
|
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
|
|
handler := RequireAuth(svc)(okTokenHandler())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
|
req.Header.Set("Authorization", "Bearer "+plain)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("只读令牌应能读,实际 %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAuthBlocksWriteForReadToken(t *testing.T) {
|
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
|
|
handler := RequireAuth(svc)(okTokenHandler())
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/proxies/1", nil)
|
|
req.Header.Set("Authorization", "Bearer "+plain)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("只读令牌不应能写,实际 %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAuthAllowsWriteForWriteToken(t *testing.T) {
|
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
|
|
handler := RequireAuth(svc)(okTokenHandler())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/proxies", nil)
|
|
req.Header.Set("Authorization", "Bearer "+plain)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("写令牌应能写,实际 %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAuthTokenCannotManageTokens(t *testing.T) {
|
|
// 写令牌也不能管理令牌:否则等于拿令牌给自己提权或无限续期
|
|
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
|
|
handler := RequireAuth(svc)(okTokenHandler())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/settings/tokens", nil)
|
|
req.Header.Set("Authorization", "Bearer "+plain)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("令牌管理应只允许登录会话,实际 %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAuthRejectsBadCredentials(t *testing.T) {
|
|
svc, _ := newTokenTestAuth(t, auth.TokenScopeRead)
|
|
handler := RequireAuth(svc)(okTokenHandler())
|
|
|
|
cases := map[string]string{
|
|
"未知令牌": "Bearer deadbeef",
|
|
"错误前缀": "Token abc",
|
|
"没有凭据": "",
|
|
}
|
|
for name, header := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
|
if header != "" {
|
|
req.Header.Set("Authorization", header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s 应返回 401,实际 %d", name, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBearerTokenParsing(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Bearer abc": "abc",
|
|
"bearer abc": "abc",
|
|
"Bearer abc": "abc",
|
|
"Basic abc": "",
|
|
"Bearer ": "",
|
|
"": "",
|
|
}
|
|
for header, want := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if header != "" {
|
|
req.Header.Set("Authorization", header)
|
|
}
|
|
if got := bearerToken(req); got != want {
|
|
t.Fatalf("Authorization %q 解析为 %q,期望 %q", header, got, want)
|
|
}
|
|
}
|
|
}
|