- 配置版本: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 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user