diff --git a/app/controllers/studip_controller.php b/app/controllers/studip_controller.php
index 0e340d6d722485a18f58df482d3c7aadff2cb0f3..556ff8b6284b866679fc2636c9fd186484d347d1 100644
--- a/app/controllers/studip_controller.php
+++ b/app/controllers/studip_controller.php
@@ -252,6 +252,21 @@ abstract class StudipController extends Trails_Controller
     {
         $args = func_get_args();
 
+        // Create url for a specific action
+        // TODO: This seems odd. You kinda specify an absolute path
+        //       to receive a relative url. Meh...
+        //
+        // @deprecated Do not use this, please!
+        if ($to[0] === '/') {
+            $args[0] = substr($to, 1);
+            return $this->action_url(...$args);
+        }
+
+        // Check for absolute URL
+        if ($this->isURL($to)) {
+            throw new InvalidArgumentException(__METHOD__ . ' cannot be used with absolute URLs');
+        }
+
         // Extract parameters (if any)
         $params = [];
         if (is_array(end($args))) {
@@ -280,16 +295,8 @@ abstract class StudipController extends Trails_Controller
                  : $this->current_action;
         }
 
-        // Create url for a specific action
-        // TODO: This seems odd. You kinda specify an absolute path
-        //       to receive a relative url. Meh...
-        if ($to[0] === '/') {
-            $to = $this->controller_path() . $to;
-        }
+        $url = parent::url_for($to);
 
-        $url = preg_match('#^(/|\w+://)#', $to)
-             ? $to
-             : parent::url_for($to);
         if ($fragment) {
             $url .= '#' . $fragment;
         }
@@ -321,10 +328,9 @@ abstract class StudipController extends Trails_Controller
      */
     public function redirect($to)
     {
-        if (func_num_args() > 1) {
-            $to = $this->url_for(...func_get_args());
-        }
-        return parent::redirect($to);
+        $to = $this->adjustToArguments(...func_get_args());
+
+        parent::redirect($to);
     }
 
     /**
@@ -340,13 +346,9 @@ abstract class StudipController extends Trails_Controller
      */
     public function relocate($to)
     {
-        $from_dialog = Request::isDialog();
-
-        if (func_num_args() > 1 || $from_dialog) {
-            $to = $this->url_for(...func_get_args());
-        }
+        $to = $this->adjustToArguments(...func_get_args());
 
-        if ($from_dialog) {
+        if (Request::isDialog()) {
             $this->response->add_header('X-Location', rawurlencode($to));
             $this->render_nothing();
         } else {
@@ -354,6 +356,37 @@ abstract class StudipController extends Trails_Controller
         }
     }
 
+    /**
+     * Returns a URL to a specified route to your Trails application, unless
+     * the parameter is already a valid URL (which is returned unchanged).
+     *
+     * If no absolute url or more than one argument is given, url_for() is
+     * used.
+     */
+    private function adjustToArguments(...$args): string
+    {
+        if ($this->isURL($args[0]) && count($args) > 1) {
+            throw new InvalidArgumentException('Method may not be used with a URL and multiple parameters');
+        }
+
+        if (count($args) > 1 || !$this->isURL($args[0])) {
+            return $this->url_for(...$args);
+        }
+
+        return $args[0];
+    }
+
+    /**
+     * Returns whether the given parameter is a valid url.
+     *
+     * @param string $to
+     * @return bool
+     */
+    private function isURL(string $to): bool
+    {
+        return preg_match('#^(/|\w+://)#', $to);
+    }
+
     /**
      * Exception handler called when the performance of an action raises an
      * exception.
diff --git a/tests/unit/lib/classes/StudipControllerTest.php b/tests/unit/lib/classes/StudipControllerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..db2d6591aac6b6bab1add9859fd3c96807959d95
--- /dev/null
+++ b/tests/unit/lib/classes/StudipControllerTest.php
@@ -0,0 +1,216 @@
+<?php
+/**
+ * StudipControllerTest.php - unit tests for the StudipController class
+ *
+ * @author   Jan-Hendrik Willms <tleilax+studip@gmail.com>
+ * @license  GPL2 or any later version
+ *
+ * @covers StudipController
+ */
+
+final class StudipControllerTest extends Codeception\Test\Unit
+{
+    private $old_uri;
+
+    public static function setUpBeforeClass(): void
+    {
+        parent::setUpBeforeClass();
+
+        require_once 'vendor/trails/trails-abridged.php';
+        require_once 'app/controllers/studip_controller.php';
+    }
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->old_uri = $GLOBALS['ABSOLUTE_URI_STUDIP'] ?? null;
+        $GLOBALS['ABSOLUTE_URI_STUDIP'] = 'https://studip.example.org/';
+    }
+
+    public function tearDown(): void
+    {
+        $GLOBALS['ABSOLUTE_URI_STUDIP'] = $this->old_uri;
+
+        parent::tearDown();
+    }
+
+    private function getDispatcher(): Trails_Dispatcher
+    {
+        $trails_root = $GLOBALS['STUDIP_BASE_PATH'] . DIRECTORY_SEPARATOR . 'app';
+        $trails_uri = rtrim($GLOBALS['ABSOLUTE_URI_STUDIP'], '/') . '/dispatch.php';
+        $default_controller = 'default';
+
+        return new Trails_Dispatcher($trails_root, $trails_uri, $default_controller);
+    }
+
+    private function getController(): StudipController
+    {
+        $dispatcher = $this->getDispatcher();
+        return new class($dispatcher) extends StudipController {
+
+        };
+    }
+
+    /**
+     * @dataProvider UrlForProvider
+     * @covers StudipController::url_for
+     */
+    public function testUrlFor(string $expected, ...$args): void
+    {
+        $url = $this->getController()->url_for(...$args);
+        $this->assertEquals(
+            $expected,
+            $this->getRelativeURL($url)
+        );
+    }
+
+    /**
+     * @dataProvider absoluteUrlForProvider
+     * @covers StudipController::url_for
+     */
+    public function testUrlForWithAbsoluteURL(...$args): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->getController()->url_for(...$args);
+    }
+
+    /**
+     * @dataProvider UrlForProvider
+     * @covers StudipController::redirect
+     */
+    public function testRedirect(string $expected, ...$args): void
+    {
+        $controller = $this->getController();
+        $controller->redirect(...$args);
+        $headers = $controller->get_response()->headers;
+
+        $this->assertArrayHasKey('Location', $headers);
+
+        $this->assertEquals(
+            $expected,
+            $this->getRelativeURL($headers['Location'])
+        );
+    }
+
+    /**
+     * @dataProvider absoluteRedirectProvider
+     * @covers StudipController::redirect
+     */
+    public function testRedirectAbsoluteURL(bool $should_suceed, ...$args): void
+    {
+        if (!$should_suceed) {
+            $this->expectException(InvalidArgumentException::class);
+        }
+        $this->getController()->redirect(...$args);
+    }
+
+    /**
+     * @dataProvider UrlForProvider
+     * @covers StudipController::relocate
+     */
+    public function testRelocate(string $expected, ...$args): void
+    {
+        $controller = $this->getController();
+        $controller->relocate(...$args);
+        $headers = $controller->get_response()->headers;
+
+        $this->assertArrayHasKey('Location', $headers);
+
+        $this->assertEquals(
+            $expected,
+            $this->getRelativeURL($headers['Location'])
+        );
+    }
+
+    /**
+     * @dataProvider absoluteRedirectProvider
+     * @covers StudipController::relocate
+     */
+    public function testRelocateAbsoluteURL(bool $should_suceed, ...$args): void
+    {
+        if (!$should_suceed) {
+            $this->expectException(InvalidArgumentException::class);
+        }
+        $this->getController()->relocate(...$args);
+    }
+
+    /**
+     * @dataProvider UrlForProvider
+     * @covers StudipController::relocate
+     * @backupGlobals enabled
+     */
+    public function testRelocateFromDialog(string $expected, ...$args): void
+    {
+        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
+        $_SERVER['HTTP_X_DIALOG'] = true;
+
+        $controller = $this->getController();
+        $controller->relocate(...$args);
+        $headers = $controller->get_response()->headers;
+
+        $this->assertArrayHasKey('X-Location', $headers);
+
+        $this->assertEquals(
+            $expected,
+            $this->getRelativeURL(rawurldecode($headers['X-Location']))
+        );
+    }
+
+    /**
+     * @dataProvider absoluteRedirectProvider
+     * @covers StudipController::relocate
+     * @backupGlobals enabled
+     */
+    public function testRelocateFromDialogAbsoluteURL(bool $should_suceed, ...$args): void
+    {
+        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
+        $_SERVER['HTTP_X_DIALOG'] = true;
+
+        if (!$should_suceed) {
+            $this->expectException(InvalidArgumentException::class);
+        }
+        $this->getController()->relocate(...$args);
+    }
+
+    /**
+     * Returns a relative url for Stud.IP if given url matches.
+     */
+    private function getRelativeURL(string $url): string
+    {
+        if (strpos($url, $GLOBALS['ABSOLUTE_URI_STUDIP']) === 0) {
+            return str_replace($GLOBALS['ABSOLUTE_URI_STUDIP'], '', $url);
+        }
+        return $url;
+    }
+
+    public function UrlForProvider(): array
+    {
+        return [
+            '1-action'                 => ['dispatch.php/foo', 'foo'],
+            '1-action-and-parameter'   => ['dispatch.php/foo?bar=42', 'foo', ['bar' => 42]],
+            '1-action-and-parameters'  => ['dispatch.php/foo?bar=42&baz=23', 'foo', ['bar' => 42, 'baz' => 23]],
+
+            '2-actions'                => ['dispatch.php/foo/bar', 'foo', 'bar'],
+            '2-actions-and-parameter'  => ['dispatch.php/foo/bar?bar=42', 'foo', 'bar', ['bar' => 42]],
+            '2-actions-and-parameters' => ['dispatch.php/foo/bar?bar=42&baz=23', 'foo', 'bar', ['bar' => 42, 'baz' => 23]],
+        ];
+    }
+
+    public function absoluteUrlForProvider(): array
+    {
+        return [
+            'url'                => ['https://example.org'],
+            'url-and-parameters' => ['https://example.org', ['foo' => 'bar', 'baz' => 42]],
+        ];
+    }
+
+    public function absoluteRedirectProvider(): array
+    {
+        return [
+            'url'                => [true, 'https://example.org'],
+            'url-and-parameters' => [false, 'https://example.org', ['foo' => 'bar', 'baz' => 42]],
+        ];
+    }
+}
+