OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Middleware\AuthCheck;
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
|
||||
/**
|
||||
* Guards the Bearer-auth regression (3.9.0): the permission engine reads the user's id + role from
|
||||
* session('userdata'), which the x-api-key guard establishes as a side effect of getAPIKeyUser()
|
||||
* but the Sanctum (Bearer) guard never did — so every gated @api method denied Bearer requests.
|
||||
* establishApiUserSession() makes the API auth path uniform: any guard that resolves a user has the
|
||||
* same userdata built from the canonical user row, through the same setApiUserSession() builder.
|
||||
*
|
||||
* This tests the middleware's responsibility — resolve the user id, fetch the canonical row, and
|
||||
* hand it to the session builder, idempotently. The builder itself is covered by ApiServiceTest.
|
||||
*/
|
||||
class AuthCheckTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* A request whose user() resolver returns an object with the given id — i.e. a guard (Sanctum
|
||||
* or x-api-key) has authenticated, but userdata has not been established yet.
|
||||
*/
|
||||
private function apiRequestForUser(int $userId): IncomingRequest
|
||||
{
|
||||
$request = IncomingRequest::create('/api/jsonrpc', 'POST');
|
||||
$request->setUserResolver(fn () => (object) ['id' => $userId]);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/** Invoke the protected establishApiUserSession() on a constructor-less AuthCheck. */
|
||||
private function establish(IncomingRequest $request): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
(fn () => $this->establishApiUserSession($request))->call($authCheck);
|
||||
}
|
||||
|
||||
public function test_establishes_userdata_from_the_canonical_row_when_missing(): void
|
||||
{
|
||||
session()->forget('userdata');
|
||||
|
||||
$row = ['id' => 42, 'firstname' => 'Gloria', 'role' => 20];
|
||||
|
||||
app()->instance(Users::class, $this->make(Users::class, [
|
||||
'getUser' => fn ($id = null) => (int) $id === 42 ? $row : false,
|
||||
]));
|
||||
|
||||
$captured = null;
|
||||
app()->instance(Api::class, $this->make(Api::class, [
|
||||
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$captured) {
|
||||
$captured = ['user' => $user, 'external' => $isExternalAuth];
|
||||
},
|
||||
]));
|
||||
|
||||
$this->establish($this->apiRequestForUser(42));
|
||||
|
||||
$this->assertSame($row, $captured['user'] ?? null, 'the canonical row must be handed to the session builder');
|
||||
$this->assertTrue($captured['external'] ?? false, 'API sessions are external auth');
|
||||
}
|
||||
|
||||
public function test_is_idempotent_when_userdata_already_exists(): void
|
||||
{
|
||||
// x-api-key (and stateful web) already populated userdata before this runs — leave it,
|
||||
// and never re-resolve the user.
|
||||
session(['userdata' => ['id' => 7, 'role' => 'admin']]);
|
||||
|
||||
app()->instance(Users::class, $this->make(Users::class, [
|
||||
'getUser' => function ($id = null) {
|
||||
$this->fail('must not re-resolve the user when userdata already exists');
|
||||
},
|
||||
]));
|
||||
|
||||
$called = false;
|
||||
app()->instance(Api::class, $this->make(Api::class, [
|
||||
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$called) {
|
||||
$called = true;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->establish($this->apiRequestForUser(42));
|
||||
|
||||
$this->assertFalse($called, 'must not rebuild an already-established session');
|
||||
$this->assertSame(7, session('userdata.id'), 'existing userdata must be left untouched');
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile SSO exchange (/oidc/mobile/exchange) arrives with no session
|
||||
* cookie — the validated one-time code + PKCE verifier are the authorization —
|
||||
* so it must be allow-listed as public. Guards that allow-list from regressing.
|
||||
*/
|
||||
public function test_oidc_mobile_exchange_is_a_public_route(): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
|
||||
$this->assertTrue(
|
||||
$authCheck->isPublicController('oidc.mobile.exchange'),
|
||||
'the mobile exchange endpoint must be public (no session at exchange time)'
|
||||
);
|
||||
|
||||
// Negative control: an oidc sub-route that is NOT allow-listed stays private.
|
||||
$this->assertFalse($authCheck->isPublicController('oidc.settings.save'));
|
||||
}
|
||||
|
||||
public function test_status_discovery_is_a_public_route(): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
|
||||
// The mobile app hits /status unauthenticated at connect time to discover
|
||||
// login methods, so the route must be public.
|
||||
$this->assertTrue($authCheck->isPublicController('status.index'));
|
||||
$this->assertTrue($authCheck->isPublicController('status'));
|
||||
}
|
||||
}
|
||||
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Leantime\Core\Middleware\StartSession;
|
||||
use ReflectionMethod;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for the optimistic session-concurrency strategy in
|
||||
* StartSession. The original blanket-locking existed because a no-lock version
|
||||
* lost session writes: two concurrent requests would each overwrite the whole
|
||||
* session blob, clobbering each other (e.g. a project switch reverted by a
|
||||
* background widget). The merge-on-write strategy must persist ONLY the keys a
|
||||
* request actually changed, re-reading the freshest state first, so a concurrent
|
||||
* writer's keys survive.
|
||||
*/
|
||||
class SessionMergeTest extends TestCase
|
||||
{
|
||||
private function middleware(): StartSession
|
||||
{
|
||||
return new StartSession(app('session'));
|
||||
}
|
||||
|
||||
private function invokeDiff(array $initial, array $current): array
|
||||
{
|
||||
$method = new ReflectionMethod(StartSession::class, 'diffSession');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($this->middleware(), $initial, $current);
|
||||
}
|
||||
|
||||
private function invokeMerge(Store $session, array $changed, array $removed): void
|
||||
{
|
||||
$method = new ReflectionMethod(StartSession::class, 'mergeSessionChanges');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->middleware(), $session, $changed, $removed);
|
||||
}
|
||||
|
||||
public function test_diff_detects_added_changed_and_removed_keys(): void
|
||||
{
|
||||
[$changed, $removed] = $this->invokeDiff(
|
||||
['currentProject' => 1, 'keep' => 'same', 'goingAway' => 'x'],
|
||||
['currentProject' => 2, 'keep' => 'same', 'brandNew' => 'y'],
|
||||
);
|
||||
|
||||
$this->assertSame(['currentProject' => 2, 'brandNew' => 'y'], $changed);
|
||||
$this->assertSame(['goingAway'], $removed);
|
||||
}
|
||||
|
||||
public function test_pure_read_produces_no_diff(): void
|
||||
{
|
||||
[$changed, $removed] = $this->invokeDiff(
|
||||
['currentProject' => 1, 'nested' => ['a' => 1]],
|
||||
['currentProject' => 1, 'nested' => ['a' => 1]],
|
||||
);
|
||||
|
||||
$this->assertSame([], $changed);
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The core race: request B loads the session, request A switches the project
|
||||
* and commits first, then B persists. B only changed `lastPage`, so the merge
|
||||
* must keep A's `currentProject = 2` rather than reverting it to the value B
|
||||
* originally loaded.
|
||||
*/
|
||||
public function test_merge_preserves_a_concurrent_writers_key(): void
|
||||
{
|
||||
$handler = new ArraySessionHandler(120);
|
||||
$name = 'leantime_session';
|
||||
// Store::setId() rejects ids that aren't 40-char alphanumeric and
|
||||
// generates a random one instead, so the id must be a valid session id
|
||||
// for the three stores to share state through the handler.
|
||||
$id = str_repeat('a', 40);
|
||||
|
||||
// Seed the persisted session.
|
||||
$seed = new Store($name, $handler, $id);
|
||||
$seed->start();
|
||||
$seed->put('currentProject', 1);
|
||||
$seed->put('userdata.id', 99);
|
||||
$seed->save();
|
||||
|
||||
// Request B starts and loads the current state.
|
||||
$requestB = new Store($name, $handler, $id);
|
||||
$requestB->start();
|
||||
$bInitial = $requestB->all();
|
||||
$requestB->put('lastPage', '/dashboard/home'); // B's only change
|
||||
|
||||
// Request A switches the project and commits BEFORE B persists.
|
||||
$requestA = new Store($name, $handler, $id);
|
||||
$requestA->start();
|
||||
$requestA->put('currentProject', 2);
|
||||
$requestA->save();
|
||||
|
||||
// B persists via the merge strategy (diff of B's change against B's snapshot).
|
||||
[$changed, $removed] = $this->invokeDiff($bInitial, $requestB->all());
|
||||
$this->invokeMerge($requestB, $changed, $removed);
|
||||
|
||||
// Read the final persisted state.
|
||||
$verify = new Store($name, $handler, $id);
|
||||
$verify->start();
|
||||
|
||||
$this->assertSame(2, $verify->get('currentProject'), 'concurrent project switch was clobbered');
|
||||
$this->assertSame('/dashboard/home', $verify->get('lastPage'), 'B\'s own write was lost');
|
||||
$this->assertSame(99, $verify->get('userdata.id'), 'untouched key was dropped');
|
||||
}
|
||||
|
||||
public function test_merge_applies_removed_keys(): void
|
||||
{
|
||||
$handler = new ArraySessionHandler(120);
|
||||
$name = 'leantime_session';
|
||||
$id = str_repeat('b', 40);
|
||||
|
||||
$seed = new Store($name, $handler, $id);
|
||||
$seed->start();
|
||||
$seed->put('currentIdeaCanvas', 5);
|
||||
$seed->put('currentProject', 3);
|
||||
$seed->save();
|
||||
|
||||
$request = new Store($name, $handler, $id);
|
||||
$request->start();
|
||||
$initial = $request->all();
|
||||
$request->forget('currentIdeaCanvas');
|
||||
|
||||
[$changed, $removed] = $this->invokeDiff($initial, $request->all());
|
||||
$this->invokeMerge($request, $changed, $removed);
|
||||
|
||||
$verify = new Store($name, $handler, $id);
|
||||
$verify->start();
|
||||
|
||||
$this->assertFalse($verify->has('currentIdeaCanvas'), 'removed key should not be persisted');
|
||||
$this->assertSame(3, $verify->get('currentProject'));
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Middleware\Updated;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
|
||||
/**
|
||||
* Guards the stale-session redirect loop: the Updated middleware caches the
|
||||
* db-version in the session and (before the fix) never re-read the database
|
||||
* once a value was cached. After an admin ran an update in THEIR session,
|
||||
* every other live session kept the old cached version, concluded "not
|
||||
* updated", and bounced between every page and /install/update until the
|
||||
* user's cookies were cleared.
|
||||
*
|
||||
* The fix self-heals: when a CACHED value would trigger the redirect, the
|
||||
* middleware re-reads the real version from the database first — one extra
|
||||
* query, only on the would-redirect path.
|
||||
*/
|
||||
class UpdatedTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function appSettings(string $codeVersion): void
|
||||
{
|
||||
$settings = new AppSettings;
|
||||
$settings->dbVersion = $codeVersion;
|
||||
app()->instance(AppSettings::class, $settings);
|
||||
}
|
||||
|
||||
/** @param array<int, string|false> $dbVersions consecutive getSetting('db-version') results */
|
||||
private function settingRepo(array $dbVersions, ?int &$reads = null): void
|
||||
{
|
||||
$reads = 0;
|
||||
app()->instance(SettingRepository::class, $this->make(SettingRepository::class, [
|
||||
// Mirrors the real signature so forwarded arguments can't ever
|
||||
// make the stub brittle.
|
||||
'getSetting' => function (string $type = 'db-version') use (&$reads, $dbVersions) {
|
||||
$value = $dbVersions[min($reads, count($dbVersions) - 1)];
|
||||
$reads++;
|
||||
|
||||
return $value;
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
/** Run the middleware; returns [response, nextWasCalled]. */
|
||||
private function handleRequest(): array
|
||||
{
|
||||
// The redirect path resolves Frontcontroller from the container; its
|
||||
// real constructor needs the full HTTP stack, so bind a bare instance
|
||||
// (its redirect()/getCurrentRoute() members are static and work as-is).
|
||||
app()->instance(
|
||||
\Leantime\Core\Controller\Frontcontroller::class,
|
||||
$this->make(\Leantime\Core\Controller\Frontcontroller::class)
|
||||
);
|
||||
|
||||
$called = false;
|
||||
$response = (new Updated)->handle(
|
||||
IncomingRequest::create('/dashboard/home', 'GET'),
|
||||
function () use (&$called) {
|
||||
$called = true;
|
||||
|
||||
return new \Symfony\Component\HttpFoundation\Response('ok');
|
||||
}
|
||||
);
|
||||
|
||||
return [$response, $called];
|
||||
}
|
||||
|
||||
public function test_stale_session_cache_self_heals_after_an_update_ran_elsewhere(): void
|
||||
{
|
||||
// Session still remembers 3.5.25 from before the admin upgraded; the
|
||||
// DATABASE already says 3.5.26 (matching the code). The middleware
|
||||
// must re-read and pass through — not redirect-loop the user.
|
||||
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.26'], $reads);
|
||||
|
||||
[, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertTrue($nextCalled, 'a session whose cache is stale but whose DB is current must pass through');
|
||||
$this->assertSame(1, $reads, 'the DB is consulted exactly once to heal the cache');
|
||||
$this->assertSame('3.5.26', session('dbVersion'), 'the healed version is re-cached');
|
||||
$this->assertTrue(session('isUpdated'));
|
||||
}
|
||||
|
||||
public function test_current_session_cache_passes_through_without_touching_the_db(): void
|
||||
{
|
||||
session(['dbVersion' => '3.5.26', 'isUpdated' => true]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.26'], $reads);
|
||||
|
||||
[, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertTrue($nextCalled);
|
||||
$this->assertSame(0, $reads, 'an up-to-date cached version costs zero settings reads');
|
||||
}
|
||||
|
||||
public function test_genuinely_outdated_install_still_redirects_to_update(): void
|
||||
{
|
||||
// Both the cache AND the database are behind the code: the redirect is
|
||||
// correct. The self-heal costs one confirming read, then redirects.
|
||||
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.25'], $reads);
|
||||
|
||||
[$response, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertFalse($nextCalled, 'a genuinely outdated install must not pass through');
|
||||
$this->assertSame(1, $reads);
|
||||
$this->assertStringContainsString('/install/update', $response->headers->get('Location') ?? '', 'the redirect still points at the updater');
|
||||
$this->assertFalse(session('isUpdated'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user