opnsense-portal/internal/web/handlers_certs_test.go
2026-08-14 09:25:52 +02:00

305 lines
9.8 KiB
Go

package web
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// certsWithExport ist eine CertSource mit funktionierendem Export.
type certsWithExport struct {
entries []CertEntry
listErr error
exportErr error
exportCall int
lastArgs [4]string
}
func (c *certsWithExport) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) {
return c.entries, c.listErr
}
func (c *certsWithExport) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) {
c.exportCall++
c.lastArgs = [4]string{username, vpnID, refID, format}
if c.exportErr != nil {
return nil, c.exportErr
}
return &ExportStream{
Filename: "vpn-homeoffice-mmueller.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\nremote fw01 1194\n")),
InstanceName: "VPN Homeoffice",
CommonName: "mmueller",
ValidTo: time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC),
}, nil
}
// Pattern macht certsWithExport zur Quelle des Audit-Feldes pattern.
func (c *certsWithExport) Pattern(username string) string { return username }
// loggedInServer liefert Server, Audit-Mitschrift, Cookie und Session eines
// angemeldeten Benutzers.
func loggedInServer(t *testing.T, certs CertSource) (*Server, *recordAudit, *http.Cookie, *Session) {
t.Helper()
store := NewSessionStore(10*time.Minute, nil)
srv, rec := newTestServer(t, Deps{
Auth: &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}},
Certs: certs,
Sessions: store,
SupportContact: "it-helpdesk@firma.de",
})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
if cookie == nil {
t.Fatal("Anmeldung lieferte kein Session-Cookie")
}
sess, ok := store.Get(cookie.Value)
if !ok {
t.Fatal("Anmeldung fehlgeschlagen")
}
return srv, rec, cookie, sess
}
func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, path, nil)
r.RemoteAddr = "10.1.20.34:5000"
if c != nil {
r.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
func TestOverviewListsCertificates(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
{Token: "2:b1", InstanceName: "VPN Aussendienst", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
rec := getWithCookie(srv.Handler(), "/", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"VPN Homeoffice", "VPN Aussendienst",
S["download_ovpn"], S["download_viscosity"], `name="csrf_token"`} {
if !strings.Contains(body, want) {
t.Errorf("Übersicht enthält %q nicht", want)
}
}
if strings.Contains(body, `<a href="/download`) {
t.Error("Downloads dürfen keine GET-Links sein")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestOverviewShowsExpiryWarning(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if !strings.Contains(body, "12 Tagen") {
t.Errorf("Ablaufwarnung fehlt: %s", body)
}
}
func TestOverviewNoWarningForDistantExpiry(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if strings.Contains(body, "Achtung") {
t.Error("bei ferner Gültigkeit darf keine Warnung erscheinen")
}
}
func TestOverviewNoCertsShowsSupportContact(t *testing.T) {
srv, rec, cookie, _ := loggedInServer(t, &certsWithExport{})
out := getWithCookie(srv.Handler(), "/", cookie)
body := out.Body.String()
if !strings.Contains(body, S["overview_no_certs"]) {
t.Error("Meldung für fehlende Zertifikate fehlt")
}
if !strings.Contains(body, "it-helpdesk@firma.de") {
t.Error("support_contact fehlt")
}
ev, ok := rec.find(audit.EventNoCertFound)
if !ok {
t.Fatal("no_cert_found fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
if ev.Pattern == "" {
t.Error("angewendetes Muster muss im Audit-Log stehen")
}
}
func TestOverviewBackendDownShowsServiceError(t *testing.T) {
certs := &certsWithExport{listErr: ErrBackendUnavailable}
srv, rec, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/", cookie)
if out.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", out.Code)
}
body := out.Body.String()
if !strings.Contains(body, S["error_service_unavailable"]) {
t.Error("Dienstfehlerseite fehlt")
}
if strings.Contains(body, S["overview_no_certs"]) {
t.Error("Firewall-Ausfall darf nicht wie \"kein Zertifikat\" aussehen")
}
if _, ok := rec.find(audit.EventOPNsenseUnreachable); !ok {
t.Error("opnsense_unreachable fehlt im Audit-Log")
}
if _, ok := rec.find(audit.EventNoCertFound); ok {
t.Error("bei Firewall-Ausfall darf kein no_cert_found geloggt werden")
}
}
func TestDownloadStreamsWithHardenedHeaders(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller"},
}}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusOK {
t.Fatalf("Code = %d, body: %s", out.Code, out.Body.String())
}
cd := out.Header().Get("Content-Disposition")
if !strings.Contains(cd, `attachment; filename="vpn-homeoffice-mmueller.ovpn"`) {
t.Errorf("Content-Disposition = %q", cd)
}
if cc := out.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
if !strings.Contains(out.Body.String(), "remote fw01 1194") {
t.Errorf("Body = %q", out.Body.String())
}
// Der Export muss mit dem kanonischen Benutzernamen aufgerufen werden.
if certs.lastArgs[0] != "mmueller" {
t.Errorf("Export-Benutzername = %q, want mmueller", certs.lastArgs[0])
}
ev, ok := rec.find(audit.EventConfigDownload)
if !ok {
t.Fatal("config_download fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.VPNInstance != "VPN Homeoffice" ||
ev.Format != "ovpn" || ev.CertCN != "mmueller" || ev.CertExpiry != "2027-03-01" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
}
func TestDownloadRequiresCSRF(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {"falsch"}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if certs.exportCall != 0 {
t.Error("ohne gültiges CSRF-Token darf kein Export ausgelöst werden")
}
}
func TestDownloadGETIsNotRouted(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/download?cert=1:a1&format=ovpn", cookie)
if out.Code == http.StatusOK {
t.Fatal("Downloads per GET dürfen nicht möglich sein")
}
if certs.exportCall != 0 {
t.Error("GET darf keinen Export auslösen")
}
}
func TestForeignCertificateIsDenied(t *testing.T) {
certs := &certsWithExport{
entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}},
exportErr: ErrCertNotOwned,
}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"9:fremd"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
ev, ok := rec.find(audit.EventDownloadDenied)
if !ok {
t.Fatal("download_denied fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
}
func TestDownloadRejectsMalformedToken(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
for _, bad := range []string{"", "keinDoppelpunkt", "a:b:c"} {
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {bad}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden && out.Code != http.StatusBadRequest {
t.Errorf("Token %q ergab Code %d", bad, out.Code)
}
}
if certs.exportCall != 0 {
t.Error("kaputte Tokens dürfen keinen Export auslösen")
}
}
func TestDownloadRejectsUnknownFormat(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"exe"},
}, cookie)
if out.Code == http.StatusOK {
t.Fatal("unbekanntes Format muss abgelehnt werden")
}
if certs.exportCall != 0 {
t.Error("unbekanntes Format darf keinen Export auslösen")
}
}
func TestDownloadWithoutSessionRedirects(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
out := postForm(srv.Handler(), "/download", url.Values{"cert": {"1:a1"}, "format": {"ovpn"}})
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
}