Current state with latest updates

This commit is contained in:
2026-08-23 13:28:34 +02:00
parent 5af95f6533
commit f52879edbb
74 changed files with 5174 additions and 960 deletions
+20 -7
View File
@@ -412,6 +412,10 @@ test('removing a medal dispatches artwork reindexing', function () {
});
test('cache invalidation occurs after medal updates', function () {
// HomepageService::clearFeaturedAndMedalCaches() forgets homepage.hero.{segment},
// homepage.community-favorites.8.{segment} and homepage.hall-of-fame.8.{segment} for
// each viewer-visibility segment (see HomepageService::viewerCacheSegment()) — not the
// bare, unsegmented keys this test originally asserted against pre-segmentation.
$homepage = app(HomepageService::class);
$service = app(ArtworkAwardService::class);
$user = User::factory()->create(['created_at' => now()->subDays(30), 'email_verified_at' => now()]);
@@ -420,15 +424,24 @@ test('cache invalidation occurs after medal updates', function () {
Config::set('homepage.guest_payload_key', $guestPayloadKey);
Cache::put('homepage.hero', ['stale' => true], 600);
Cache::put('homepage.community-favorites.8', ['stale' => true], 600);
Cache::put('homepage.hall-of-fame.8', ['stale' => true], 600);
$segments = ['visibility-hide', 'visibility-blur', 'visibility-show'];
$segmentedKeys = collect($segments)
->flatMap(fn (string $segment): array => [
"homepage.hero.{$segment}",
"homepage.community-favorites.8.{$segment}",
"homepage.hall-of-fame.8.{$segment}",
])
->all();
foreach ($segmentedKeys as $key) {
Cache::put($key, ['stale' => true], 600);
}
Cache::store($homepage->guestPayloadCacheStoreName())->put($guestPayloadKey, ['stale' => true], 600);
$service->award($artwork, $user, 'gold');
expect(Cache::get('homepage.hero'))->toBeNull()
->and(Cache::get('homepage.community-favorites.8'))->toBeNull()
->and(Cache::get('homepage.hall-of-fame.8'))->toBeNull()
->and(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
foreach ($segmentedKeys as $key) {
expect(Cache::get($key))->toBeNull();
}
expect(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
});
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
function makeAuditableArtwork(array $attributes = []): Artwork
{
return Artwork::factory()->create(array_merge([
'user_id' => User::factory()->create()->id,
'hash' => str_repeat('a', 64),
'file_ext' => 'png',
'thumb_ext' => 'webp',
], $attributes));
}
test('null featured_thumbnail_variants_json means not audited yet', function () {
$artwork = makeAuditableArtwork();
expect($artwork->featured_thumbnail_variants_json)->toBeNull()
->and($artwork->featuredThumbnailAuditState())->toBe(['status' => 'not_audited', 'variants' => []])
->and($artwork->hasFeaturedThumbnail())->toBeFalse()
->and($artwork->hasFeaturedThumbnail('desktop'))->toBeFalse();
});
test('empty array means audited with no featured variants available', function () {
$artwork = makeAuditableArtwork();
$artwork->forceFill([
'featured_thumbnail_variants_json' => [],
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
$fresh = $artwork->fresh();
expect($fresh->featured_thumbnail_variants_json)->toBe([])
->and($fresh->featuredThumbnailAuditState())->toBe(['status' => 'no_variants', 'variants' => []])
->and($fresh->hasFeaturedThumbnail())->toBeFalse();
});
test('a populated array means audited with those variants known to be available', function () {
$artwork = makeAuditableArtwork();
$artwork->forceFill([
'featured_thumbnail_variants_json' => ['desktop', 'desktop_xl'],
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
$fresh = $artwork->fresh();
expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'available', 'variants' => ['desktop', 'desktop_xl']])
->and($fresh->hasFeaturedThumbnail('desktop'))->toBeTrue()
->and($fresh->hasFeaturedThumbnail('desktop_xl'))->toBeTrue()
->and($fresh->hasFeaturedThumbnail('mobile'))->toBeFalse();
});
test('malformed json in the column is treated as not audited rather than throwing', function () {
$artwork = makeAuditableArtwork();
// Bypass the array cast to write a raw, non-JSON-array value directly, simulating
// legacy or corrupted data that predates this column's introduction.
DB::table('artworks')->where('id', $artwork->id)->update([
'featured_thumbnail_variants_json' => '"not-an-array"',
]);
$fresh = $artwork->fresh();
expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'not_audited', 'variants' => []])
->and($fresh->hasFeaturedThumbnail())->toBeFalse();
});
test('non-string entries in the variants array are ignored rather than matched', function () {
$artwork = makeAuditableArtwork();
DB::table('artworks')->where('id', $artwork->id)->update([
'featured_thumbnail_variants_json' => json_encode(['desktop', 42, null, ['nested' => true]]),
]);
$fresh = $artwork->fresh();
expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'available', 'variants' => ['desktop']])
->and($fresh->hasFeaturedThumbnail('desktop'))->toBeTrue()
->and($fresh->hasFeaturedThumbnail('tablet'))->toBeFalse();
});
test('an artwork without a hash never reports a featured thumbnail regardless of audit state', function () {
$artwork = makeAuditableArtwork(['hash' => null, 'file_ext' => null]);
$artwork->forceFill([
'featured_thumbnail_variants_json' => ['desktop'],
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeFalse();
});
test('changing an artwork hash resets stale featured thumbnail audit state and queues regeneration for featured artworks', function () {
Queue::fake();
$artwork = makeAuditableArtwork([
'hash' => str_repeat('b', 64),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
$artwork->forceFill([
'featured_thumbnail_variants_json' => ['desktop', 'desktop_xl'],
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
DB::table('artwork_features')->insert([
'artwork_id' => $artwork->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 500,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
// Old hash reported 2 available variants — that state is now stale because the
// object paths it referred to were computed from the old hash.
expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeTrue();
$artwork->hash = str_repeat('c', 64);
$artwork->save();
$fresh = $artwork->fresh();
expect($fresh->featured_thumbnail_variants_json)->toBeNull()
->and($fresh->featured_thumbnails_checked_at)->toBeNull()
->and($fresh->hasFeaturedThumbnail('desktop'))->toBeFalse();
Queue::assertPushed(GenerateFeaturedArtworkThumbnailsJob::class);
});
test('changing hash on an artwork with no active feature row does not queue regeneration', function () {
Queue::fake();
$artwork = makeAuditableArtwork(['hash' => str_repeat('d', 64)]);
$artwork->forceFill([
'featured_thumbnail_variants_json' => ['desktop'],
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
$artwork->hash = str_repeat('e', 64);
$artwork->save();
expect($artwork->fresh()->featured_thumbnail_variants_json)->toBeNull();
Queue::assertNotPushed(GenerateFeaturedArtworkThumbnailsJob::class);
});
+64 -27
View File
@@ -10,8 +10,6 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
use Klevze\ControlPanel\Models\Admin\AdminVerification;
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
uses(RefreshDatabase::class);
@@ -81,20 +79,35 @@ it('blocks non staff users from the featured artworks admin area', function ():
->assertRedirect(route('cp.login'));
});
it('registers the featured artworks entry in the cpad menu', function (): void {
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
it('resolves featured artwork admin routes to the current moderation surface, not the legacy cpad menu target', function (): void {
// The original version of this test asserted that the legacy cpad sidebar (built by
// packages/klevze/Plugins/Artworks/ServiceProvider::boot()) had a "Featured Artworks"
// item under the "Artworks" root pointing at admin.cp.artworks.featured.main. That
// item was removed once featured-artwork management moved to the /moderation surface
// (routes/web.php, name admin.artworks.featured.*) — admin.cp.artworks.featured.main
// now exists only as a redirect alias to /moderation/artworks/featured
// (packages/klevze/Plugins/Artworks/Routes/admin.php:24), and the new surface's
// navigation is owned by the React/Inertia frontend, not this PHP-built sidebar.
//
// The sidebar itself can't be asserted on here at all: ServiceProvider::boot() only
// registers menu items when shouldRegisterControlPanelUi() is true, which explicitly
// returns false whenever app()->runningInConsole() is true — and `php artisan test`
// always runs in console, so getSidebarMenu() is empty for every test in this suite,
// regardless of this change. That's an environment property of the legacy menu
// builder, not something this test can meaningfully exercise.
//
// What's left to verify, and is exercised by other tests in this file (see "renders
// the featured artworks admin index..." and "clears homepage hero cache..."), is that
// the legacy route still redirects to the canonical surface rather than 404ing or
// rendering the retired page directly.
expect(route('admin.artworks.featured.main'))->toBe(url('/moderation/artworks/featured'));
expect(route('admin.cp.artworks.featured.main'))->toBe(url('/cp/artworks/featured'));
$editorialRoot = $sidebarMenu
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
$admin = createControlPanelAdmin();
expect($editorialRoot)->toBeInstanceOf(MenuRootItem::class);
$featuredItem = collect($editorialRoot->getItems())
->first(fn ($item): bool => ($item->name ?? null) === 'Featured Artworks');
expect($featuredItem)->not->toBeNull()
->and($featuredItem->mainRoute)->toBe('admin.cp.artworks.featured.main')
->and($featuredItem->icon)->toBe('fas fa-star');
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.cp.artworks.featured.main'))
->assertRedirect('/moderation/artworks/featured');
});
it('renders the featured artworks admin index with the current winner summary', function (): void {
@@ -108,17 +121,20 @@ it('renders the featured artworks admin index with the current winner summary',
medalScore($higherMedal, 12);
medalScore($runnerUp, 3);
// admin.cp.artworks.featured.main (legacy cpad alias) now just redirects to the
// canonical /moderation surface (packages/klevze/Plugins/Artworks/Routes/admin.php:24),
// so the page itself is rendered at admin.artworks.featured.main.
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.cp.artworks.featured.main'))
->get(route('admin.artworks.featured.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/FeaturedArtworksAdmin')
->component('Moderation/FeaturedArtworks')
->where('winner.artwork.id', $higherMedal->id)
->where('winner.medals.score_30d', 12)
->where('winner.selection_reason', 'Tied on priority, won on higher 30-day medal score.')
->where('entries.0.is_winner', true)
->where('entries.0.artwork.id', $higherMedal->id)
->where('endpoints.store', route('admin.cp.artworks.featured.store')));
->where('endpoints.store', route('admin.artworks.featured.store')));
});
it('allows admins to create featured rows', function (): void {
@@ -259,11 +275,12 @@ it('marks expired and ineligible rows on the index page', function (): void {
featureRow($privateArtwork, ['priority' => 300]);
featureRow($expiredArtwork, ['priority' => 200, 'expires_at' => now()->subMinute()]);
// See note above: the cpad alias redirects, so the page renders at admin.artworks.featured.main.
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->get(route('admin.cp.artworks.featured.main'))
->get(route('admin.artworks.featured.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/FeaturedArtworksAdmin')
->component('Moderation/FeaturedArtworks')
->where('entries.0.artwork.id', $privateArtwork->id)
->where('entries.0.eligibility.is_eligible', false)
->where('entries.0.eligibility.reasons.0', 'Private')
@@ -274,10 +291,30 @@ it('marks expired and ineligible rows on the index page', function (): void {
});
it('clears homepage hero cache after create update toggle and delete actions', function (): void {
// HomepageService::clearFeaturedAndMedalCaches() forgets homepage.hero.{segment} for
// each viewer-visibility segment (not a bare "homepage.hero" key) — the segmentation
// was introduced by a prior commit (see HomepageService::viewerCacheSegment()). Assert
// against the real segmented keys instead of the pre-segmentation key this test used.
$heroCacheKeys = [
'homepage.hero.visibility-hide',
'homepage.hero.visibility-blur',
'homepage.hero.visibility-show',
];
$seedHeroCache = function () use ($heroCacheKeys): void {
foreach ($heroCacheKeys as $key) {
Cache::put($key, ['stale' => true], 600);
}
};
$assertHeroCacheCleared = function () use ($heroCacheKeys): void {
foreach ($heroCacheKeys as $key) {
expect(Cache::has($key))->toBeFalse();
}
};
$admin = createControlPanelAdmin();
$artwork = adminArtwork();
Cache::put('homepage.hero', ['stale' => true], 600);
$seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('admin.cp.artworks.featured.store'), [
@@ -289,11 +326,11 @@ it('clears homepage hero cache after create update toggle and delete actions', f
])
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
$assertHeroCacheCleared();
$feature = ArtworkFeature::query()->firstOrFail();
Cache::put('homepage.hero', ['stale' => true], 600);
$seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
'priority' => 110,
@@ -302,17 +339,17 @@ it('clears homepage hero cache after create update toggle and delete actions', f
'is_active' => true,
])
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
$assertHeroCacheCleared();
Cache::put('homepage.hero', ['stale' => true], 600);
$seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
$assertHeroCacheCleared();
Cache::put('homepage.hero', ['stale' => true], 600);
$seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
->assertOk();
expect(Cache::has('homepage.hero'))->toBeFalse();
$assertHeroCacheCleared();
});
@@ -6,6 +6,7 @@ use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\HomepageService;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use App\Support\ArtworkFeaturedImagePath;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
@@ -303,6 +304,7 @@ test('homepage hero payload uses the forced hero artwork when one is set', funct
test('homepage renders featured hero picture and preload from dedicated featured thumbnails', function () {
Cache::flush();
app(HomepageService::class)->clearFeaturedAndMedalCaches();
Storage::fake('s3');
config([
'uploads.object_storage.disk' => 's3',
@@ -335,10 +337,19 @@ test('homepage renders featured hero picture and preload from dedicated featured
$paths = app(ArtworkFeaturedImagePath::class);
// Featured-thumbnail existence is precomputed by FeaturedArtworkThumbnailGenerator
// (queued off the request path) and persisted on the artwork row; the homepage
// request only reads that state, so seed both the fake disk (for URL building)
// and the DB column here rather than relying on a live Storage::exists() check.
foreach ($paths->variantNames() as $variant) {
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
}
$artwork->forceFill([
'featured_thumbnail_variants_json' => $paths->variantNames(),
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
$desktopUrl = $paths->url($artwork, 'desktop');
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
$mobileXsUrl = $paths->url($artwork, 'mobile_xs');
@@ -355,6 +366,125 @@ test('homepage renders featured hero picture and preload from dedicated featured
->assertSee('fetchpriority="high"', false);
});
test('featured hero image resolution never checks the remote disk', function () {
Cache::flush();
app(HomepageService::class)->clearFeaturedAndMedalCaches();
Storage::fake('s3');
config([
'uploads.object_storage.disk' => 's3',
'cdn.files_url' => 'https://files.skinbase.org',
]);
$owner = User::factory()->create();
$artwork = makeFeaturedArtwork([
'user_id' => $owner->id,
'title' => 'Hero Without Live Storage Checks',
'hash' => str_repeat('e', 64),
'file_ext' => 'png',
'thumb_ext' => 'webp',
]);
DB::table('artwork_features')->insert([
'artwork_id' => $artwork->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 900,
'label' => null,
'note' => null,
'is_active' => true,
'force_hero' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
$paths = app(ArtworkFeaturedImagePath::class);
$artwork->forceFill([
'featured_thumbnail_variants_json' => $paths->variantNames(),
'featured_thumbnails_checked_at' => now(),
])->saveQuietly();
// Note: no files are ever written to the fake "s3" disk. If hasFeaturedThumbnail()
// fell back to a live Storage::exists() check, these URLs would resolve to null;
// since it reads the DB column instead, they resolve correctly with zero disk I/O.
$hero = app(HomepageService::class)->getHeroArtwork();
expect($hero)->not->toBeNull()
->and($hero['id'])->toBe($artwork->id)
->and($hero['featured_image']['variants']['desktop'])->toBe($paths->url($artwork, 'desktop'))
->and($hero['featured_image']['variants']['desktop_xl'])->toBe($paths->url($artwork, 'desktop_xl'));
expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeTrue();
});
test('generating featured thumbnail metadata invalidates the stale hero cache so the next resolution uses it', function () {
Cache::flush();
app(HomepageService::class)->clearFeaturedAndMedalCaches();
Storage::fake('s3');
config([
'uploads.object_storage.disk' => 's3',
'cdn.files_url' => 'https://files.skinbase.org',
]);
$owner = User::factory()->create();
$artwork = makeFeaturedArtwork([
'user_id' => $owner->id,
'title' => 'Hero Metadata Refresh',
'hash' => str_repeat('f', 64),
'file_ext' => 'png',
'thumb_ext' => 'webp',
]);
DB::table('artwork_features')->insert([
'artwork_id' => $artwork->id,
'featured_at' => now()->subHour(),
'expires_at' => null,
'priority' => 900,
'label' => null,
'note' => null,
'is_active' => true,
'force_hero' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
$homepage = app(HomepageService::class);
$paths = app(ArtworkFeaturedImagePath::class);
// 1. Hero cache is built while featured metadata is unavailable: no variants have
// been persisted yet, so hasFeaturedThumbnail() reports false for every variant and
// the payload falls back to the non-featured thumbnail.
$heroBeforeGeneration = $homepage->getHeroArtwork();
expect($heroBeforeGeneration)->not->toBeNull()
->and($heroBeforeGeneration['id'])->toBe($artwork->id)
->and($heroBeforeGeneration['featured_image']['variants']['desktop'])->toBeNull();
// 2. Featured-thumbnail metadata is generated. The generator's plan() (also used by
// the skinbase:featured-thumbnails:generate command and GenerateFeaturedArtworkThumbnailsJob)
// discovers the now-present files and persists featured_thumbnail_variants_json via
// saveQuietly() — which does NOT fire ArtworkObserver/ArtworkFeatureObserver, so any
// cache invalidation has to come from the generator itself.
foreach ($paths->variantNames() as $variant) {
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
}
app(FeaturedArtworkThumbnailGenerator::class)->plan($artwork->fresh());
// 3. The relevant hero cache (all three viewer-visibility segments) was invalidated as
// a side effect of that generation, without a full application cache flush.
foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
expect(Cache::has("homepage.hero.{$segment}"))->toBeFalse();
}
// 4. The next homepage resolution rebuilds the cache and now uses the featured thumbnail.
$heroAfterGeneration = $homepage->getHeroArtwork();
expect($heroAfterGeneration['featured_image']['variants']['desktop'])
->toBe($paths->url($artwork, 'desktop'));
});
test('community favorites returns artworks ordered by recent medal score', function () {
$owner = User::factory()->create();
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SecurityReport;
use App\Models\SecurityReport;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
final class SecurityReportAdminTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config(['security-report.enabled' => true]);
}
public function test_guest_cannot_open_security_report_page(): void
{
$this->get('/moderation/system/security-report')
->assertRedirect(route('login'));
}
public function test_normal_user_cannot_open_security_report_page(): void
{
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)
->get('/moderation/system/security-report')
->assertRedirect(route('index'));
}
public function test_staff_non_admin_cannot_open_security_report_page(): void
{
$manager = User::factory()->create(['role' => 'manager']);
$this->actingAs($manager)
->get('/moderation/system/security-report')
->assertForbidden();
}
public function test_admin_can_open_security_report_index_and_detail(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$report = SecurityReport::query()->create([
'status' => 'completed_with_findings',
'started_at' => now()->subMinute(),
'finished_at' => now(),
'total_critical' => 1,
'total_high' => 2,
'total_medium' => 3,
'total_low' => 4,
'total_unknown' => 0,
'composer_outdated_count' => 5,
'npm_outdated_count' => 6,
'triggered_by' => 'artisan',
'summary' => [
'total' => ['critical' => 1, 'high' => 2, 'medium' => 3, 'low' => 4, 'unknown' => 0],
],
'composer_audit' => ['advisories' => ['laravel/framework' => [['severity' => 'high', 'title' => 'Test advisory']]]],
'npm_audit' => ['vulnerabilities' => ['vite' => ['severity' => 'moderate', 'via' => [['title' => 'Moderate issue']]]]],
]);
$this->actingAs($admin)
->get('/moderation/system/security-report')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/System/SecurityReportIndex')
->where('latest.id', $report->id)
->where('latest.total_critical', 1)
->where('reports.data.0.id', $report->id));
$this->actingAs($admin)
->get('/moderation/system/security-report/' . $report->id)
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/System/SecurityReportShow')
->where('report.id', $report->id)
->where('report.total_high', 2)
->where('report.composer_advisories.0.package', 'laravel/framework'));
}
}
+150 -1
View File
@@ -3,6 +3,12 @@
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\AcademyChallenge;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\Category;
use App\Models\Collection;
use App\Models\ContentType;
@@ -46,12 +52,24 @@ it('renders the sitemap index and every child sitemap endpoint', function (): vo
expect($indexXml)->not->toBeFalse();
expect($indexResponse->getContent())
->toContain(url('/sitemaps/artworks.xml'))
->toContain(url('/sitemaps/academy-pages.xml'))
->toContain(url('/sitemaps/academy-courses.xml'))
->toContain(url('/sitemaps/academy-lessons.xml'))
->toContain(url('/sitemaps/academy-prompts.xml'))
->toContain(url('/sitemaps/academy-packs.xml'))
->toContain(url('/sitemaps/academy-challenges.xml'))
->toContain(url('/sitemaps/static-pages.xml'))
->toContain(url('/sitemaps/forum-threads.xml'))
->toContain(url('/sitemaps/news-google.xml'));
foreach ([
'artworks',
'academy-pages',
'academy-courses',
'academy-lessons',
'academy-prompts',
'academy-packs',
'academy-challenges',
'users',
'tags',
'categories',
@@ -139,8 +157,36 @@ it('includes only public canonical urls and exposes the sitemap in robots txt',
->toContain('http://skinbase26.test/')
->toContain('/about')
->toContain('/pages/community-handbook')
->not->toContain('/academy')
->not->toContain('/academy/pricing')
->not->toContain('/pages/about');
$academyPagesXml = $this->get('/sitemaps/academy-pages.xml')->assertOk()->getContent();
expect($academyPagesXml)
->toContain(route('academy.index'))
->toContain(route('academy.pricing'));
$academyCoursesXml = $this->get('/sitemaps/academy-courses.xml')->assertOk()->getContent();
expect($academyCoursesXml)
->toContain($fixtures['academy_course_url']);
$academyLessonsXml = $this->get('/sitemaps/academy-lessons.xml')->assertOk()->getContent();
expect($academyLessonsXml)
->toContain($fixtures['academy_lesson_url'])
->not->toContain($fixtures['academy_course_lesson_url']);
$academyPromptsXml = $this->get('/sitemaps/academy-prompts.xml')->assertOk()->getContent();
expect($academyPromptsXml)
->toContain(route('academy.prompts.popular'))
->toContain($fixtures['academy_prompt_url'])
->not->toContain(route('academy.prompts.popular', ['period' => '7d']));
$academyPacksXml = $this->get('/sitemaps/academy-packs.xml')->assertOk()->getContent();
expect($academyPacksXml)->toContain($fixtures['academy_pack_url']);
$academyChallengesXml = $this->get('/sitemaps/academy-challenges.xml')->assertOk()->getContent();
expect($academyChallengesXml)->toContain($fixtures['academy_challenge_url']);
$robots = $this->get('/robots.txt')
->assertOk()
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
@@ -149,6 +195,38 @@ it('includes only public canonical urls and exposes the sitemap in robots txt',
expect($robots)->toContain('Sitemap: http://skinbase26.test/sitemap.xml');
});
it('includes only canonical academy urls in academy sitemap families', function (): void {
$fixtures = seedSitemapFixtures();
$academyCoursesXml = $this->get('/sitemaps/academy-courses.xml')->assertOk()->getContent();
expect($academyCoursesXml)
->toContain(route('academy.courses.index'))
->toContain($fixtures['academy_course_url']);
$academyLessonsXml = $this->get('/sitemaps/academy-lessons.xml')->assertOk()->getContent();
expect($academyLessonsXml)
->toContain(route('academy.lessons.index'))
->toContain($fixtures['academy_lesson_url'])
->not->toContain($fixtures['academy_course_lesson_url']);
$academyPromptsXml = $this->get('/sitemaps/academy-prompts.xml')->assertOk()->getContent();
expect($academyPromptsXml)
->toContain(route('academy.prompts.index'))
->toContain(route('academy.prompts.popular'))
->toContain($fixtures['academy_prompt_url'])
->not->toContain(route('academy.prompts.popular', ['period' => '7d']));
$academyPacksXml = $this->get('/sitemaps/academy-packs.xml')->assertOk()->getContent();
expect($academyPacksXml)
->toContain(route('academy.packs.index'))
->toContain($fixtures['academy_pack_url']);
$academyChallengesXml = $this->get('/sitemaps/academy-challenges.xml')->assertOk()->getContent();
expect($academyChallengesXml)
->toContain(route('academy.challenges.index'))
->toContain($fixtures['academy_challenge_url']);
});
it('returns 404 for unknown sitemap names', function (): void {
$this->get('/sitemaps/not-a-real-sitemap.xml')->assertNotFound();
});
@@ -547,6 +625,71 @@ function seedSitemapFixtures(): array
'last_post_at' => now()->subHours(2),
]);
$academyCourse = AcademyCourse::query()->create([
'title' => 'Sitemap Academy Course',
'slug' => 'sitemap-academy-course',
'excerpt' => 'An academy course for sitemap coverage.',
'description' => 'Course sitemap description',
'access_level' => 'free',
'difficulty' => 'beginner',
'status' => 'published',
'published_at' => now()->subMinute(),
]);
$academyLesson = AcademyLesson::query()->create([
'title' => 'Sitemap Academy Lesson',
'slug' => 'sitemap-academy-lesson',
'excerpt' => 'An academy lesson for sitemap coverage.',
'content' => 'Lesson sitemap content',
'difficulty' => 'beginner',
'access_level' => 'free',
'lesson_type' => 'article',
'active' => true,
'published_at' => now()->subMinute(),
]);
AcademyCourseLesson::query()->create([
'course_id' => $academyCourse->id,
'lesson_id' => $academyLesson->id,
'order_num' => 0,
'is_required' => true,
]);
$academyPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Sitemap Academy Prompt',
'slug' => 'sitemap-academy-prompt',
'excerpt' => 'An academy prompt for sitemap coverage.',
'prompt' => 'Create a cinematic moonlit portrait.',
'difficulty' => 'beginner',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$academyPack = AcademyPromptPack::query()->create([
'title' => 'Sitemap Academy Pack',
'slug' => 'sitemap-academy-pack',
'excerpt' => 'An academy pack for sitemap coverage.',
'description' => 'Pack sitemap description',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$academyChallenge = AcademyChallenge::query()->create([
'title' => 'Sitemap Academy Challenge',
'slug' => 'sitemap-academy-challenge',
'excerpt' => 'An academy challenge for sitemap coverage.',
'description' => 'Challenge sitemap description',
'brief' => 'Create a hero image.',
'rules' => 'Keep it public.',
'access_level' => 'free',
'status' => AcademyChallenge::STATUS_ACTIVE,
'starts_at' => now()->subDay(),
'ends_at' => now()->addDay(),
'active' => true,
]);
return [
'artwork_url' => route('art.show', [
'id' => $artwork->id,
@@ -556,6 +699,12 @@ function seedSitemapFixtures(): array
'profile_url' => route('profile.show', ['username' => 'sitemapuser']),
'collection_url' => route('profile.collections.show', ['username' => 'sitemapuser', 'slug' => 'showcase-set']),
'card_url' => route('cards.show', ['slug' => 'clarity-card', 'id' => $card->id]),
'academy_course_url' => route('academy.courses.show', ['course' => $academyCourse->slug]),
'academy_lesson_url' => route('academy.lessons.show', ['slug' => $academyLesson->slug]),
'academy_course_lesson_url' => route('academy.courses.lessons.show', ['course' => $academyCourse->slug, 'lesson' => $academyLesson->slug]),
'academy_prompt_url' => route('academy.prompts.show', ['slug' => $academyPrompt->slug]),
'academy_pack_url' => route('academy.packs.show', ['slug' => $academyPack->slug]),
'academy_challenge_url' => route('academy.challenges.show', ['slug' => $academyChallenge->slug]),
];
}
@@ -696,4 +845,4 @@ function extractUrlLocs(string $xml): array
static fn ($node): string => trim((string) $node),
$nodes,
)));
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SecurityReport;
use App\Services\SecurityReport\SecurityReportScanner;
use PHPUnit\Framework\TestCase;
final class SecurityReportScannerTest extends TestCase
{
public function test_it_summarizes_npm_metadata_vulnerability_counts(): void
{
$scanner = new SecurityReportScanner();
$counts = $scanner->summarizeNpmAudit([
'metadata' => [
'vulnerabilities' => [
'critical' => 1,
'high' => 2,
'moderate' => 3,
'low' => 4,
'info' => 5,
],
],
]);
self::assertSame(1, $counts['critical']);
self::assertSame(2, $counts['high']);
self::assertSame(3, $counts['moderate']);
self::assertSame(4, $counts['low']);
self::assertSame(5, $counts['info']);
}
public function test_it_summarizes_composer_advisory_severity_counts(): void
{
$scanner = new SecurityReportScanner();
$counts = $scanner->summarizeComposerAudit([
'advisories' => [
'laravel/framework' => [
['severity' => 'critical'],
['severity' => 'high'],
],
'symfony/http-foundation' => [
['severity' => 'medium'],
['severity' => 'low'],
['severity' => 'unexpected'],
],
],
]);
self::assertSame(1, $counts['critical']);
self::assertSame(1, $counts['high']);
self::assertSame(1, $counts['medium']);
self::assertSame(1, $counts['low']);
self::assertSame(1, $counts['unknown']);
}
}