fix: final review wave — public routes, inbox 419, url schemes, deep_link, tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
@@ -40,3 +40,7 @@ POST-ом без авторизации.
|
|||||||
|
|
||||||
`core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy —
|
`core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy —
|
||||||
`deploy/Caddyfile.snippet` в хаб sekai.
|
`deploy/Caddyfile.snippet` в хаб sekai.
|
||||||
|
|
||||||
|
Compose в репозитории — dev-стек (`Dockerfile.dev`, `artisan serve`); прод-сборка
|
||||||
|
через `core/Dockerfile` (serversideup/php) описана, но не проверена — отдельный шаг
|
||||||
|
перед деплоем.
|
||||||
|
|||||||
@@ -36,4 +36,13 @@ enum EventState: string
|
|||||||
array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()),
|
array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function badgeValues(): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (self $s) => $s->value,
|
||||||
|
array_filter(self::cases(), fn (self $s) => $s->countsInBadge()),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class InboxController extends Controller
|
|||||||
/** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */
|
/** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */
|
||||||
public static function safeLink(?string $link): ?string
|
public static function safeLink(?string $link): ?string
|
||||||
{
|
{
|
||||||
return $link !== null && preg_match('#^(/(?!/)|https?://)#i', $link) === 1 ? $link : null;
|
return $link !== null && preg_match('#^(/(?![/\\\\])|https?://)#i', $link) === 1 ? $link : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function relative(int $diff): string
|
public static function relative(int $diff): string
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class ChannelsController extends Controller
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'type' => ['required', 'in:webhook'],
|
'type' => ['required', 'in:webhook'],
|
||||||
'config' => ['required', 'array'],
|
'config' => ['required', 'array'],
|
||||||
'config.deliver_url' => ['required', 'url'],
|
'config.deliver_url' => ['required', 'url:http,https'],
|
||||||
'config.presence_url' => ['required', 'url'],
|
'config.presence_url' => ['required', 'url:http,https'],
|
||||||
], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']);
|
], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']);
|
||||||
|
|
||||||
$channel = $user->channels()->create([
|
$channel = $user->channels()->create([
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ use App\Ingest\EventUpserter;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Source;
|
use App\Models\Source;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class EventsController extends Controller
|
class EventsController extends Controller
|
||||||
{
|
{
|
||||||
@@ -22,11 +24,14 @@ class EventsController extends Controller
|
|||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $request->attributes->get('user');
|
$user = $request->attributes->get('user');
|
||||||
|
$request->validate([
|
||||||
|
'state' => ['sometimes', Rule::in(array_map(fn ($s) => $s->value, EventState::cases()))],
|
||||||
|
]);
|
||||||
$states = $request->query('state')
|
$states = $request->query('state')
|
||||||
? [$request->query('state')]
|
? [$request->query('state')]
|
||||||
: EventState::nonTerminalValues();
|
: EventState::nonTerminalValues();
|
||||||
|
|
||||||
$events = $user->events()->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get();
|
$events = $user->events()->with('source')->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get();
|
||||||
|
|
||||||
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]);
|
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]);
|
||||||
}
|
}
|
||||||
@@ -66,7 +71,7 @@ class EventsController extends Controller
|
|||||||
'source_ref' => 'manual:'.Str::ulid(),
|
'source_ref' => 'manual:'.Str::ulid(),
|
||||||
'topic' => '',
|
'topic' => '',
|
||||||
'due_at' => $dueAt,
|
'due_at' => $dueAt,
|
||||||
'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
|
'fire_at' => CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
|
||||||
'after_due' => 'keep',
|
'after_due' => 'keep',
|
||||||
'payload' => array_filter([
|
'payload' => array_filter([
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Validator;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@ class ProfileController extends Controller
|
|||||||
$start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5);
|
$start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5);
|
||||||
$end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5);
|
$end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5);
|
||||||
if ($end >= $start) {
|
if ($end >= $start) {
|
||||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start',
|
'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ class ProfileController extends Controller
|
|||||||
'tz' => $user->tz,
|
'tz' => $user->tz,
|
||||||
'quiet_start' => substr($user->quiet_start, 0, 5),
|
'quiet_start' => substr($user->quiet_start, 0, 5),
|
||||||
'quiet_end' => substr($user->quiet_end, 0, 5),
|
'quiet_end' => substr($user->quiet_end, 0, 5),
|
||||||
'badge' => $user->events()->whereIn('state', [EventState::Today->value, EventState::Overdue->value])->count(),
|
'badge' => $user->events()->whereIn('state', EventState::badgeValues())->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class UpsertEventsRequest extends FormRequest
|
|||||||
'events.*.payload' => ['required', 'array'],
|
'events.*.payload' => ['required', 'array'],
|
||||||
'events.*.payload.title' => ['required', 'string'],
|
'events.*.payload.title' => ['required', 'string'],
|
||||||
'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'],
|
'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'],
|
||||||
'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?!/)|https?://)#'],
|
'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?![/\\\\])|https?://)#i'],
|
||||||
'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'],
|
'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'auth.source' => AuthenticateSource::class,
|
'auth.source' => AuthenticateSource::class,
|
||||||
'remote.user' => RemoteUser::class,
|
'remote.user' => RemoteUser::class,
|
||||||
]);
|
]);
|
||||||
$middleware->validateCsrfTokens(except: ['a/*', 'hooks/*']);
|
$middleware->validateCsrfTokens(except: ['a/*', 'hooks/*', 'me/heartbeat']);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const csrf = document.querySelector('meta[name=csrf-token]').content;
|
const csrf = document.querySelector('meta[name=csrf-token]').content;
|
||||||
const call = (method, url, body) => fetch(url, {
|
const call = async (method, url, body) => {
|
||||||
|
const r = await fetch(url, {
|
||||||
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
|
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
if (r.status === 419) { location.reload(); return r; }
|
||||||
|
return r;
|
||||||
|
};
|
||||||
const showErr = async (el, r) => {
|
const showErr = async (el, r) => {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
|
el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
|
||||||
@@ -49,7 +53,10 @@
|
|||||||
const card = btn.closest('.card');
|
const card = btn.closest('.card');
|
||||||
card.querySelectorAll('button').forEach(b => b.disabled = true);
|
card.querySelectorAll('button').forEach(b => b.disabled = true);
|
||||||
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
|
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
|
||||||
if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false);
|
if (r.ok) location.reload(); else if (r.status !== 419) {
|
||||||
|
card.querySelectorAll('button').forEach(b => b.disabled = false);
|
||||||
|
showErr(document.getElementById('card-err'), r);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Настройки
|
// Настройки
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padd
|
|||||||
<button class="btn primary" id="toggle-add">+ Добавить</button>
|
<button class="btn primary" id="toggle-add">+ Добавить</button>
|
||||||
<span class="num">05</span>
|
<span class="num">05</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="err" id="card-err" hidden></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -161,7 +162,7 @@ footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padd
|
|||||||
@foreach($channels as $ch)
|
@foreach($channels as $ch)
|
||||||
<div class="chrow">
|
<div class="chrow">
|
||||||
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
|
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
|
||||||
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span>
|
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.($ch['config']['chat_id'] ?? '—') : ($ch['config']['deliver_url'] ?? '—')) }}</span>
|
||||||
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
|
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ use App\Http\Controllers\Me\ChannelsController;
|
|||||||
use App\Http\Controllers\Me\EventsController as MeEvents;
|
use App\Http\Controllers\Me\EventsController as MeEvents;
|
||||||
use App\Http\Controllers\Me\ProfileController;
|
use App\Http\Controllers\Me\ProfileController;
|
||||||
use App\Http\Controllers\TelegramWebhookController;
|
use App\Http\Controllers\TelegramWebhookController;
|
||||||
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
|
||||||
// Публичные: без SSO, без CSRF (см. bootstrap/app.php)
|
// Публичные: без SSO, без CSRF (см. bootstrap/app.php), без сессии
|
||||||
Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack');
|
Route::withoutMiddleware([
|
||||||
Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done');
|
StartSession::class,
|
||||||
Route::post('/hooks/telegram', TelegramWebhookController::class);
|
ShareErrorsFromSession::class,
|
||||||
|
AddQueuedCookiesToResponse::class,
|
||||||
|
PreventRequestForgery::class,
|
||||||
|
])->middleware('throttle:60,1')->group(function () {
|
||||||
|
Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack');
|
||||||
|
Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done');
|
||||||
|
Route::post('/hooks/telegram', TelegramWebhookController::class);
|
||||||
|
});
|
||||||
|
|
||||||
// Под SSO хаба
|
// Под SSO хаба
|
||||||
Route::middleware('remote.user')->group(function () {
|
Route::middleware('remote.user')->group(function () {
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class ActionLinksTest extends TestCase
|
|||||||
$this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String());
|
$this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_public_route_sets_no_session_cookie(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/a/tok/ack')->assertOk()->assertHeaderMissing('Set-Cookie');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_done_by_token_then_gone(): void
|
public function test_done_by_token_then_gone(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done');
|
$this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done');
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class ChannelsApiTest extends TestCase
|
|||||||
// telegram подключается только через /start, не через API
|
// telegram подключается только через /start, не через API
|
||||||
$this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as)
|
$this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as)
|
||||||
->assertStatus(422);
|
->assertStatus(422);
|
||||||
|
// только http/https-схемы
|
||||||
|
$this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'ftp://x/y', 'presence_url' => 'ftp://x/y']], $this->as)
|
||||||
|
->assertStatus(422)->assertJsonValidationErrors(['config.deliver_url', 'config.presence_url']);
|
||||||
|
$this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'http://home.local/x', 'presence_url' => 'http://home.local/x']], $this->as)
|
||||||
|
->assertCreated();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void
|
public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void
|
||||||
|
|||||||
@@ -93,11 +93,18 @@ class ClientApiTest extends TestCase
|
|||||||
$this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth())
|
$this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth())
|
||||||
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']);
|
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']);
|
||||||
|
|
||||||
|
$backslash = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/\evil.com']]);
|
||||||
|
$this->putJson('/api/events', ['events' => [$backslash]], $this->auth())
|
||||||
|
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']);
|
||||||
|
|
||||||
$ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]);
|
$ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]);
|
||||||
$this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk();
|
$this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk();
|
||||||
|
|
||||||
$ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]);
|
$ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]);
|
||||||
$this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk();
|
$this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk();
|
||||||
|
|
||||||
|
$ok3 = $this->item(['topic' => 'birthday:2028', 'payload' => ['title' => 'x', 'deep_link' => 'HTTPS://docs.example/x']]);
|
||||||
|
$this->putJson('/api/events', ['events' => [$ok3]], $this->auth())->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_topic_defaults_to_empty_string(): void
|
public function test_topic_defaults_to_empty_string(): void
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ class InboxPageTest extends TestCase
|
|||||||
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
|
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
|
||||||
$this->assertStringContainsString('/inbox.js', $html);
|
$this->assertStringContainsString('/inbox.js', $html);
|
||||||
$this->assertStringContainsString('csrf-token', $html);
|
$this->assertStringContainsString('csrf-token', $html);
|
||||||
|
// инлайн-ошибка карточки и обработка 419 в клиентском скрипте
|
||||||
|
$this->assertStringContainsString('card-err', $html);
|
||||||
|
$this->assertStringContainsString('419', file_get_contents(public_path('inbox.js')));
|
||||||
|
// Запланировано — витрина, без действий «Помню»/«Сделано»
|
||||||
|
$this->assertStringContainsString('>Помню<', $html);
|
||||||
|
$scheduledSection = substr($html, (int) strpos($html, '予定'));
|
||||||
|
$this->assertStringNotContainsString('>Помню<', $scheduledSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_counter_hidden_when_zero(): void
|
public function test_counter_hidden_when_zero(): void
|
||||||
@@ -106,12 +113,20 @@ class InboxPageTest extends TestCase
|
|||||||
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'],
|
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'],
|
||||||
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||||||
]);
|
]);
|
||||||
|
Event::create([
|
||||||
|
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'c',
|
||||||
|
'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04',
|
||||||
|
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Обратный слэш', 'deep_link' => '/\evil.com'],
|
||||||
|
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||||||
|
]);
|
||||||
|
|
||||||
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
|
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
|
||||||
|
|
||||||
$this->assertStringNotContainsString('href="javascript:', $html);
|
$this->assertStringNotContainsString('href="javascript:', $html);
|
||||||
$this->assertStringNotContainsString('href="//evil', $html);
|
$this->assertStringNotContainsString('href="//evil', $html);
|
||||||
|
$this->assertStringNotContainsString('href="/\\', $html);
|
||||||
$this->assertStringContainsString('Опасное событие', $html);
|
$this->assertStringContainsString('Опасное событие', $html);
|
||||||
$this->assertStringContainsString('Протокольная ссылка', $html);
|
$this->assertStringContainsString('Протокольная ссылка', $html);
|
||||||
|
$this->assertStringContainsString('Обратный слэш', $html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class MeApiTest extends TestCase
|
|||||||
|
|
||||||
$this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a');
|
$this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a');
|
||||||
$this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b');
|
$this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b');
|
||||||
|
$this->getJson('/me/events?state=garbage', $this->as())->assertStatus(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ack_sets_quiet_until_and_done_closes(): void
|
public function test_ack_sets_quiet_until_and_done_closes(): void
|
||||||
|
|||||||
@@ -110,5 +110,6 @@ class SchemaTest extends TestCase
|
|||||||
$this->assertTrue(EventState::Overdue->countsInBadge());
|
$this->assertTrue(EventState::Overdue->countsInBadge());
|
||||||
$this->assertFalse(EventState::Preparing->countsInBadge());
|
$this->assertFalse(EventState::Preparing->countsInBadge());
|
||||||
$this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues());
|
$this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues());
|
||||||
|
$this->assertSame(['today', 'overdue'], EventState::badgeValues());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class TelegramWebhookTest extends TestCase
|
|||||||
$this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403);
|
$this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_public_route_sets_no_session_cookie(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/hooks/telegram', ['message' => []])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertHeaderMissing('Set-Cookie');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_start_with_code_links_chat(): void
|
public function test_start_with_code_links_chat(): void
|
||||||
{
|
{
|
||||||
Cache::put('tg:link:ABC123', $this->user->id, 900);
|
Cache::put('tg:link:ABC123', $this->user->id, 900);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Channels\ChannelRegistry;
|
use App\Channels\ChannelRegistry;
|
||||||
|
use App\Delivery\DeliverJob;
|
||||||
use App\Delivery\Tick;
|
use App\Delivery\Tick;
|
||||||
use App\Enums\AfterDue;
|
use App\Enums\AfterDue;
|
||||||
use App\Enums\ChannelType;
|
use App\Enums\ChannelType;
|
||||||
@@ -20,6 +21,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Tests\Support\FakeChannel;
|
use Tests\Support\FakeChannel;
|
||||||
use Tests\Support\FakeRegistry;
|
use Tests\Support\FakeRegistry;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -29,9 +31,13 @@ class TickTest extends TestCase
|
|||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private User $user;
|
private User $user;
|
||||||
|
|
||||||
private Source $source;
|
private Source $source;
|
||||||
|
|
||||||
private FakeChannel $web;
|
private FakeChannel $web;
|
||||||
|
|
||||||
private FakeChannel $telegram;
|
private FakeChannel $telegram;
|
||||||
|
|
||||||
private FakeChannel $webhook;
|
private FakeChannel $webhook;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -105,6 +111,18 @@ class TickTest extends TestCase
|
|||||||
$this->assertSame(2, Delivery::count(), 'новое окно — новая доставка');
|
$this->assertSame(2, Delivery::count(), 'новое окно — новая доставка');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_pending_row_exists_before_job_runs(): void
|
||||||
|
{
|
||||||
|
Queue::fake();
|
||||||
|
$this->event('2026-09-30', '2026-08-31');
|
||||||
|
$this->webhook->presence = Presence::Present;
|
||||||
|
|
||||||
|
$this->tickAt('2026-09-05T10:00:00Z');
|
||||||
|
|
||||||
|
$this->assertSame(DeliveryResult::Pending, Delivery::sole()->result);
|
||||||
|
Queue::assertPushed(DeliverJob::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_falls_back_to_all_channels_near_window_end(): void
|
public function test_falls_back_to_all_channels_near_window_end(): void
|
||||||
{
|
{
|
||||||
$this->event('2026-09-30', '2026-08-31');
|
$this->event('2026-09-30', '2026-08-31');
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ PUT /api/events
|
|||||||
`HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние,
|
`HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние,
|
||||||
чтобы вхождения не копились вечно.
|
чтобы вхождения не копились вечно.
|
||||||
|
|
||||||
|
Реализация считает срок от `updated_at`; повторный upsert закрытого события
|
||||||
|
продлевает хранение — допустимое приближение.
|
||||||
|
|
||||||
### Снятие и чтение
|
### Снятие и чтение
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -467,6 +470,10 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
|||||||
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
|
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
|
||||||
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
|
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
|
||||||
|
|
||||||
|
Compose в репозитории — dev-стек (`Dockerfile.dev`, `artisan serve`); прод-сборка
|
||||||
|
через `core/Dockerfile` (serversideup/php) описана, но не проверена — отдельный шаг
|
||||||
|
перед деплоем.
|
||||||
|
|
||||||
## 13. Тесты
|
## 13. Тесты
|
||||||
|
|
||||||
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
|
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
|
||||||
|
|||||||
Reference in New Issue
Block a user