diff --git a/Sense/server/app/sense/adapters/onvif/client_test.go b/Sense/server/app/sense/adapters/onvif/client_test.go
new file mode 100644
index 0000000..c8d6154
--- /dev/null
+++ b/Sense/server/app/sense/adapters/onvif/client_test.go
@@ -0,0 +1,144 @@
+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)
+ }
+ if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://camera.invalid/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 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)
+ }
+}
+