041a19f060
* feat: add service-managed self-update * chore: add temporary self-update test scripts * chore: simplify temporary script markdown fences * chore: run frontend in temporary self-update scripts * fix: show self-update job progress after apply * chore: add real npm self-update test script * fix: isolate npm self-update prefix * fix: detect npm service installs from bin shim * chore: reset service before real npm update test * chore: expand real npm status diagnostics * fix: preserve service home during self-update * fix(cli): tee self-update helper output to a log file * fix(cli): avoid launchd bootstrap race when reloading the macOS service * fix(backend): run macOS self-update helper once via transient launchd plist * fix(web): show self-update progress and lock the apply button until the update lands * fix(cli): reinstall via the kandev wrapper on Homebrew self-update * chore: remove temp self-update test scripts * fix(web): drop unused apply path from useUpdates hook * fix(backend): snapshot install state once in apply preflight * docs: align self-update docs with transient LaunchAgent flow * chore(release): run cli bundle from apps workspace root * fix(backend): guard against concurrent self-update applies * fix(backend): check scheme and helper paths in self-update apply * fix(backend): drop duplicate binary name from npx manual command * fix(cli): allow --help for service self-update without --intent * fix(cli): harden self-update dir perms and darwin uid resolution * fix(web): set required POST fields last in applyUpdate * docs: use JSON body notation for the apply endpoint * fix(backend): expire self-update guard so a stuck helper cannot wedge apply * refactor(cli): collapse redundant writeSync branch in self-update log
246 lines
7.4 KiB
Go
246 lines
7.4 KiB
Go
package updates
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
func init() {
|
|
gin.SetMode(gin.TestMode)
|
|
}
|
|
|
|
func newRouter(svc *Service) *gin.Engine {
|
|
r := gin.New()
|
|
api := r.Group("/api/v1/system")
|
|
api.GET("/updates", HandleGet(svc))
|
|
api.POST("/updates/check", HandleCheck(svc))
|
|
api.POST("/updates/apply", HandleApply(svc))
|
|
return r
|
|
}
|
|
|
|
func TestHandleGet_ReturnsZeroValues(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp UpdatesResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp.Current != "v1.0.0" {
|
|
t.Errorf("current=%q", resp.Current)
|
|
}
|
|
if resp.UpdateAvailable {
|
|
t.Errorf("expected UpdateAvailable=false")
|
|
}
|
|
}
|
|
|
|
func TestHandleGet_IncludesNonServiceInstallState(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
install, ok := body["install"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("install state missing from response: %s", w.Body.String())
|
|
}
|
|
if got := install["running_as_service"]; got != false {
|
|
t.Errorf("running_as_service=%v want false", got)
|
|
}
|
|
if got := body["apply_supported"]; got != false {
|
|
t.Errorf("apply_supported=%v want false", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleCheck_FirstCall200(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
|
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
|
svc.SetReleaseURL(srv.URL)
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp UpdatesResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp.Latest != "v1.0.1" {
|
|
t.Errorf("latest=%q", resp.Latest)
|
|
}
|
|
if !resp.UpdateAvailable {
|
|
t.Errorf("expected UpdateAvailable=true")
|
|
}
|
|
}
|
|
|
|
func TestHandleCheck_SecondCallReturns429(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
|
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
|
svc.SetReleaseURL(srv.URL)
|
|
r := newRouter(svc)
|
|
|
|
// First call seeds the limiter.
|
|
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
|
w1 := httptest.NewRecorder()
|
|
r.ServeHTTP(w1, req1)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first call status=%d", w1.Code)
|
|
}
|
|
|
|
// Second call within window is rate-limited.
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
if w2.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429, got %d body=%s", w2.Code, w2.Body.String())
|
|
}
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
RetryAfterSeconds int64 `json:"retry_after_seconds"`
|
|
}
|
|
if err := json.Unmarshal(w2.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if body.RetryAfterSeconds < 1 || body.RetryAfterSeconds > 30 {
|
|
t.Errorf("retry_after_seconds out of range: %d", body.RetryAfterSeconds)
|
|
}
|
|
if body.Error == "" {
|
|
t.Errorf("expected non-empty error")
|
|
}
|
|
}
|
|
|
|
func TestHandleCheck_GitHubFailureReturns502(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "down", http.StatusInternalServerError)
|
|
}))
|
|
defer failSrv.Close()
|
|
svc := NewService(pool, "v1.0.0", failSrv.Client(), logger.Default())
|
|
svc.SetReleaseURL(failSrv.URL)
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleApply_RejectsCrossOrigin(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
|
|
req.Host = "kandev.local"
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleApply_WrongConfirmReturns400(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"NOPE"}`))
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "http://localhost:38429")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleApply_RejectsCrossScheme(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
|
|
req.Host = "localhost:38429"
|
|
// Server was reached over plain http (no TLS); an https Origin is cross-scheme.
|
|
req.Header.Set("Origin", "https://localhost:38429")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s want 403", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleApply_HonorsForwardedProtoForScheme(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "https://localhost:38429")
|
|
// A reverse proxy terminated TLS upstream, so the https Origin is same-origin.
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Passes the same-origin gate, so it proceeds to the install-state check and
|
|
// is refused there (409) rather than blocked as cross-origin (403).
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status=%d body=%s want 409 (not 403)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleApply_RejectsLoopbackDifferentPort(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
r := newRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "http://localhost:37429")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|