CI / verify (push) Canceled after 0s
- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
274 lines
9.9 KiB
Go
274 lines
9.9 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/havline/havline/internal/auth"
|
||
"github.com/havline/havline/internal/db"
|
||
"github.com/havline/havline/internal/settings"
|
||
"github.com/havline/havline/internal/terminal"
|
||
)
|
||
|
||
const (
|
||
testAdminPassword = "admin-password-123"
|
||
)
|
||
|
||
// TestTerminalUnlockStore:票据按时失效、可主动撤销,过期条目会被顺带清理。
|
||
func TestTerminalUnlockStore(t *testing.T) {
|
||
store := newTerminalUnlockStore()
|
||
const testSessionID = "session-abc"
|
||
if _, ok := store.expiresAt(testSessionID); ok {
|
||
t.Fatal("未解锁的会话不应有效")
|
||
}
|
||
expires := store.grant(testSessionID)
|
||
if !expires.After(time.Now()) {
|
||
t.Fatalf("解锁到期时间应当在未来:%v", expires)
|
||
}
|
||
if _, ok := store.expiresAt(testSessionID); !ok {
|
||
t.Fatal("解锁后应当有效")
|
||
}
|
||
store.revoke(testSessionID)
|
||
if _, ok := store.expiresAt(testSessionID); ok {
|
||
t.Fatal("锁定后应当失效")
|
||
}
|
||
|
||
// 手动塞一条过期记录,grant 时应当被清掉
|
||
store.expires["stale"] = time.Now().Add(-time.Minute)
|
||
store.grant("other")
|
||
if _, ok := store.expires["stale"]; ok {
|
||
t.Fatal("过期条目未被清理")
|
||
}
|
||
}
|
||
|
||
// TestTerminalUnlockFlow:未解锁不能建会话 → 密码错误被拒并计数 → 解锁后可建会话 → 可主动锁定。
|
||
func TestTerminalUnlockFlow(t *testing.T) {
|
||
handler, openerCalled, sessionID := newUnlockTestHandler(t)
|
||
|
||
// 1) 未解锁:建会话被拒,并给出稳定错误码
|
||
rec := callTerminal(t, handler.CreateSession, http.MethodPost, `{"server_id":1,"cols":80,"rows":24}`, sessionID)
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("未解锁建会话应当 403,实际 %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if code := errorCode(t, rec); code != "terminal_locked" {
|
||
t.Fatalf("错误码应当是 terminal_locked,实际 %q", code)
|
||
}
|
||
if *openerCalled {
|
||
t.Fatal("未解锁就触发了后端连接")
|
||
}
|
||
|
||
// 2) 密码错误:401,且不发放票据
|
||
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("错误密码应当 401,实际 %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if code := errorCode(t, rec); code != "bad_password" {
|
||
t.Fatalf("错误码应当是 bad_password,实际 %q", code)
|
||
}
|
||
if _, ok := handler.unlocks.expiresAt(sessionID); ok {
|
||
t.Fatal("密码错误却发放了解锁票据")
|
||
}
|
||
|
||
// 3) 密码正确:解锁成功
|
||
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"`+testAdminPassword+`"}`, sessionID)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("正确密码应当 200,实际 %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
var unlocked struct {
|
||
ExpiresAt string `json:"expires_at"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &unlocked); err != nil || unlocked.ExpiresAt == "" {
|
||
t.Fatalf("解锁响应缺少 expires_at:%s", rec.Body.String())
|
||
}
|
||
|
||
// 状态接口能读到剩余时间
|
||
rec = callTerminal(t, handler.UnlockStatus, http.MethodGet, "", sessionID)
|
||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"unlocked":true`) {
|
||
t.Fatalf("解锁状态不符:%d %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
// 4) 已解锁:放行到后端连接(测试用 Opener 固定失败,所以这里是 502 而不是 403)
|
||
rec = callTerminal(t, handler.CreateSession, http.MethodPost, `{"server_id":1,"cols":80,"rows":24}`, sessionID)
|
||
if rec.Code == http.StatusForbidden {
|
||
t.Fatalf("解锁后仍被拦:%s", rec.Body.String())
|
||
}
|
||
if !*openerCalled {
|
||
t.Fatal("解锁后应当尝试连接后端")
|
||
}
|
||
|
||
// 5) 主动锁定
|
||
rec = callTerminal(t, handler.Lock, http.MethodDelete, "", sessionID)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("锁定失败:%d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if _, ok := handler.unlocks.expiresAt(sessionID); ok {
|
||
t.Fatal("锁定后票据仍有效")
|
||
}
|
||
}
|
||
|
||
// TestTerminalUnlockRateLimit:同一 IP 连续失败到阈值后被锁定。
|
||
func TestTerminalUnlockRateLimit(t *testing.T) {
|
||
handler, _, sessionID := newUnlockTestHandler(t)
|
||
for i := 0; i < loginFailureLimit; i++ {
|
||
rec := callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("第 %d 次失败应当 401,实际 %d", i+1, rec.Code)
|
||
}
|
||
}
|
||
rec := callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
|
||
if rec.Code != http.StatusTooManyRequests {
|
||
t.Fatalf("超过阈值应当 429,实际 %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if code := errorCode(t, rec); code != "too_many_attempts" {
|
||
t.Fatalf("错误码应当是 too_many_attempts,实际 %q", code)
|
||
}
|
||
// 锁定期间即使密码正确也不放行
|
||
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"`+testAdminPassword+`"}`, sessionID)
|
||
if rec.Code != http.StatusTooManyRequests {
|
||
t.Fatalf("锁定期间应当继续 429,实际 %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// TestTerminalCommandsAPI:常用命令的新增、列表与删除。
|
||
func TestTerminalCommandsAPI(t *testing.T) {
|
||
handler, _, sessionID := newUnlockTestHandler(t)
|
||
|
||
rec := callTerminal(t, handler.CreateCommand, http.MethodPost, `{"name":"磁盘占用","command":"df -h"}`, sessionID)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("新增命令应当 201,实际 %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
var created terminal.TerminalCommand
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.ID == 0 {
|
||
t.Fatalf("新增响应缺少 ID:%s", rec.Body.String())
|
||
}
|
||
|
||
rec = callTerminal(t, handler.ListCommands, http.MethodGet, "", sessionID)
|
||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "df -h") {
|
||
t.Fatalf("列表未包含新增命令:%d %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
// 空名称应当被拒
|
||
rec = callTerminal(t, handler.CreateCommand, http.MethodPost, `{"name":" ","command":"ls"}`, sessionID)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("空名称应当 400,实际 %d", rec.Code)
|
||
}
|
||
|
||
// 删除要走带路径参数的分支,这里手工塞 PathValue
|
||
if code := deleteCommand(t, handler, sessionID, created.ID); code != http.StatusOK {
|
||
t.Fatalf("删除应当 200,实际 %d", code)
|
||
}
|
||
if code := deleteCommand(t, handler, sessionID, created.ID); code != http.StatusNotFound {
|
||
t.Fatalf("重复删除应当 404,实际 %d", code)
|
||
}
|
||
}
|
||
|
||
// deleteCommand 按路由形态调用删除接口(PathValue 由 ServeMux 注入,测试里手工设置)。
|
||
func deleteCommand(t *testing.T, handler *TerminalHandler, sessionID string, id int64) int {
|
||
t.Helper()
|
||
raw := strconv.FormatInt(id, 10)
|
||
req := httptest.NewRequest(http.MethodDelete, "/api/terminal/commands/"+raw, nil)
|
||
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
|
||
req.SetPathValue("id", raw)
|
||
rec := httptest.NewRecorder()
|
||
SessionMiddleware(nil)(http.HandlerFunc(handler.DeleteCommand)).ServeHTTP(rec, req)
|
||
return rec.Code
|
||
}
|
||
|
||
// TestTokenDeniedPathCoversTerminal:终端不接受 API Token,否则二次认证可以被绕过。
|
||
func TestTokenDeniedPathCoversTerminal(t *testing.T) {
|
||
for _, path := range []string{
|
||
"/api/terminal/targets",
|
||
"/api/terminal/unlock",
|
||
"/api/terminal/sessions",
|
||
} {
|
||
if !tokenDeniedPath(path) {
|
||
t.Fatalf("%s 应当只允许浏览器会话", path)
|
||
}
|
||
}
|
||
if tokenDeniedPath("/api/proxies") {
|
||
t.Fatal("普通接口不应被限制")
|
||
}
|
||
}
|
||
|
||
// newUnlockTestHandler 造一套「终端已开启 + 已初始化管理员」的处理器,并返回一个真实会话 ID。
|
||
func newUnlockTestHandler(t *testing.T) (*TerminalHandler, *bool, string) {
|
||
t.Helper()
|
||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||
if err != nil {
|
||
t.Fatalf("打开数据库失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { conn.Close() })
|
||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||
t.Fatalf("迁移失败:%v", err)
|
||
}
|
||
|
||
ctx := context.Background()
|
||
authSvc := auth.New(conn)
|
||
if err := authSvc.Setup(ctx, "admin", testAdminPassword); err != nil {
|
||
t.Fatalf("初始化管理员失败:%v", err)
|
||
}
|
||
settingsStore := settings.NewStore(conn)
|
||
if err := settingsStore.SetBool(ctx, settings.KeyTerminalEnabled, true); err != nil {
|
||
t.Fatalf("打开终端开关失败:%v", err)
|
||
}
|
||
// 解锁票据绑定真实会话:没有这条会话记录,AdminIDForSession 会先拒绝
|
||
sessionID, err := authSvc.Login(ctx, "admin", testAdminPassword)
|
||
if err != nil {
|
||
t.Fatalf("登录失败:%v", err)
|
||
}
|
||
|
||
openerCalled := false
|
||
manager := terminal.NewManager(
|
||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
func(context.Context, int64, int, int) (terminal.Terminal, string, error) {
|
||
openerCalled = true
|
||
return nil, "", errors.New("测试环境不建立真实连接")
|
||
},
|
||
terminal.Options{},
|
||
)
|
||
|
||
handler := NewTerminalHandler(TerminalHandlerOptions{
|
||
Auth: authSvc,
|
||
Settings: settingsStore,
|
||
Manager: manager,
|
||
Unlocks: newTerminalUnlockStore(),
|
||
Commands: terminal.NewCommandStore(conn),
|
||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
})
|
||
return handler, &openerCalled, sessionID
|
||
}
|
||
|
||
// callTerminal 带上会话 Cookie 调用处理器;SessionMiddleware 只读 Cookie,这里复用它注入会话。
|
||
func callTerminal(t *testing.T, next http.HandlerFunc, method, body, sessionID string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var reader io.Reader
|
||
if body != "" {
|
||
reader = strings.NewReader(body)
|
||
}
|
||
req := httptest.NewRequest(method, "/api/terminal", reader)
|
||
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
|
||
rec := httptest.NewRecorder()
|
||
SessionMiddleware(nil)(next).ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
|
||
// errorCode 读出响应体里的稳定错误码。
|
||
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||
t.Helper()
|
||
var body ErrorBody
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("解析错误响应失败:%v", err)
|
||
}
|
||
return body.Code
|
||
}
|