package onvif import ( "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" ) func TestProfilesDiscoversMediaServiceAndUsesDigest(t *testing.T) { var digestRequests atomic.Int32 var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/onvif/device_service": fmt.Fprintf(w, `%s/onvif/media_service`, server.URL) case "/onvif/media_service": authorization := r.Header.Get("Authorization") if !strings.HasPrefix(authorization, "Digest ") { w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="nonce-1", algorithm=MD5, qop="auth"`) w.WriteHeader(http.StatusUnauthorized) return } digestRequests.Add(1) if strings.Contains(readRequestBody(t, r), "GetProfiles") { fmt.Fprint(w, `MainH26419201080`) return } fmt.Fprint(w, `rtsp://camera.invalid/live`) default: http.NotFound(w, r) } })) defer server.Close() profiles, err := NewHTTPClient(2*time.Second).Profiles(context.Background(), server.URL+"/onvif/device_service", Credential{Username: "operator", Password: "secret"}) if err != nil { t.Fatal(err) } serverURL, _ := url.Parse(server.URL) if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://"+serverURL.Hostname()+"/live" { t.Fatalf("profiles=%#v", profiles) } if digestRequests.Load() != 2 { t.Fatalf("digest requests=%d", digestRequests.Load()) } } func TestNormalizeServiceEndpoint(t *testing.T) { tests := []struct { name string device string advertised string want string wantError bool }{ {name: "same host keeps media port", device: "http://camera.local:80/device", advertised: "http://camera.local:8000/media", want: "http://camera.local:8000/media"}, {name: "different host uses authorized origin", device: "http://192.0.2.10:8080/device", advertised: "http://unusable.local:9000/media?profile=1", want: "http://192.0.2.10:8080/media?profile=1"}, {name: "reject credentials", device: "http://camera.local/device", advertised: "http://user:pass@camera.local/media", wantError: true}, {name: "reject scheme", device: "http://camera.local/device", advertised: "ftp://camera.local/media", wantError: true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := normalizeServiceEndpoint(test.device, test.advertised) if test.wantError { if err == nil { t.Fatalf("got=%q", got) } return } if err != nil || got != test.want { t.Fatalf("got=%q err=%v", got, err) } }) } } func TestNormalizeStreamURI(t *testing.T) { got, err := normalizeStreamURI("http://192.0.2.10:80/onvif/device_service", "rtsp://unusable.local:8554/live/main?channel=1") if err != nil || got != "rtsp://192.0.2.10:8554/live/main?channel=1" { t.Fatalf("got=%q err=%v", got, err) } if _, err := normalizeStreamURI("http://camera.local/onvif", "rtsp://user:pass@camera.local/live"); err == nil { t.Fatal("credential stream URI accepted") } if _, err := normalizeStreamURI("http://camera.local/onvif", "http://camera.local/live"); err == nil { t.Fatal("non-RTSP URI accepted") } } func TestRejectUnsupportedDigestChallenge(t *testing.T) { for _, challenge := range []string{ `Digest realm="camera", nonce="n", algorithm=SHA-512, qop="auth"`, `Digest realm="camera", nonce="n", algorithm=MD5, qop="auth-int"`, `Digest realm="camera"`, } { if _, err := parseDigestChallenge([]string{challenge}); err == nil { t.Fatalf("challenge accepted: %s", challenge) } } } func TestValidateEndpointRejectsCredentials(t *testing.T) { if _, err := validateEndpoint("http://user:pass@camera.invalid/onvif"); err == nil { t.Fatal("credential endpoint accepted") } } func TestSOAPDoesNotFollowRedirect(t *testing.T) { redirectTargetCalled := false target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { redirectTargetCalled = true })) defer target.Close() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) })) defer server.Close() _, err := NewHTTPClient(time.Second).soap(context.Background(), server.URL, Credential{Username: "operator", Password: "secret"}, "") if err == nil || redirectTargetCalled { t.Fatalf("err=%v redirect_target_called=%v", err, redirectTargetCalled) } } func readRequestBody(t *testing.T, r *http.Request) string { t.Helper() defer r.Body.Close() data, err := io.ReadAll(r.Body) if err != nil { t.Fatal(err) } return string(data) } func TestDigestAuthorizationUsesRequestURI(t *testing.T) { header, err := digestAuthorization(http.MethodPost, "/media?profile=1", Credential{Username: "operator", Password: "secret"}, digestChallenge{realm: "camera", nonce: "n", algorithm: "SHA-256", qop: "auth"}) if err != nil { t.Fatal(err) } parsed, err := parseAuthParameters(strings.TrimPrefix(header, "Digest ")) if err != nil { t.Fatal(err) } if parsed["uri"] != "/media?profile=1" || parsed["username"] != "operator" || parsed["response"] == "" { t.Fatalf("invalid digest fields: %#v", parsed) } if _, err := url.Parse(parsed["uri"]); err != nil { t.Fatal(err) } }