Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
305 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|