Skip to content
Merged
26 changes: 26 additions & 0 deletions packages/http/src/Session/Config/RedisSessionConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Session\Config;

use Tempest\Container\Container;
use Tempest\DateTime\Duration;
use Tempest\Http\Session\Managers\RedisSessionManager;
use Tempest\Http\Session\SessionConfig;

final class RedisSessionConfig implements SessionConfig
{
/**
* @param Duration $expiration Time required for a session to expire.
*/
public function __construct(
private(set) Duration $expiration,
private(set) string $prefix = 'session',
) {}

public function createManager(Container $container): RedisSessionManager
{
return $container->get(RedisSessionManager::class);
}
}
131 changes: 131 additions & 0 deletions packages/http/src/Session/Managers/RedisSessionManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Session\Managers;

use Tempest\Clock\Clock;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionConfig;
use Tempest\Http\Session\SessionDestroyed;
use Tempest\Http\Session\SessionId;
use Tempest\Http\Session\SessionManager;
use Tempest\KeyValue\Redis\Redis;
use Throwable;

use function Tempest\event;

final readonly class RedisSessionManager implements SessionManager
{
public function __construct(
private Clock $clock,
private Redis $redis,
private SessionConfig $sessionConfig,
) {}

public function create(SessionId $id): Session
{
return $this->persist($id);
}

public function set(SessionId $id, string $key, mixed $value): void
{
$this->persist($id, [...$this->getData($id), ...[$key => $value]]);
}

public function get(SessionId $id, string $key, mixed $default = null): mixed
{
return $this->getData($id)[$key] ?? $default;
}

public function remove(SessionId $id, string $key): void
{
$data = $this->getData($id);

unset($data[$key]);

$this->persist($id, $data);
}

public function destroy(SessionId $id): void
{
$this->redis->getClient()->del($this->getKey($id));

event(new SessionDestroyed($id));
}

public function isValid(SessionId $id): bool
{
$session = $this->resolve($id);

if ($session === null) {
return false;
}

if (! ($session->lastActiveAt ?? null)) {
return false;
}

return $this->clock->now()->before(
other: $session->lastActiveAt->plus($this->sessionConfig->expiration),
);
}

private function resolve(SessionId $id): ?Session
{
try {
$content = $this->redis->get($this->getKey($id));
return unserialize($content, ['allowed_classes' => true]);
} catch (Throwable $e) {
return null;
}
}

public function all(SessionId $id): array
{
return $this->getData($id);
}

/**
* @return array<mixed>
*/
private function getData(SessionId $id): array
{
return $this->resolve($id)->data ?? [];
}

/**
* @param array<mixed>|null $data
*/
private function persist(SessionId $id, ?array $data = null): Session
{
$now = $this->clock->now();
$session = $this->resolve($id) ?? new Session(
id: $id,
createdAt: $now,
lastActiveAt: $now,
);

$session->lastActiveAt = $now;

if ($data !== null) {
$session->data = $data;
}

$this->redis->set($this->getKey($id), serialize($session), $this->sessionConfig->expiration);

return $session;
}

private function getKey(SessionId $id): string
{
return sprintf('%s_%s', $this->sessionConfig->prefix, $id);
}

public function cleanup(): void
{
// what should we do here?
// on persist we set the expiration (ttl) for the session in the redis store
// in theory all session data should be expire by itself
}
}
174 changes: 174 additions & 0 deletions tests/Integration/Http/RedisSessionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

declare(strict_types=1);

namespace Integration\Http;

use PHPUnit\Framework\Attributes\Test;
use Tempest\Clock\Clock;
use Tempest\DateTime\Duration;
use Tempest\EventBus\EventBus;
use Tempest\Http\Session\Config\RedisSessionConfig;
use Tempest\Http\Session\Managers\RedisSessionManager;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionConfig;
use Tempest\Http\Session\SessionDestroyed;
use Tempest\Http\Session\SessionId;
use Tempest\Http\Session\SessionManager;
use Tempest\KeyValue\Redis\Redis;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

/**
* @internal
*/
final class RedisSessionTest extends FrameworkIntegrationTestCase
{
protected function setUp(): void
{
parent::setUp();

$this->container->config(new RedisSessionConfig(expiration: Duration::hours(2)));
$this->container->singleton(
SessionManager::class,
fn () => new RedisSessionManager(
$this->container->get(Clock::class),
$this->container->get(Redis::class),
$this->container->get(SessionConfig::class),
),
);
}

#[Test]
public function create_session_from_container(): void
{
$session = $this->container->get(Session::class);

$this->assertInstanceOf(Session::class, $session);
}

#[Test]
public function put_get(): void
{
$session = $this->container->get(Session::class);

$session->set('test', 'value');

$value = $session->get('test');
$this->assertEquals('value', $value);
}

#[Test]
public function remove(): void
{
$session = $this->container->get(Session::class);

$session->set('test', 'value');
$session->remove('test');

$value = $session->get('test');
$this->assertNull($value);
}

#[Test]
public function destroy(): void
{
$manager = $this->container->get(SessionManager::class);
$sessionId = new SessionId('test_session_destroy');

$session = $manager->create($sessionId);
$session->set('magic_type', 'offensive');

$this->assertTrue($manager->isValid($sessionId));

$events = [];
$eventBus = $this->container->get(EventBus::class);
$eventBus->listen(function (SessionDestroyed $event) use (&$events): void {
$events[] = $event;
});

$session->destroy();

$this->assertFalse($manager->isValid($sessionId));
$this->assertCount(1, $events);
$this->assertEquals((string) $sessionId, (string) $events[0]->id);
}

#[Test]
public function set_previous_url(): void
{
$session = $this->container->get(Session::class);
$session->setPreviousUrl('http://localhost/previous');

$this->assertEquals('http://localhost/previous', $session->getPreviousUrl());
}

#[Test]
public function is_valid(): void
{
$clock = $this->clock('2023-01-01 00:00:00');

$this->container->config(new RedisSessionConfig(
expiration: Duration::second(),
));

$sessionManager = $this->container->get(SessionManager::class);

$this->assertFalse($sessionManager->isValid(new SessionId('unknown')));

$session = $sessionManager->create(new SessionId('new'));

$this->assertTrue($session->isValid());

$clock->plus(1);

$this->assertFalse($session->isValid());
}

#[Test]
public function session_reflash(): void
{
$session = $this->container->get(Session::class);

$session->flash('test', 'value');
$session->flash('test2', ['key' => 'value']);

$this->assertEquals('value', $session->get('test'));

$session->reflash();
$session->cleanup();

$this->assertEquals('value', $session->get('test'));
$this->assertEquals(['key' => 'value'], $session->get('test2'));
}

#[Test]
public function session_expires_based_on_last_activity(): void
{
$clock = $this->clock('2023-01-01 00:00:00');

$this->container->config(new RedisSessionConfig(
expiration: Duration::minutes(30),
));

$manager = $this->container->get(SessionManager::class);
$sessionId = new SessionId('last_activity_test');

// Create session
$session = $manager->create($sessionId);
$this->assertTrue($session->isValid());

$clock->plus(Duration::minutes(25));
$this->assertTrue($session->isValid());

// Perform activity
$session->set('activity', 'user_action');
$clock->plus(Duration::minutes(25));
$this->assertTrue($session->isValid());
$this->assertTrue($manager->isValid($sessionId));

// Move forward another 10 minutes, now 35 minutes from last activity
$clock->plus(Duration::minutes(10));
$this->assertFalse($session->isValid());
$this->assertFalse($manager->isValid($sessionId));
}
}