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) + } +} +