diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 0d3ff49..d5d5a33 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -130,11 +130,11 @@ jobs: id: stan run: | set +e - vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json 2>&1 + vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json EXIT_CODE=$? # Also run for GitHub format - vendor/bin/phpstan analyse --error-format=github --no-progress 2>&1 || true + vendor/bin/phpstan analyse --error-format=github --no-progress || true ERRORS=$(jq '.totals.file_errors // 0' phpstan.json 2>/dev/null || echo "0") echo "errors=${ERRORS}" >> $GITHUB_OUTPUT @@ -181,11 +181,27 @@ jobs: id: psalm run: | set +e - vendor/bin/psalm --output-format=json --show-info=false > psalm.json 2>&1 + vendor/bin/psalm --output-format=json --show-info=false > psalm.json EXIT_CODE=$? # Generate SARIF for GitHub Security - vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif 2>&1 || true + vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif || true + + # Sanitize SARIF file (remove invalid locations where line/column < 1) + if [ -f psalm.sarif ]; then + jq ' + walk( + if type == "object" and has("physicalLocation") then + .physicalLocation.region.startLine |= (if . < 1 then 1 else . end) | + .physicalLocation.region.startColumn |= (if . < 1 then 1 else . end) | + .physicalLocation.region.endLine |= (if . < 1 then 1 else . end) | + .physicalLocation.region.endColumn |= (if . < 1 then 1 else . end) + else + . + end + ) + ' psalm.sarif > psalm.sarif.tmp && mv psalm.sarif.tmp psalm.sarif + fi ERRORS=$(jq 'length' psalm.json 2>/dev/null || echo "0") echo "errors=${ERRORS}" >> $GITHUB_OUTPUT @@ -200,7 +216,7 @@ jobs: - name: Upload Psalm SARIF if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: psalm.sarif category: psalm @@ -242,7 +258,7 @@ jobs: id: fmt run: | set +e - OUTPUT=$(vendor/bin/pint --test --format=json 2>&1) + OUTPUT=$(vendor/bin/pint --test --format=json) EXIT_CODE=$? echo "$OUTPUT" > pint.json @@ -292,7 +308,7 @@ jobs: id: audit run: | set +e - composer audit --format=json > audit.json 2>&1 + composer audit --format=json > audit.json EXIT_CODE=$? VULNS=$(jq '.advisories | to_entries | map(.value | length) | add // 0' audit.json 2>/dev/null || echo "0") diff --git a/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php b/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php index 637b408..c65a8a4 100644 --- a/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php +++ b/src/Core/Seo/Console/Commands/AuditCanonicalUrls.php @@ -11,8 +11,10 @@ namespace Core\Seo\Console\Commands; +use Core\Seo\SeoMetadata; use Core\Seo\Validation\CanonicalUrlValidator; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; /** * Audit canonical URLs across the site for conflicts and issues. @@ -134,17 +136,25 @@ protected function attemptFixes(array $audit, CanonicalUrlValidator $validator): $fixed = 0; // Fix protocol issues (HTTP -> HTTPS) - foreach ($audit['protocol_issues'] as $record) { + $protocolIssues = $audit['protocol_issues']; + $idsToUpdate = []; + + foreach ($protocolIssues as $record) { $oldUrl = $record->canonical_url; $newUrl = str_replace('http://', 'https://', $oldUrl); - $record->canonical_url = $newUrl; - $record->save(); - $this->line(" Fixed protocol: {$oldUrl} -> {$newUrl}"); + $idsToUpdate[] = $record->id; $fixed++; } + if (! empty($idsToUpdate)) { + SeoMetadata::whereIn('id', $idsToUpdate)->update([ + 'canonical_url' => DB::raw("REPLACE(canonical_url, 'http://', 'https://')"), + 'updated_at' => now(), + ]); + } + // Note: Duplicates and www inconsistencies require manual review if ($audit['duplicates']->isNotEmpty()) { $this->warn(' Duplicate canonical URLs require manual review.'); diff --git a/src/Core/Tests/Feature/SeoAuditCanonicalTest.php b/src/Core/Tests/Feature/SeoAuditCanonicalTest.php new file mode 100644 index 0000000..5c9da77 --- /dev/null +++ b/src/Core/Tests/Feature/SeoAuditCanonicalTest.php @@ -0,0 +1,107 @@ +id(); + $table->timestamps(); + }); + + Schema::create('seo_metadata', function (Blueprint $table) { + $table->id(); + $table->string('seoable_type'); + $table->unsignedBigInteger('seoable_id'); + $table->string('canonical_url')->nullable(); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->json('og_data')->nullable(); + $table->json('twitter_data')->nullable(); + $table->json('schema_markup')->nullable(); + $table->string('robots')->nullable(); + $table->string('focus_keyword')->nullable(); + $table->integer('seo_score')->nullable(); + $table->json('seo_issues')->nullable(); + $table->json('seo_suggestions')->nullable(); + $table->timestamps(); + }); + } + + public function test_fixes_protocol_issues(): void + { + // Create seoables + $item1 = TestSeoable::create(); + $item2 = TestSeoable::create(); + $item3 = TestSeoable::create(); + $item4 = TestSeoable::create(); + + // Create records with HTTP canonical URLs + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item1->id, + 'canonical_url' => 'http://example.com/post-1', + ]); + + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item2->id, + 'canonical_url' => 'http://example.com/post-2', + ]); + + // Create a record with HTTPS (should not be touched) + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item3->id, + 'canonical_url' => 'https://example.com/post-3', + ]); + + // Create a record without canonical URL + SeoMetadata::create([ + 'seoable_type' => TestSeoable::class, + 'seoable_id' => $item4->id, + 'canonical_url' => null, + ]); + + // Run the command with --fix + $this->artisan('seo:audit-canonical', ['--fix' => true]) + ->expectsOutput('Fixed 2 issue(s).') + ->assertExitCode(1); + + // Verify updates + $this->assertEquals('https://example.com/post-1', SeoMetadata::find(1)->canonical_url); + $this->assertEquals('https://example.com/post-2', SeoMetadata::find(2)->canonical_url); + $this->assertEquals('https://example.com/post-3', SeoMetadata::find(3)->canonical_url); + $this->assertNull(SeoMetadata::find(4)->canonical_url); + } +}