feat(check,serve): Startvalidierung, HTTPS-Server, Signal-Handling und Versionscheck

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:34:22 +02:00
parent 7e23df9fec
commit 2d223c5823
9 changed files with 1158 additions and 6 deletions

View file

@ -1,17 +1,80 @@
package main
import (
"context"
"fmt"
"io"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
func runCheck(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("check", stderr)
testAuth := fs.String("test-auth", "", "Suche und Gruppenprüfung für einen Benutzer durchspielen")
testAuth := fs.String("test-auth", "",
"Suche und Gruppenprüfung für einen konkreten Benutzer durchspielen (ohne Passwort)")
if err := fs.Parse(args); err != nil {
return 2
}
_ = testAuth
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
return 1
cfg, err := config.Load(*path, nil)
if err != nil {
// Ohne lesbare Konfiguration lässt sich nichts weiter prüfen.
fmt.Fprintf(stdout, "✗ Konfiguration\n %v\n", err)
return 1
}
fw, ad, err := buildBackends(cfg)
if err != nil {
fmt.Fprintf(stdout, "✗ Initialisierung\n %v\n", err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := &check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}
report := c.Run(ctx, *testAuth)
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stdout, "\nEs sind Fehler aufgetreten. Das Portal würde nicht korrekt arbeiten.")
return 1
}
fmt.Fprintln(stdout, "\nAlle Prüfungen bestanden.")
return 0
}
// buildBackends erzeugt Firewall- und Verzeichnis-Client aus der Konfiguration.
// Wird von serve und check gemeinsam genutzt.
func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
fw, err := opnsense.New(opnsense.Options{
BaseURL: cfg.OPNsense.URL,
APIKey: cfg.OPNsense.APIKey,
APISecret: cfg.OPNsense.APISecret,
CAFile: cfg.OPNsense.CAFile,
InsecureSkipVerify: cfg.OPNsense.InsecureSkipVerify,
Timeout: 20 * time.Second,
})
if err != nil {
return nil, nil, err
}
ad, err := auth.NewAD(auth.Options{
Servers: cfg.AD.Servers,
Port: cfg.AD.Port,
TLSMode: cfg.AD.TLSMode,
BaseDN: cfg.EffectiveBaseDN(),
BindUser: cfg.AD.BindUser,
BindPassword: cfg.AD.BindPassword,
VPNGroup: cfg.AD.VPNGroup,
CAFile: cfg.AD.CAFile,
Timeout: time.Duration(cfg.AD.Timeout),
})
if err != nil {
return nil, nil, err
}
return fw, ad, nil
}

62
cmd/vpnportal/health.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"context"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// healthCacheTTL verhindert, dass häufige Monitoring-Abfragen Firewall und
// Verzeichnis belasten.
const healthCacheTTL = 30 * time.Second
// directoryPinger ist der für /healthz benötigte Ausschnitt des AD-Clients.
type directoryPinger interface {
ResolveGroupDN(ctx context.Context) (string, error)
}
// healthChecker beantwortet /healthz mit kurz gecachten Ergebnissen.
type healthChecker struct {
fw check.Pinger
dir directoryPinger
clock func() time.Time
mu sync.Mutex
cached web.HealthReport
cachedAt time.Time
}
func newHealthChecker(fw check.Pinger, dir directoryPinger) *healthChecker {
return &healthChecker{fw: fw, dir: dir, clock: time.Now}
}
func (h *healthChecker) Check(ctx context.Context) web.HealthReport {
now := h.clock()
h.mu.Lock()
if !h.cachedAt.IsZero() && now.Sub(h.cachedAt) < healthCacheTTL {
rep := h.cached
h.mu.Unlock()
return rep
}
h.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var rep web.HealthReport
_, fwErr := h.fw.Ping(ctx)
rep.OPNsense = fwErr == nil
_, dirErr := h.dir.ResolveGroupDN(ctx)
rep.Directory = dirErr == nil
rep.OK = rep.OPNsense && rep.Directory
h.mu.Lock()
h.cached, h.cachedAt = rep, now
h.mu.Unlock()
return rep
}
var _ web.HealthChecker = (*healthChecker)(nil)

View file

@ -1,13 +1,34 @@
package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/portal"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
const defaultConfigPath = "/etc/vpnportal/config.yaml"
// providerCacheTTL ist die einzige erlaubte Cache-Ausnahme: die Liste der
// VPN-Instanzen. Zertifikate werden nie gecacht.
const providerCacheTTL = 5 * time.Minute
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
@ -15,11 +36,203 @@ func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
return fs, path
}
// configChecksum liefert eine gekürzte Prüfsumme der Konfigurationsdatei
// für das startup-Ereignis — sie erlaubt, Konfigurationsstände zu
// unterscheiden, ohne Inhalte zu protokollieren.
func configChecksum(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:12], nil
}
// newTLSConfig lädt Zertifikat und Schlüssel des Portals.
func newTLSConfig(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("TLS-Zertifikat/-Schlüssel konnten nicht geladen werden: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}, nil
}
func runServe(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("serve", stderr)
if err := fs.Parse(args); err != nil {
return 2
}
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
return 1
cfg, err := config.Load(*path, nil)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
if cfg.OPNsense.InsecureSkipVerify {
// Bewusst bei jedem Start, nicht nur einmalig.
fmt.Fprintln(stderr,
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
}
fw, ad, err := buildBackends(cfg)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
// Startprüfung: das Portal startet nicht mit kaputter Umgebung.
startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second)
report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "")
startCancel()
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.")
return 1
}
auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{
MaxSizeMB: cfg.Logging.MaxSizeMB,
MaxBackups: cfg.Logging.MaxBackups,
Compress: cfg.Logging.Compress,
Stdout: stdout,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
defer auditLog.Close()
// Der Failover-Hook kann erst jetzt gesetzt werden — vorher gab es noch
// kein Audit-Log, in das er hätte schreiben können.
ad.SetFailoverHook(func(server string, err error) {
auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server})
})
matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now)
renderer, err := web.NewRenderer(web.Brand{
Title: cfg.Portal.Title,
SupportContact: cfg.Portal.SupportContact,
Version: version,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now)
limiter := web.NewLimiter(time.Now)
srv, err := web.NewServer(web.Deps{
Auth: ad,
Certs: certs,
Audit: auditLog,
Sessions: sessions,
Limiter: limiter,
Renderer: renderer,
Health: newHealthChecker(fw, ad),
Clock: time.Now,
LogoPath: cfg.Portal.LogoFile,
SupportContact: cfg.Portal.SupportContact,
SessionTTL: time.Duration(cfg.Portal.SessionTTL),
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sum, _ := configChecksum(*path)
auditLog.Log(audit.Event{
Event: audit.EventStartup, Version: versionString(), ConfigSum: sum,
Detail: "listen=" + cfg.Portal.Listen,
})
if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" {
fmt.Fprintln(stdout, msg)
}
httpSrv := &http.Server{
Addr: cfg.Portal.Listen,
Handler: srv.Handler(),
TLSConfig: tlsCfg,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// WriteTimeout bleibt großzügig, weil Downloads gestreamt werden.
WriteTimeout: 5 * time.Minute,
IdleTimeout: 60 * time.Second,
}
// Hintergrundaufräumen für Sessions und Rate-Limiter.
stopGC := make(chan struct{})
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
sessions.GC()
limiter.GC()
case <-stopGC:
return
}
}
}()
defer close(stopGC)
// SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen).
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for range hup {
if err := auditLog.Reopen(); err != nil {
fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err)
}
}
}()
// Geordnetes Herunterfahren.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(shutdown)
errCh := make(chan error, 1)
go func() {
fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen)
errCh <- httpSrv.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err)
return 1
}
return 0
case sig := <-shutdown:
fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpSrv.Shutdown(ctx); err != nil {
fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err)
return 1
}
return 0
}
}

View file

@ -0,0 +1,82 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestServeRejectsMissingConfig(t *testing.T) {
var out, errOut bytes.Buffer
code := runServe([]string{"--config", "/gibt/es/nicht.yaml"}, &out, &errOut)
if code == 0 {
t.Fatal("fehlende Config muss zum Abbruch führen")
}
if !strings.Contains(errOut.String(), "nicht.yaml") {
t.Errorf("Fehlermeldung muss den Pfad nennen: %q", errOut.String())
}
}
func TestServeRejectsLooseConfigPermissions(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("portal:\n listen: \"127.0.0.1:0\"\n"), 0o644)
os.Chmod(p, 0o644)
var out, errOut bytes.Buffer
if code := runServe([]string{"--config", p}, &out, &errOut); code == 0 {
t.Fatal("zu offene Dateirechte müssen den Start verhindern")
}
if !strings.Contains(errOut.String(), "chmod") {
t.Errorf("Fehlermeldung sollte den Reparaturbefehl nennen: %q", errOut.String())
}
}
func TestCheckRejectsUnknownConfigField(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("portal:\n tls_cert_typo: \"x\"\n"), 0o600)
os.Chmod(p, 0o600)
var out, errOut bytes.Buffer
code := runCheck([]string{"--config", p}, &out, &errOut)
if code == 0 {
t.Fatal("Tippfehler in der Config muss zum Abbruch führen")
}
combined := out.String() + errOut.String()
if !strings.Contains(combined, "tls_cert_typo") {
t.Errorf("das unbekannte Feld muss genannt werden: %q", combined)
}
}
func TestConfigChecksumIsStable(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("inhalt"), 0o600)
os.Chmod(p, 0o600)
a, err := configChecksum(p)
if err != nil {
t.Fatal(err)
}
b, _ := configChecksum(p)
if a != b || a == "" {
t.Fatalf("Prüfsumme instabil: %q vs %q", a, b)
}
if len(a) != 12 {
t.Errorf("Prüfsumme sollte gekürzt sein (12 Zeichen), ist %d", len(a))
}
os.WriteFile(p, []byte("anderer inhalt"), 0o600)
if c, _ := configChecksum(p); c == a {
t.Error("geänderte Datei muss eine andere Prüfsumme ergeben")
}
}
func TestNewTLSConfigRejectsMissingFiles(t *testing.T) {
if _, err := newTLSConfig("/nix.crt", "/nix.key"); err == nil {
t.Fatal("fehlende Zertifikatsdateien müssen abgelehnt werden")
}
}

View file

@ -0,0 +1,94 @@
//go:build uidemo
// Dieser Test startet das Portal mit Attrappen für AD und Firewall auf
// http://127.0.0.1:8080, damit die Oberfläche ohne echte Backends begutachtet
// werden kann. Aufruf:
//
// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
//
// Anmeldung mit beliebigem Benutzernamen und Passwort.
// Browser erlauben Secure-Cookies auf 127.0.0.1, deshalb funktioniert der
// gesamte Ablauf auch ohne TLS.
package main
import (
"context"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
type demoAuth struct{}
func (demoAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}, nil
}
type demoCerts struct{}
func (demoCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
return []web.CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 2, 0)},
{Token: "2:b1", InstanceName: "VPN Außendienst", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}, nil
}
func (demoCerts) Export(ctx context.Context, user, vpnID, refID, format string) (*web.ExportStream, error) {
return &web.ExportStream{
Filename: "vpn-demo-mmueller.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\nremote fw01.testfirma.local 1194\n")),
InstanceName: "VPN Homeoffice",
CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0),
}, nil
}
func (demoCerts) Pattern(user string) string { return user }
// leereCerts zeigt den Zustand "kein Zertifikat hinterlegt".
type leereCerts struct{ demoCerts }
func (leereCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
return nil, nil
}
type demoAudit struct{ t *testing.T }
func (d demoAudit) Log(e audit.Event) { d.t.Logf("audit: %+v", e) }
func TestUIDemo(t *testing.T) {
rn, err := web.NewRenderer(web.Brand{Title: "VPN-Portal Testfirma", Version: "uidemo"})
if err != nil {
t.Fatal(err)
}
// Mit VPNPORTAL_UIDEMO_EMPTY=1 den Zustand "kein Zertifikat" begutachten.
var certs web.CertSource = demoCerts{}
if os.Getenv("VPNPORTAL_UIDEMO_EMPTY") != "" {
certs = leereCerts{}
}
srv, err := web.NewServer(web.Deps{
Auth: demoAuth{}, Certs: certs, Audit: demoAudit{t},
Sessions: web.NewSessionStore(30*time.Minute, nil),
Limiter: web.NewLimiter(nil), Renderer: rn,
SupportContact: "it-helpdesk@testfirma.de",
SessionTTL: 30 * time.Minute,
MinLoginDuration: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden")
if err := http.ListenAndServe("127.0.0.1:8080", srv.Handler()); err != nil {
t.Fatal(err)
}
}

View file

@ -0,0 +1,61 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// releaseURL ist der Endpunkt des Versionschecks. Er wird nur abgefragt,
// wenn portal.update_check ausdrücklich eingeschaltet ist (Default: aus).
const releaseURL = "https://git.ravensburg.dev/api/v1/repos/cabele/opnsense-portal/releases/latest"
// checkForUpdate fragt read-only die neueste Version ab und liefert einen
// Hinweistext, falls sich die veröffentlichte Version unterscheidet.
// Jeder Fehler führt zu einer leeren Rückgabe — der Check darf den Start
// unter keinen Umständen stören.
func checkForUpdate(ctx context.Context, enabled bool, url, current string, client *http.Client) string {
if !enabled {
return ""
}
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "vpnportal/"+current)
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var release struct {
TagName string `json:"tag_name"`
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil || json.Unmarshal(raw, &release) != nil {
return ""
}
// Reiner Gleichheitsvergleich, keine Semver-Ordnung: das Portal meldet nur,
// dass sich die veröffentlichte Version unterscheidet, und urteilt nicht
// selbst über Versionsordnungen.
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
if latest == "" || latest == strings.TrimPrefix(strings.TrimSpace(current), "v") {
return ""
}
return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current)
}

View file

@ -0,0 +1,78 @@
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestUpdateCheckDisabledMakesNoRequest(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer srv.Close()
if msg := checkForUpdate(context.Background(), false, srv.URL, "1.0.0", srv.Client()); msg != "" {
t.Errorf("bei update_check=false darf nichts gemeldet werden, got %q", msg)
}
if called {
t.Fatal("bei update_check=false darf kein Netzwerkzugriff stattfinden")
}
}
func TestUpdateCheckReportsNewerVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.4.0"}`)
}))
defer srv.Close()
msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client())
if msg == "" {
t.Fatal("abweichende Version muss gemeldet werden")
}
if !strings.Contains(msg, "1.4.0") {
t.Errorf("Meldung nennt die neue Version nicht: %q", msg)
}
}
func TestUpdateCheckSilentWhenCurrent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.2.0"}`)
}))
defer srv.Close()
// Auch mit "v"-Präfix auf der einen und ohne auf der anderen Seite.
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
t.Errorf("bei aktueller Version darf nichts gemeldet werden, got %q", msg)
}
}
func TestUpdateCheckSurvivesUnreachableEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := srv.URL
client := srv.Client()
srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Ein nicht erreichbarer Endpunkt darf den Start niemals stören.
if msg := checkForUpdate(ctx, true, url, "1.2.0", client); msg != "" {
t.Errorf("bei Fehler darf nichts gemeldet werden, got %q", msg)
}
}
func TestUpdateCheckIgnoresGarbage(t *testing.T) {
for _, body := range []string{`nicht json`, `{}`, `{"tag_name":""}`, `{"tag_name":" "}`} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
}))
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
t.Errorf("Antwort %q darf nichts melden, got %q", body, msg)
}
srv.Close()
}
}