mirror of
https://github.com/Kori1c/ecs-controller.git
synced 2026-11-16 05:37:22 +08:00
1239 lines
43 KiB
Go
1239 lines
43 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Kori1c/ecs-controller/internal/app"
|
|
"github.com/Kori1c/ecs-controller/internal/cloud"
|
|
"github.com/Kori1c/ecs-controller/internal/store"
|
|
)
|
|
|
|
func TestTemplateAndStaticAssetsUseCompressionAndCacheHeaders(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
root := t.TempDir()
|
|
templatePath := filepath.Join(root, "template.html")
|
|
if err := os.WriteFile(templatePath, []byte("<!doctype html><link href=\"static/app.css?v=__ASSET_VERSION__\">"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "static"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "static", "app.css"), []byte("body { color: #123; }"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handler := New(st, t.TempDir(), templatePath, "setup-token", nil).Handler()
|
|
for _, test := range []struct {
|
|
path string
|
|
cache string
|
|
wantBody string
|
|
noToken bool
|
|
}{
|
|
{path: "/", cache: "no-cache", wantBody: "static/app.css?v=", noToken: true},
|
|
{path: "/static/app.css", cache: "public, max-age=31536000, immutable", wantBody: "body { color: #123; }"},
|
|
{path: "/healthz", cache: "", wantBody: "\"ok\":true"},
|
|
} {
|
|
t.Run(test.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
response := recorder.Result()
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
t.Fatalf("status=%d", response.StatusCode)
|
|
}
|
|
if got := response.Header.Get("Content-Encoding"); got != "gzip" {
|
|
t.Fatalf("Content-Encoding=%q, want gzip", got)
|
|
}
|
|
if test.cache != "" && response.Header.Get("Cache-Control") != test.cache {
|
|
t.Fatalf("Cache-Control=%q, want %q", response.Header.Get("Cache-Control"), test.cache)
|
|
}
|
|
reader, err := gzip.NewReader(response.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reader.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), test.wantBody) {
|
|
t.Fatalf("response body %q does not contain %q", body, test.wantBody)
|
|
}
|
|
if test.noToken && strings.Contains(string(body), "__ASSET_VERSION__") {
|
|
t.Fatal("template did not replace asset version token")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVersionTagsForCommitsUsesOnlySemanticReleaseTags(t *testing.T) {
|
|
firstCommit := strings.Repeat("a", 40)
|
|
secondCommit := strings.Repeat("b", 40)
|
|
invalidCommit := "not-a-commit"
|
|
firstTag := updateVersionTag{Name: "v1.6.40"}
|
|
firstTag.Commit.SHA = firstCommit
|
|
duplicateTag := updateVersionTag{Name: "v9.9.9"}
|
|
duplicateTag.Commit.SHA = firstCommit
|
|
nonVersionTag := updateVersionTag{Name: "latest"}
|
|
nonVersionTag.Commit.SHA = secondCommit
|
|
invalidTag := updateVersionTag{Name: "v1.6.41"}
|
|
invalidTag.Commit.SHA = invalidCommit
|
|
|
|
versions := versionTagsForCommits([]updateVersionTag{firstTag, duplicateTag, nonVersionTag, invalidTag})
|
|
if got := versions[firstCommit]; got != "v1.6.40" {
|
|
t.Fatalf("version=%q, want v1.6.40", got)
|
|
}
|
|
if len(versions) != 1 {
|
|
t.Fatalf("unexpected version tags: %#v", versions)
|
|
}
|
|
}
|
|
|
|
func TestCheckForUpdateUsesGitHubReleaseTagsAsDisplayVersions(t *testing.T) {
|
|
currentCommit := strings.Repeat("a", 40)
|
|
latestCommit := strings.Repeat("b", 40)
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/repos/Kori1c/ecs-controller/commits/main":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"sha": latestCommit,
|
|
"commit": map[string]any{"message": "release build"},
|
|
"html_url": "https://github.com/Kori1c/ecs-controller/commit/" + latestCommit,
|
|
})
|
|
case "/repos/Kori1c/ecs-controller/tags":
|
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
|
{"name": "v1.6.40", "commit": map[string]any{"sha": latestCommit}},
|
|
{"name": "v1.6.39", "commit": map[string]any{"sha": currentCommit}},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer api.Close()
|
|
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.githubAPIBase = api.URL
|
|
srv.imageChecker = func(context.Context, string) (bool, string, error) {
|
|
return true, "sha256:test", nil
|
|
}
|
|
|
|
previousCommit, previousVersion := app.Commit, app.Version
|
|
app.Commit, app.Version = currentCommit, "sha-"+shortCommit(currentCommit)
|
|
defer func() { app.Commit, app.Version = previousCommit, previousVersion }()
|
|
|
|
recorder := httptest.NewRecorder()
|
|
srv.checkForUpdate(recorder, httptest.NewRequest(http.MethodGet, "/index.php?action=check_update", nil))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var result map[string]any
|
|
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := result["current_version"]; got != "v1.6.39" {
|
|
t.Fatalf("current_version=%q, want v1.6.39", got)
|
|
}
|
|
latest, ok := result["latest"].(map[string]any)
|
|
if !ok || latest["version"] != "v1.6.40" {
|
|
t.Fatalf("latest=%#v, want v1.6.40", result["latest"])
|
|
}
|
|
}
|
|
|
|
func TestSetupLoginAndCSRF(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
dataDir := t.TempDir()
|
|
templatePath := filepath.Join(t.TempDir(), "template.html")
|
|
if err := os.WriteFile(templatePath, []byte("<!doctype html>ok"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, dataDir, templatePath, "setup-token", nil)
|
|
httpSrv := httptest.NewServer(srv.Handler())
|
|
defer httpSrv.Close()
|
|
jar, _ := cookiejar.New(nil)
|
|
client := &http.Client{Jar: jar}
|
|
for _, path := range []string{"/index.php", "/index.php?action=view"} {
|
|
resp, err := client.Get(httpSrv.URL + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
t.Fatalf("legacy page route %s status: %d", path, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
resp := postJSON(t, client, httpSrv.URL+"/index.php?action=setup", map[string]any{"admin_password": "correct horse battery staple", "traffic_threshold": 95}, map[string]string{"X-Setup-Token": "wrong"})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("wrong setup token status: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=setup", map[string]any{"admin_password": "correct horse battery staple", "traffic_threshold": 95}, map[string]string{"X-Setup-Token": "setup-token"})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("setup status: %d", resp.StatusCode)
|
|
}
|
|
csrf := resp.Header.Get("X-CSRF-Token")
|
|
resp.Body.Close()
|
|
if csrf == "" {
|
|
t.Fatal("setup did not return csrf token")
|
|
}
|
|
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=save_config", map[string]any{"traffic_threshold": 90, "api_interval": 600}, nil)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("missing csrf status: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=save_config", map[string]any{"traffic_threshold": 90, "api_interval": 600}, map[string]string{"X-CSRF-Token": csrf})
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("valid csrf status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
resp, err = client.Get(httpSrv.URL + "/index.php?action=check_login")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var check map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&check)
|
|
resp.Body.Close()
|
|
if check["logged_in"] != true {
|
|
t.Fatal("session was not established")
|
|
}
|
|
if got := resp.Header.Get("X-CSRF-Token"); got != csrf {
|
|
t.Fatalf("check_login did not restore csrf token: got %q want %q", got, csrf)
|
|
}
|
|
|
|
var upload bytes.Buffer
|
|
writer := multipart.NewWriter(&upload)
|
|
part, err := writer.CreateFormFile("logo", "logo.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Minimal 1x1 PNG. The endpoint validates the detected MIME type before saving.
|
|
if _, err := part.Write([]byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, httpSrv.URL+"/index.php?action=upload_logo", &upload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.Header.Set("X-CSRF-Token", csrf)
|
|
resp, err = client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("logo upload status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
resp.Body.Close()
|
|
if _, err := os.Stat(dataDir + "/brand-logo.png"); err != nil {
|
|
t.Fatalf("logo was not saved: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAdminPasswordCanBeChangedFromConfig(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
httpSrv := httptest.NewServer(srv.Handler())
|
|
defer httpSrv.Close()
|
|
jar, _ := cookiejar.New(nil)
|
|
client := &http.Client{Jar: jar}
|
|
|
|
resp := postJSON(t, client, httpSrv.URL+"/index.php?action=setup", map[string]any{"admin_password": "start1"}, map[string]string{"X-Setup-Token": "setup-token"})
|
|
csrf := resp.Header.Get("X-CSRF-Token")
|
|
resp.Body.Close()
|
|
if csrf == "" {
|
|
t.Fatal("setup did not return csrf token")
|
|
}
|
|
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=save_config", map[string]any{"admin_password": "six123", "traffic_threshold": 95, "api_interval": 600}, map[string]string{"X-CSRF-Token": csrf})
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("password change status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
resp.Body.Close()
|
|
if !st.CheckAdminPassword("six123") || st.CheckAdminPassword("start1") {
|
|
t.Fatal("admin password was not changed")
|
|
}
|
|
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=save_config", map[string]any{"admin_password": "********", "traffic_threshold": 95, "api_interval": 600}, map[string]string{"X-CSRF-Token": csrf})
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("masked password save status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
resp.Body.Close()
|
|
if !st.CheckAdminPassword("six123") {
|
|
t.Fatal("masked password overwrote the current password")
|
|
}
|
|
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=save_config", map[string]any{"admin_password": "12345", "traffic_threshold": 95, "api_interval": 600}, map[string]string{"X-CSRF-Token": csrf})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("short password status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestPasswordLoginCanBeDisabledAfterPasskeyIsRegistered(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SetAdminPassword("start1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
httpSrv := httptest.NewServer(srv.Handler())
|
|
defer httpSrv.Close()
|
|
client := &http.Client{}
|
|
|
|
if err := srv.saveConfig(map[string]any{"password_login_enabled": false}); err == nil {
|
|
t.Fatal("password login was disabled without a Passkey")
|
|
}
|
|
if got := st.GetSetting("password_login_enabled", ""); got != "" && got != "1" {
|
|
t.Fatalf("failed disable changed the setting: %q", got)
|
|
}
|
|
|
|
if err := st.SavePasskeyCredential("test-credential", `{"id":"test-credential"}`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.saveConfig(map[string]any{"password_login_enabled": false}); err != nil {
|
|
t.Fatalf("disable password login: %v", err)
|
|
}
|
|
if got := st.GetSetting("password_login_enabled", ""); got != "0" {
|
|
t.Fatalf("password login setting=%q, want 0", got)
|
|
}
|
|
|
|
resp := postJSON(t, client, httpSrv.URL+"/index.php?action=login", map[string]any{"password": "start1"}, nil)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("disabled password login status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
var disabledResponse map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&disabledResponse); err != nil {
|
|
resp.Body.Close()
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
message, _ := disabledResponse["message"].(string)
|
|
if !strings.Contains(message, "Passkey") {
|
|
t.Fatalf("disabled password login message=%#v", disabledResponse)
|
|
}
|
|
|
|
if err := srv.saveConfig(map[string]any{"password_login_enabled": true}); err != nil {
|
|
t.Fatalf("re-enable password login: %v", err)
|
|
}
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=login", map[string]any{"password": "start1"}, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("re-enabled password login status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestPasskeyWebAuthnUsesForwardedBrowserOrigin(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "https://internal:8080/index.php?action=passkey_login_start", nil)
|
|
req.Host = "internal:8080"
|
|
req.Header.Set("X-Forwarded-Host", "console.example.com")
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
wa, err := srv.passkeyWebAuthn(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wa.Config.RPID != "console.example.com" {
|
|
t.Fatalf("RP ID=%q, want console.example.com", wa.Config.RPID)
|
|
}
|
|
if len(wa.Config.RPOrigins) != 1 || wa.Config.RPOrigins[0] != "https://console.example.com" {
|
|
t.Fatalf("RP origins=%#v, want https://console.example.com", wa.Config.RPOrigins)
|
|
}
|
|
}
|
|
|
|
func TestNotificationSwitchesPersistAndReadBack(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
config := map[string]any{
|
|
"traffic_threshold": 95,
|
|
"api_interval": 600,
|
|
"Notification": map[string]any{
|
|
"email_enabled": false,
|
|
"email": "ops@example.com",
|
|
"daily_enabled": true,
|
|
"daily_time": "01:30",
|
|
"telegram": map[string]any{
|
|
"enabled": true,
|
|
"confirm_ttl": 60,
|
|
},
|
|
"webhook": map[string]any{
|
|
"enabled": true,
|
|
},
|
|
},
|
|
}
|
|
if err := srv.saveConfig(config); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
settings := st.Settings()
|
|
if settings["notify_email_enabled"] != "0" || settings["notify_tg_enabled"] != "1" || settings["notify_wh_enabled"] != "1" {
|
|
t.Fatalf("notification switches were not normalized: email=%q telegram=%q webhook=%q", settings["notify_email_enabled"], settings["notify_tg_enabled"], settings["notify_wh_enabled"])
|
|
}
|
|
if settings["notify_daily_enabled"] != "1" || settings["notify_daily_time"] != "01:30" {
|
|
t.Fatalf("daily summary settings were not normalized: enabled=%q time=%q", settings["notify_daily_enabled"], settings["notify_daily_time"])
|
|
}
|
|
readBack := notificationSettings(settings)
|
|
telegram := readBack["telegram"].(map[string]any)
|
|
webhook := readBack["webhook"].(map[string]any)
|
|
if readBack["email_enabled"] != false || readBack["daily_enabled"] != true || readBack["daily_time"] != "01:30" || telegram["enabled"] != true || webhook["enabled"] != true {
|
|
t.Fatalf("notification switches did not read back: %#v", readBack)
|
|
}
|
|
|
|
legacy := notificationSettings(map[string]string{
|
|
"notify_email_enabled": "false",
|
|
"notify_tg_enabled": "true",
|
|
"notify_wh_enabled": "false",
|
|
})
|
|
legacyTelegram := legacy["telegram"].(map[string]any)
|
|
legacyWebhook := legacy["webhook"].(map[string]any)
|
|
if legacy["email_enabled"] != false || legacyTelegram["enabled"] != true || legacyWebhook["enabled"] != false {
|
|
t.Fatalf("legacy notification switches were not interpreted: %#v", legacy)
|
|
}
|
|
}
|
|
|
|
func TestSaveConfigRejectsDuplicateAccountRegion(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
err = srv.saveConfig(map[string]any{
|
|
"Accounts": []any{
|
|
map[string]any{"AccessKeyId": "ak", "AccessKeySecret": "sk", "regionId": "cn-hongkong"},
|
|
map[string]any{"AccessKeyId": "ak", "AccessKeySecret": "sk", "regionId": "CN-HONGKONG"},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("saveConfig accepted duplicate account and region")
|
|
}
|
|
if got := st.GetSetting("traffic_threshold", ""); got != "" {
|
|
t.Fatalf("duplicate configuration partially changed settings: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSaveConfigSyncsGroupWithGeneratedKey(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client {
|
|
return &fakeSyncClient{instances: []cloud.Instance{{ID: "i-generated", Status: "Running"}}}
|
|
}
|
|
if err := srv.saveConfig(map[string]any{
|
|
"Accounts": []any{map[string]any{
|
|
"AccessKeyId": "ak",
|
|
"AccessKeySecret": "sk",
|
|
"regionId": "cn-test",
|
|
"maxTraffic": 200,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
groups, err := st.LoadGroups()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(groups) != 1 || groups[0].GroupKey == "" {
|
|
t.Fatalf("generated group key was not persisted: %#v", groups)
|
|
}
|
|
for _, log := range st.Logs("", 20) {
|
|
if log["message"] == "账号组同步失败: 账号组不存在" {
|
|
t.Fatalf("sync used the pre-save empty group key: %#v", log)
|
|
}
|
|
}
|
|
accounts, err := st.LoadAccounts(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(accounts) != 1 || accounts[0].GroupKey != groups[0].GroupKey {
|
|
t.Fatalf("synced account was not linked to the generated group: %#v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestTestAccountResolvesMaskedSecret(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-hongkong"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
secret, err := srv.resolveMaskedAccountSecret(map[string]any{"groupKey": "group-1"}, "ak", "cn-hongkong")
|
|
if err != nil || secret != "sk" {
|
|
t.Fatalf("masked account secret was not restored: secret=%q err=%v", secret, err)
|
|
}
|
|
if _, err := srv.resolveMaskedAccountSecret(map[string]any{"groupKey": "group-1"}, "other-ak", "cn-hongkong"); err == nil {
|
|
t.Fatal("secret was restored for a different access key")
|
|
}
|
|
}
|
|
|
|
func TestTaskResponsePreservesFrontendCredentialFields(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.CreateTask("task-1", "preview-1", "group-1", "cn-test", "ecs.test", map[string]any{"loginPassword": "Password123!"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicTask, err := st.GetTask("task-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if publicTask.LoginPassword != "" {
|
|
t.Fatal("queued task exposed its password")
|
|
}
|
|
task, err := st.GetTaskForWorker("task-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response := taskResponse(task)
|
|
if response["loginPassword"] != "Password123!" {
|
|
t.Fatalf("camelCase password missing: %#v", response)
|
|
}
|
|
if response["task_id"] != "task-1" || response["taskId"] != "task-1" {
|
|
t.Fatalf("task aliases missing: %#v", response)
|
|
}
|
|
if err := st.UpdateTask("task-1", map[string]any{"status": "success"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := st.ConsumeTaskPassword("task-1")
|
|
if err != nil || first.LoginPassword != "Password123!" {
|
|
t.Fatalf("first credential read: task=%+v err=%v", first, err)
|
|
}
|
|
second, err := st.ConsumeTaskPassword("task-1")
|
|
if err != nil || second.LoginPassword != "" {
|
|
t.Fatalf("credential was not one-time: task=%+v err=%v", second, err)
|
|
}
|
|
}
|
|
|
|
func TestConfigDoesNotDoubleCountCDTAggregateAcrossInstances(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", MaxTraffic: 200}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, instanceID := range []string{"i-1", "i-2"} {
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: instanceID, TrafficUsed: 12.5, TrafficAPIStatus: "fallback_cdt", TrafficAPIMessage: "CDT aggregate", UpdatedAt: 100}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
recorder := httptest.NewRecorder()
|
|
srv.config(recorder)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("config status: %d", recorder.Code)
|
|
}
|
|
var response map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, ok := response["Accounts"].([]any)
|
|
if !ok || len(items) != 1 {
|
|
t.Fatalf("unexpected account groups: %#v", response["Accounts"])
|
|
}
|
|
item := items[0].(map[string]any)
|
|
if item["usageUsed"] != 12.5 {
|
|
t.Fatalf("CDT aggregate was double-counted: %#v", item["usageUsed"])
|
|
}
|
|
}
|
|
|
|
type fakePreflightClient struct{ cloud.Client }
|
|
|
|
func (f *fakePreflightClient) DescribeInstanceType(context.Context, string, string) (map[string]any, error) {
|
|
return map[string]any{"InstanceTypeId": "ecs.test", "CpuArchitecture": "X86"}, nil
|
|
}
|
|
|
|
func (f *fakePreflightClient) DescribeAvailableZones(context.Context, string, string, string) ([]map[string]any, error) {
|
|
return []map[string]any{{"ZoneId": "zone-a", "Status": "Available"}}, nil
|
|
}
|
|
|
|
func (f *fakePreflightClient) DescribeImagesForArchitecture(context.Context, string, string, string) ([]map[string]any, error) {
|
|
return []map[string]any{{"ImageId": "img-x86", "OSName": "Windows Server 2022"}}, nil
|
|
}
|
|
|
|
func (f *fakePreflightClient) GetSystemDiskOptions(context.Context, string, string, string) ([]map[string]any, error) {
|
|
return []map[string]any{{"value": "cloud_essd", "label": "ESSD", "min": 40, "max": 100, "unit": "GB"}}, nil
|
|
}
|
|
|
|
type fakeRefreshClient struct {
|
|
cloud.Client
|
|
instance cloud.Instance
|
|
monthlyBytes float64
|
|
monthlyPoints int
|
|
monthlyErr error
|
|
monthlyCalls int
|
|
deltaBytes float64
|
|
deltaLastMS int64
|
|
deltaPoints int
|
|
deltaErr error
|
|
deltaCalls int
|
|
}
|
|
|
|
func (f *fakeRefreshClient) DescribeInstance(context.Context, string, string) (*cloud.Instance, error) {
|
|
instance := f.instance
|
|
return &instance, nil
|
|
}
|
|
|
|
func (f *fakeRefreshClient) GetInstanceMonthlyTraffic(context.Context, string, string, string, int64, int64) (float64, int, error) {
|
|
f.monthlyCalls++
|
|
return f.monthlyBytes, f.monthlyPoints, f.monthlyErr
|
|
}
|
|
|
|
func (f *fakeRefreshClient) GetOutboundTrafficDelta(context.Context, string, string, string, int64, int64) (float64, int64, int, string, error) {
|
|
f.deltaCalls++
|
|
return f.deltaBytes, f.deltaLastMS, f.deltaPoints, "InternetOutRate", f.deltaErr
|
|
}
|
|
|
|
func TestRefreshAccountUsesMonthlyCMSValueBeforeDelta(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.UpsertAccount(app.Account{
|
|
ID: 1,
|
|
AccessKeyID: "ak",
|
|
AccessKeySecret: "sk",
|
|
RegionID: "cn-hongkong",
|
|
InstanceID: "i-1",
|
|
PublicIP: "203.0.113.10",
|
|
TrafficUsed: 0.0008,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
month := now.Format("2006-01")
|
|
if _, err := st.SetInstanceTraffic(1, "i-1", month, 0.0008*1024*1024*1024, now.Add(-5*time.Minute).UnixMilli()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fake := &fakeRefreshClient{
|
|
instance: cloud.Instance{ID: "i-1", Status: "Running", PublicIP: "203.0.113.10"},
|
|
monthlyBytes: 8 * 1024 * 1024 * 1024,
|
|
monthlyPoints: 1,
|
|
deltaBytes: 10 * 1024 * 1024,
|
|
deltaLastMS: now.UnixMilli(),
|
|
deltaPoints: 1,
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client { return fake }
|
|
recorder := httptest.NewRecorder()
|
|
srv.refreshAccount(recorder, map[string]any{"id": 1})
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("refresh status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if fake.monthlyCalls != 1 || fake.deltaCalls != 0 {
|
|
t.Fatalf("unexpected CMS calls: monthly=%d delta=%d", fake.monthlyCalls, fake.deltaCalls)
|
|
}
|
|
|
|
sample, err := st.InstanceTrafficUsage(1, "i-1", month)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sample.TrafficBytes != 8*1024*1024*1024 {
|
|
t.Fatalf("monthly CMS value was not stored absolutely: got=%v", sample.TrafficBytes)
|
|
}
|
|
account, err := st.Account(1, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if account.TrafficUsed != 8 || account.TrafficBillingMonth != month || account.TrafficAPIStatus != "ok" {
|
|
t.Fatalf("account traffic state=%+v", account)
|
|
}
|
|
}
|
|
|
|
func TestRefreshAccountFallsBackToDeltaWhenMonthlyCMSHasNoPoints(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.UpsertAccount(app.Account{ID: 1, AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", InstanceID: "i-1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
month := now.Format("2006-01")
|
|
fake := &fakeRefreshClient{
|
|
instance: cloud.Instance{ID: "i-1", Status: "Running"},
|
|
monthlyPoints: 0,
|
|
deltaBytes: 12 * 1024 * 1024,
|
|
deltaLastMS: now.UnixMilli(),
|
|
deltaPoints: 1,
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client { return fake }
|
|
recorder := httptest.NewRecorder()
|
|
srv.refreshAccount(recorder, map[string]any{"id": 1})
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("refresh status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if fake.monthlyCalls != 1 || fake.deltaCalls != 1 {
|
|
t.Fatalf("monthly no-data did not fall back to delta: monthly=%d delta=%d", fake.monthlyCalls, fake.deltaCalls)
|
|
}
|
|
sample, err := st.InstanceTrafficUsage(1, "i-1", month)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sample.TrafficBytes != 12*1024*1024 {
|
|
t.Fatalf("delta fallback was not stored: got=%v", sample.TrafficBytes)
|
|
}
|
|
}
|
|
|
|
type fakeSyncClient struct {
|
|
cloud.Client
|
|
instances []cloud.Instance
|
|
describeErr error
|
|
publicNetworks map[string]cloud.InstancePublicNetwork
|
|
publicNetworkErr error
|
|
}
|
|
|
|
type fakeMissingInstanceClient struct{ cloud.Client }
|
|
|
|
func (f *fakeMissingInstanceClient) DescribeInstance(context.Context, string, string) (*cloud.Instance, error) {
|
|
return nil, &cloud.APIError{Code: "InvalidInstanceId.NotFound", Message: "instance not found"}
|
|
}
|
|
|
|
func (f *fakeSyncClient) DescribeInstances(context.Context, string) ([]cloud.Instance, error) {
|
|
return f.instances, f.describeErr
|
|
}
|
|
|
|
func (f *fakeSyncClient) DescribeInstance(_ context.Context, _ string, id string) (*cloud.Instance, error) {
|
|
for _, instance := range f.instances {
|
|
if instance.ID == id {
|
|
copy := instance
|
|
return ©, nil
|
|
}
|
|
}
|
|
if f.describeErr != nil {
|
|
return nil, f.describeErr
|
|
}
|
|
return nil, &cloud.APIError{Code: "InvalidInstanceId.NotFound", HTTPStatus: 404}
|
|
}
|
|
|
|
func (f *fakeSyncClient) DescribeInstancePublicNetworks(context.Context, string, []string) (map[string]cloud.InstancePublicNetwork, error) {
|
|
return f.publicNetworks, f.publicNetworkErr
|
|
}
|
|
|
|
func TestRefreshAccountPurgesProviderMissingInstance(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: "i-missing", InstanceStatus: "Running"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client { return &fakeMissingInstanceClient{} }
|
|
recorder := httptest.NewRecorder()
|
|
srv.refreshAccount(recorder, map[string]any{"accountId": 1})
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if account, err := st.Account(1, true); err == nil || account != nil {
|
|
t.Fatalf("missing account remained after refresh: %#v err=%v", account, err)
|
|
}
|
|
job, err := st.ClaimJob(time.Minute)
|
|
if err != nil || job != nil {
|
|
t.Fatalf("refresh queued a cloud deletion: job=%#v err=%v", job, err)
|
|
}
|
|
}
|
|
|
|
type fakeBillingDetailsClient struct {
|
|
cloud.Client
|
|
details []cloud.BillingDetail
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeBillingDetailsClient) GetBillingDetails(context.Context, string, string, string) ([]cloud.BillingDetail, error) {
|
|
f.calls++
|
|
return f.details, nil
|
|
}
|
|
|
|
func TestBillDetailsFiltersRecentItemsAndCachesResult(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SetSetting("enable_billing", "1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-billing", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", SiteType: "china"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{ID: 7, AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", SiteType: "china", GroupKey: "group-billing", InstanceID: "i-1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
now := time.Now()
|
|
fake := &fakeBillingDetailsClient{details: []cloud.BillingDetail{
|
|
{Date: now.Format("2006-01-02"), ProductName: "云服务器 ECS", Amount: 1.25, Currency: "CNY"},
|
|
{Date: now.AddDate(0, 0, -20).Format("2006-01-02"), ProductName: "云盘", Amount: 0.10, Currency: "CNY"},
|
|
}}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client { return fake }
|
|
body := strings.NewReader(`{"group_key":"group-billing","days":1}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/index.php?action=get_bill_details", body)
|
|
recorder := httptest.NewRecorder()
|
|
srv.billDetails(recorder, req, map[string]any{"group_key": "group-billing", "days": 1})
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("bill details status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Data struct {
|
|
Items []cloud.BillingDetail `json:"items"`
|
|
Total float64 `json:"total"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(response.Data.Items) != 1 || response.Data.Items[0].ProductName != "云服务器 ECS" || response.Data.Total != 1.25 {
|
|
t.Fatalf("unexpected filtered details: %#v", response.Data)
|
|
}
|
|
|
|
recorder = httptest.NewRecorder()
|
|
srv.billDetails(recorder, req, map[string]any{"group_key": "group-billing", "days": 1})
|
|
if recorder.Code != http.StatusOK || fake.calls != 1 {
|
|
t.Fatalf("cached bill details status=%d calls=%d", recorder.Code, fake.calls)
|
|
}
|
|
}
|
|
|
|
func TestBillingDatesIncludesPreviousMonth(t *testing.T) {
|
|
dates := billingDates("2026-07-27", "2026-08-02")
|
|
if len(dates) != 7 || dates[0] != "2026-07-27" || dates[6] != "2026-08-02" {
|
|
t.Fatalf("unexpected billing dates: %#v", dates)
|
|
}
|
|
}
|
|
|
|
func TestEnrichBillingDetailsAddsCurrentResourceWithoutChangingBillValues(t *testing.T) {
|
|
items := []cloud.BillingDetail{{
|
|
InstanceID: "eip-1",
|
|
Usage: 22,
|
|
Amount: 0.01,
|
|
}}
|
|
enrichBillingDetails(items, map[string]cloud.BillingResource{
|
|
"eip-1": {
|
|
InstanceID: "i-1",
|
|
EIP: &cloud.BillingEIP{
|
|
AllocationID: "eip-1",
|
|
Status: "InUse",
|
|
Bandwidth: 200,
|
|
Count: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
if items[0].CurrentResource == nil || items[0].CurrentResource.EIP == nil || items[0].CurrentResource.EIP.Bandwidth != 200 {
|
|
t.Fatalf("current resource not attached: %#v", items[0])
|
|
}
|
|
if items[0].Usage != 22 || items[0].Amount != 0.01 {
|
|
t.Fatalf("billing values changed during enrichment: %#v", items[0])
|
|
}
|
|
}
|
|
|
|
func TestSyncGroupPreservesReleaseAndPurgesMissingInstances(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", MaxTraffic: 200}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: "i-release", InstanceStatus: "Releasing"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: "i-missing", InstanceStatus: "Stopped"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client {
|
|
return &fakeSyncClient{instances: []cloud.Instance{{ID: "i-release", Status: "Running", PublicIP: "203.0.113.20"}}}
|
|
}
|
|
count, err := srv.syncGroup("group-1")
|
|
if err != nil || count != 1 {
|
|
t.Fatalf("sync result: %d %v", count, err)
|
|
}
|
|
accounts, err := st.LoadAccounts(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var release *app.Account
|
|
for i := range accounts {
|
|
switch accounts[i].InstanceID {
|
|
case "i-release":
|
|
release = &accounts[i]
|
|
}
|
|
}
|
|
if release == nil || release.InstanceStatus != "Releasing" {
|
|
t.Fatalf("release state was resurrected: %#v", release)
|
|
}
|
|
if _, err := st.Account(2, true); err == nil {
|
|
t.Fatal("missing instance remained in the database")
|
|
}
|
|
job, err := st.ClaimJob(time.Minute)
|
|
if err != nil || job != nil {
|
|
t.Fatalf("external deletion unexpectedly queued a cloud release job: %#v %v", job, err)
|
|
}
|
|
}
|
|
|
|
func TestSyncGroupRemovesCloudMissingReleaseFailedRecords(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", MaxTraffic: 200}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: "i-old", PublicIP: "203.0.113.10", InstanceStatus: "ReleaseFailed"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client {
|
|
return &fakeSyncClient{instances: []cloud.Instance{{ID: "i-current", Status: "Running", PublicIP: "203.0.113.10"}}}
|
|
}
|
|
if count, err := srv.syncGroup("group-1"); err != nil || count != 1 {
|
|
t.Fatalf("sync result: count=%d err=%v", count, err)
|
|
}
|
|
visible, err := st.LoadAccounts(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(visible) != 1 || visible[0].InstanceID != "i-current" {
|
|
t.Fatalf("unexpected visible accounts: %#v", visible)
|
|
}
|
|
all, err := st.LoadAccounts(true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, account := range all {
|
|
if account.InstanceID == "i-old" {
|
|
t.Fatalf("release-failed orphan remained in the database: %#v", account)
|
|
}
|
|
}
|
|
if job, err := st.ClaimJob(time.Second); err != nil || job != nil {
|
|
t.Fatalf("orphan cleanup unexpectedly queued another release job: job=%#v err=%v", job, err)
|
|
}
|
|
}
|
|
|
|
func TestSyncGroupKeepsReleaseFailedRecordWhenCloudSyncFails(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", MaxTraffic: 200}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: "i-old", InstanceStatus: "ReleaseFailed"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client {
|
|
return &fakeSyncClient{describeErr: fmt.Errorf("temporary DescribeInstances failure")}
|
|
}
|
|
if _, err := srv.syncGroup("group-1"); err == nil {
|
|
t.Fatal("sync unexpectedly succeeded")
|
|
}
|
|
account, err := st.Account(1, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if account.InstanceStatus != "ReleaseFailed" {
|
|
t.Fatalf("failed cloud sync changed local release state: %#v", account)
|
|
}
|
|
}
|
|
|
|
func TestSyncGroupKeepsReleaseFailedRecordWhenInstanceStillExists(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", MaxTraffic: 200}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", GroupKey: "group-1", InstanceID: "i-existing", InstanceStatus: "ReleaseFailed"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client {
|
|
return &fakeSyncClient{instances: []cloud.Instance{{ID: "i-existing", Status: "Running"}}}
|
|
}
|
|
if count, err := srv.syncGroup("group-1"); err != nil || count != 1 {
|
|
t.Fatalf("sync result: count=%d err=%v", count, err)
|
|
}
|
|
account, err := st.Account(1, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if account.InstanceID != "i-existing" || account.InstanceStatus != "Running" {
|
|
t.Fatalf("existing cloud instance was not retained and refreshed: %#v", account)
|
|
}
|
|
}
|
|
|
|
func TestSyncGroupRefreshesBoundEIPBandwidth(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
group := app.AccountGroup{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-hongkong", MaxTraffic: 200}
|
|
if err := st.SaveGroups([]app.AccountGroup{group}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpsertAccount(app.Account{AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-hongkong", GroupKey: "group-1", InstanceID: "i-1", InternetBandwidth: 10, PublicIPMode: "ecs_public_ip"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client {
|
|
return &fakeSyncClient{
|
|
instances: []cloud.Instance{{ID: "i-1", Status: "Running", PublicIP: "203.0.113.20"}},
|
|
publicNetworks: map[string]cloud.InstancePublicNetwork{
|
|
"i-1": {AllocationID: "eip-1", Address: "203.0.113.20", Bandwidth: 200},
|
|
},
|
|
}
|
|
}
|
|
if count, err := srv.syncGroup("group-1"); err != nil || count != 1 {
|
|
t.Fatalf("sync result: count=%d err=%v", count, err)
|
|
}
|
|
accounts, err := st.LoadAccounts(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(accounts) != 1 {
|
|
t.Fatalf("accounts=%#v", accounts)
|
|
}
|
|
account := accounts[0]
|
|
if account.InternetBandwidth != 200 || account.PublicIPMode != "eip" || account.EIPAllocationID != "eip-1" || account.PublicIP != "203.0.113.20" {
|
|
t.Fatalf("EIP network was not refreshed: %#v", account)
|
|
}
|
|
if account.EIPManaged {
|
|
t.Fatal("externally discovered EIP was incorrectly marked controller-managed")
|
|
}
|
|
}
|
|
|
|
func TestPreviewUsesDynamicArchitectureZoneDiskAndWindowsPort(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.SaveGroups([]app.AccountGroup{{GroupKey: "group-1", AccessKeyID: "ak", AccessKeySecret: "sk", RegionID: "cn-test", Remark: "test"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.CloudFactory = func(app.Account) cloud.Client { return &fakePreflightClient{} }
|
|
httpSrv := httptest.NewServer(srv.Handler())
|
|
defer httpSrv.Close()
|
|
jar, _ := cookiejar.New(nil)
|
|
client := &http.Client{Jar: jar}
|
|
resp := postJSON(t, client, httpSrv.URL+"/index.php?action=setup", map[string]any{"admin_password": "correct horse battery staple"}, map[string]string{"X-Setup-Token": "setup-token"})
|
|
csrf := resp.Header.Get("X-CSRF-Token")
|
|
resp.Body.Close()
|
|
if csrf == "" {
|
|
t.Fatal("setup did not return csrf token")
|
|
}
|
|
resp = postJSON(t, client, httpSrv.URL+"/index.php?action=preview_ecs_create", map[string]any{
|
|
"accountGroupKey": "group-1", "instanceType": "ecs.test", "osKey": "windows_2022", "zoneId": "zone-a",
|
|
"systemDiskCategory": "cloud_essd_entry", "systemDiskSize": 20, "publicIpMode": "ecs_public_ip", "clientCidrIp": "192.0.2.10/32",
|
|
}, map[string]string{"X-CSRF-Token": csrf})
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("preview status: %d body=%s", resp.StatusCode, body)
|
|
}
|
|
var result map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
resp.Body.Close()
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
summary, ok := result["summary"].(map[string]any)
|
|
if !ok || summary["imageId"] != "img-x86" || summary["loginPort"] != float64(3389) || summary["zoneId"] != "zone-a" {
|
|
t.Fatalf("dynamic preview fields missing: %#v", result)
|
|
}
|
|
disk, ok := summary["systemDisk"].(map[string]any)
|
|
if !ok || disk["category"] != "cloud_essd" || disk["size"] != float64(40) || disk["min"] != float64(40) {
|
|
t.Fatalf("dynamic disk fields missing: %#v", summary["systemDisk"])
|
|
}
|
|
}
|
|
|
|
func TestOnlineUpdateRequestRequiresUpdaterAndPersistsTarget(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
request := httptest.NewRequest(http.MethodPost, "/index.php?action=start_update", nil)
|
|
recorder := httptest.NewRecorder()
|
|
srv.startUpdate(recorder, request, map[string]any{"target_commit": strings.Repeat("a", 40)})
|
|
if recorder.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("unconfigured updater status: %d", recorder.Code)
|
|
}
|
|
|
|
srv.UpdateDir = t.TempDir()
|
|
srv.imageChecker = func(context.Context, string) (bool, string, error) {
|
|
return true, "sha256:test", nil
|
|
}
|
|
recorder = httptest.NewRecorder()
|
|
srv.startUpdate(recorder, request, map[string]any{"target_commit": strings.Repeat("b", 40)})
|
|
if recorder.Code != http.StatusAccepted {
|
|
t.Fatalf("update request status: %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(srv.UpdateDir, "request.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(raw), strings.Repeat("b", 40)) {
|
|
t.Fatalf("target commit was not persisted: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestUpdateStatusCompletesWhenControllerAlreadyRunsTargetCommit(t *testing.T) {
|
|
st, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
updateDir := t.TempDir()
|
|
target := strings.Repeat("a", 40)
|
|
status := fmt.Sprintf(`{"status":"running","phase":"restarting","message":"正在重启 ECS 控制台","progress":94,"target_commit":"%s","current_commit":"%s","request_id":"request-1","updated_at":%d}`, target, target, time.Now().Unix())
|
|
if err := os.WriteFile(filepath.Join(updateDir, "status.json"), []byte(status), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
previousCommit := app.Commit
|
|
app.Commit = target
|
|
defer func() { app.Commit = previousCommit }()
|
|
|
|
srv := New(st, t.TempDir(), "", "setup-token", nil)
|
|
srv.UpdateDir = updateDir
|
|
recorder := httptest.NewRecorder()
|
|
srv.updateStatus(recorder)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("update status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response["status"] != "success" || response["phase"] != "completed" || response["progress"] != float64(100) || response["current_commit"] != target {
|
|
t.Fatalf("target commit was not recognized as complete: %#v", response)
|
|
}
|
|
}
|
|
|
|
func postJSON(t *testing.T, client *http.Client, endpoint string, value map[string]any, headers map[string]string) *http.Response {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(value)
|
|
req, _ := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(string(raw)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|