fix(seed): impedir ContentSeeder de sobrescrever conteúdo em produção (MAN-103) (#36)
* fix(seed): impedir ContentSeeder de sobrescrever conteúdo em produção
O migrate do Dokploy roda `db:seed --class=ContentSeeder --force` em todo
deploy, tanto em staging quanto em produção (mesmo compose, só o .env
muda). Como ContentSeeder usa updateOrCreate/delete incondicionais, cada
deploy em produção revertia edições feitas pela dona no Filament
(SiteSetting, Services, PortfolioCases), republicava os 3 casos e
serviços fictícios, apagava fotos reais da galeria e reenviava as
fixtures de imagem para o bucket R2 de produção — além de publicar os
depoimentos reais via TestimonialsSeeder, sem autorização explícita.
Adiciona uma guarda de ambiente no início de ContentSeeder::run(),
seguindo o mesmo padrão já usado em AppServiceProvider::
freezeClockWhenConfigured(): em APP_ENV=production o método retorna
sem efeito colateral (exit 0), preservando o passo migrate&&seed&&...
do docker-compose.deploy.yml. TestimonialsSeeder não é tocado — ele
continua sendo o único caminho de publicação de depoimentos em
produção, documentado como passo manual em docs/deployment/dokploy.md.
Staging (APP_ENV=staging) continua recebendo conteúdo de demonstração
normalmente, preservando a revisão visual.
Atualiza docs/deployment/dokploy.md, que afirmava (incorretamente,
desde o commit 10a1f0d) que os workflows de deploy eram "migrate-only"
e que ContentSeeder nunca rodava em staging/produção.
Adiciona teste de regressão cobrindo: banco vazio em produção (nenhum
registro criado, nenhuma fixture enviada ao disco), banco com conteúdo
editado pela dona em produção (seeding duas vezes não altera nada) e
comportamento inalterado fora de produção (semeia o conteúdo de demo
normalmente).
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* fix(seed): trocar guarda de produção do ContentSeeder para allow-list
O guard anterior (`App::environment('production')`) é uma comparação
exata e sensível a maiúsculas contra o valor literal de APP_ENV, que é
digitado manualmente no editor de texto livre do Dokploy sem validação,
enum ou valor padrão garantido por este repositório. Se esse valor
algum dia ficar vazio, vier com case diferente ('Production',
'PRODUCTION'), espaço em branco ou for simplesmente digitado errado, o
guard falha aberto: o ContentSeeder roda seu caminho destrutivo em
produção sem nenhuma outra checagem no caminho de deploy que pegasse o
erro — exatamente o cenário que esta guarda deveria evitar.
Inverte a lógica para uma allow-list dos ambientes conhecidos e
seguros ('local', 'staging', 'testing'), então qualquer valor não
reconhecido de APP_ENV — incluindo 'production', vazio, mal digitado
ou com case diferente — falha fechado (no-op) em vez de falhar aberto
(sobrescrever dados reais). 'testing' entra na lista porque é o valor
de APP_ENV usado pelo job `feature` do CI (ver .github/workflows/ci.yml),
que roda testes que chamam ContentSeeder diretamente — sem esse valor
a suíte quebraria em CI mesmo passando localmente (APP_ENV=local via
.env).
Adiciona cobertura de regressão via data provider cobrindo tanto os
três ambientes permitidos quanto uma lista de valores não permitidos
('production', string vazia, 'Production', 'PRODUCTION', espaço à
direita, valor arbitrário desconhecido), para que a guarda seja testada
como allow-list e não apenas contra o literal 'production'.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* docs(deploy): corrigir descrição da guarda do ContentSeeder no runbook
O texto descrevia a guarda antiga (deny-list de 'production') e ainda
afirmava, de forma incorreta, que a publicação dos cinco depoimentos
reais era "um passo manual e deliberado em todo ambiente (incluindo
staging)". Isso nunca foi verdade: o ContentSeeder chama o
TestimonialsSeeder internamente e só pula essa chamada quando a guarda
não casa com o ambiente atual — em staging a guarda casa, então os
depoimentos são publicados/republicados automaticamente a cada deploy,
sem nenhum passo manual envolvido (o próprio teste
test_content_seeder_still_seeds_demo_content_outside_production já
provava isso).
Atualiza as duas passagens para descrever a allow-list
('local'/'staging'/'testing') introduzida no commit anterior e para
deixar explícito que staging publica depoimentos automaticamente,
reservando o comando manual apenas para produção. Também documenta o
trade-off do fail-closed: um APP_ENV de staging digitado errado agora
faz o conteúdo de demonstração parar de ser resemeado silenciosamente.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
---------
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
This commit is contained in:
@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
|
|||||||
use App\Support\PublicImageUploadRules;
|
use App\Support\PublicImageUploadRules;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -18,8 +19,32 @@ class ContentSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
|
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo/fixture content is meant for local dev, automated testing, and
|
||||||
|
* staging visual review only. It must never overwrite owner-edited
|
||||||
|
* SiteSetting, Service, and PortfolioCase records, never re-upload
|
||||||
|
* fixture images to the production storage disk, and never auto-publish
|
||||||
|
* the fictional portfolio cases or (via TestimonialsSeeder) the real
|
||||||
|
* testimonials.
|
||||||
|
*
|
||||||
|
* This is deliberately an allow-list of the known-safe environments
|
||||||
|
* ('local', 'staging', 'testing') rather than a deny-list of
|
||||||
|
* 'production'. APP_ENV is a free-text value hand-typed into the
|
||||||
|
* Dokploy environment UI with no validation — a blank value, a typo, or
|
||||||
|
* an unexpected casing (e.g. '', 'Production', 'staginng') must fail
|
||||||
|
* closed (skip seeding) rather than fail open (seed/overwrite
|
||||||
|
* production data). Only the three recognized values run this seeder;
|
||||||
|
* everything else, including 'production' itself, is a no-op.
|
||||||
|
*
|
||||||
|
* Publishing testimonials in production remains a deliberate, manually
|
||||||
|
* triggered step — see docs/deployment/dokploy.md.
|
||||||
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
|
if (! App::environment(['local', 'staging', 'testing'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->seedSiteSettings();
|
$this->seedSiteSettings();
|
||||||
$this->seedServices();
|
$this->seedServices();
|
||||||
$this->seedPortfolioCases();
|
$this->seedPortfolioCases();
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
|||||||
|
|
||||||
Expected output: `ok`.
|
Expected output: `ok`.
|
||||||
|
|
||||||
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
The `migrate` service in `docker-compose.deploy.yml` runs `php artisan db:seed --class=ContentSeeder --force --no-interaction` on every deploy, in both stacks. `ContentSeeder::run()` guards itself with an **allow-list** — `App::environment(['local', 'staging', 'testing'])` — and returns immediately (exit code 0, no side effects) unless `APP_ENV` is exactly one of those three values. In **staging** (`APP_ENV=staging`) the guard matches, so `ContentSeeder` still seeds its demo content on every deploy — this is required for visual review and is expected behavior, not a bug. In **production** (`APP_ENV=production`), and for any blank, mistyped, or unexpectedly cased `APP_ENV` value in any stack, the guard does not match, so the step is a deliberate no-op: it never overwrites SiteSetting/Service/PortfolioCase records edited in Filament, never re-uploads fixture images to the production storage disk, and never auto-publishes the fictional portfolio cases. Note the tradeoff this implies: if staging's `APP_ENV` is ever typo'd away from exactly `staging`, demo content silently stops being (re)seeded there too — check the Dokploy environment value first if a staging deploy stops refreshing demo content.
|
||||||
|
|
||||||
|
Do not run bare `DatabaseSeeder` in staging or production: it also creates the `admin@amare.local` / `password` local-dev credentials.
|
||||||
|
|
||||||
|
Publishing the five real testimonials is a separate, deliberate, manually triggered step **only in production** — `ContentSeeder` calls `TestimonialsSeeder` internally, but that call is skipped in production by the same allow-list guard, so the command above is the only path that publishes testimonials there. In **staging**, this is not manual: because the allow-list guard matches `staging`, `ContentSeeder` calls `TestimonialsSeeder` automatically on every deploy, auto-publishing/re-publishing the five canonical testimonials each time (consistent with staging's role as a demo/preview environment).
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
|
|||||||
263
tests/Feature/Marketing/ContentSeederProductionGatingTest.php
Normal file
263
tests/Feature/Marketing/ContentSeederProductionGatingTest.php
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\PortfolioImage;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Database\Seeders\ContentSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ContentSeederProductionGatingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_content_seeder_is_a_noop_on_an_empty_production_database(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
$this->clearContentTables();
|
||||||
|
|
||||||
|
$this->app->detectEnvironment(fn (): string => 'production');
|
||||||
|
|
||||||
|
$this->runContentSeeder();
|
||||||
|
|
||||||
|
$this->assertSame(0, SiteSetting::query()->count());
|
||||||
|
$this->assertSame(0, Service::query()->count());
|
||||||
|
$this->assertSame(0, PortfolioCase::query()->count());
|
||||||
|
$this->assertSame(0, PortfolioImage::query()->count());
|
||||||
|
$this->assertSame(0, Testimonial::query()->count());
|
||||||
|
|
||||||
|
Storage::disk('public')->assertMissing('content/about/about-image.jpg');
|
||||||
|
Storage::disk('public')->assertMissing('content/og/og-default.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_content_seeder_does_not_clobber_owner_edited_content_in_production(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$siteSetting = SiteSetting::query()->create([
|
||||||
|
'brand_name' => 'Nome escolhido pela dona',
|
||||||
|
'hero_eyebrow' => 'Eyebrow original',
|
||||||
|
'hero_title' => 'Título original',
|
||||||
|
'hero_subtitle' => 'Subtítulo original',
|
||||||
|
'hero_cta_label' => 'CTA original',
|
||||||
|
'hero_secondary_cta_label' => 'CTA secundário original',
|
||||||
|
'hero_note' => 'Nota original',
|
||||||
|
'about_summary' => 'Resumo original',
|
||||||
|
'manifesto_title' => 'Manifesto original',
|
||||||
|
'manifesto_lead' => 'Lead original',
|
||||||
|
'manifesto_body' => 'Corpo original',
|
||||||
|
'method_intro' => 'Intro original',
|
||||||
|
'method_steps' => [['title' => 'Passo único', 'body' => 'Descrição']],
|
||||||
|
'principles' => ['Princípio único'],
|
||||||
|
'email' => 'dona@amare.example',
|
||||||
|
'phone' => '(11) 90000-0000',
|
||||||
|
'city' => 'Fortaleza - CE',
|
||||||
|
'social_links' => ['instagram' => 'https://instagram.com/dona'],
|
||||||
|
'default_meta_title' => 'Meta original',
|
||||||
|
'default_meta_description' => 'Meta descrição original',
|
||||||
|
'analytics_enabled' => true,
|
||||||
|
'analytics_script' => '<script>console.log("dona")</script>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = Service::query()->create([
|
||||||
|
'title' => 'Serviço editado pela dona',
|
||||||
|
'slug' => 'casamentos',
|
||||||
|
'summary' => 'Resumo editado',
|
||||||
|
'description' => 'Descrição editada',
|
||||||
|
'sort_order' => 99,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$case = PortfolioCase::query()->create([
|
||||||
|
'title' => 'Caso despublicado pela dona',
|
||||||
|
'slug' => 'casamento-ana-lucas',
|
||||||
|
'summary' => 'Resumo editado',
|
||||||
|
'event_type' => 'Casamento',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Local editado',
|
||||||
|
'event_date' => '2024-01-01',
|
||||||
|
'challenge' => 'Desafio editado',
|
||||||
|
'solution' => 'Solução editada',
|
||||||
|
'result' => 'Resultado editado',
|
||||||
|
'cover_image_path' => 'content/portfolio/dona-cover.jpg',
|
||||||
|
'cover_image_alt' => 'Capa editada',
|
||||||
|
'is_featured' => false,
|
||||||
|
'sort_order' => 99,
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => 'content/portfolio/foto-real-da-dona.jpg',
|
||||||
|
'alt_text' => 'Foto real enviada pela dona',
|
||||||
|
'caption' => 'Momento real',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$testimonial = Testimonial::query()->create([
|
||||||
|
'quote' => 'Depoimento real não relacionado.',
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'context' => 'Casamento · contexto original',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$siteSettingBefore = $siteSetting->fresh()?->getAttributes();
|
||||||
|
$serviceBefore = $service->fresh()?->getAttributes();
|
||||||
|
$caseBefore = $case->fresh()?->getAttributes();
|
||||||
|
$imageBefore = $image->fresh()?->getAttributes();
|
||||||
|
$testimonialBefore = $testimonial->fresh()?->getAttributes();
|
||||||
|
$countsBefore = $this->contentCounts();
|
||||||
|
|
||||||
|
$this->app->detectEnvironment(fn (): string => 'production');
|
||||||
|
|
||||||
|
// Run twice: production gating must be stable across repeated deploys.
|
||||||
|
$this->runContentSeeder();
|
||||||
|
$this->runContentSeeder();
|
||||||
|
|
||||||
|
// Compare against counts captured just above, not hardcoded literals:
|
||||||
|
// this table may carry rows created by other tests in the same
|
||||||
|
// process (RefreshDatabase isolates per test method, not per model
|
||||||
|
// globally), so the meaningful assertion is "the seeder created
|
||||||
|
// nothing", not "there is exactly one row".
|
||||||
|
$this->assertSame($countsBefore, $this->contentCounts());
|
||||||
|
|
||||||
|
$this->assertSame($siteSettingBefore, $siteSetting->fresh()?->getAttributes());
|
||||||
|
$this->assertSame($serviceBefore, $service->fresh()?->getAttributes());
|
||||||
|
$this->assertSame($caseBefore, $case->fresh()?->getAttributes());
|
||||||
|
$this->assertSame($imageBefore, $image->fresh()?->getAttributes());
|
||||||
|
$this->assertSame($testimonialBefore, $testimonial->fresh()?->getAttributes());
|
||||||
|
|
||||||
|
Storage::disk('public')->assertMissing('content/about/about-image.jpg');
|
||||||
|
Storage::disk('public')->assertMissing('content/og/og-default.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{0: string}>
|
||||||
|
*/
|
||||||
|
public static function allowListedEnvironments(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'local' => ['local'],
|
||||||
|
'staging' => ['staging'],
|
||||||
|
'testing' => ['testing'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('allowListedEnvironments')]
|
||||||
|
public function test_content_seeder_still_seeds_demo_content_on_allow_listed_environments(string $environment): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
$this->clearContentTables();
|
||||||
|
|
||||||
|
$this->app->detectEnvironment(fn (): string => $environment);
|
||||||
|
|
||||||
|
$this->runContentSeeder();
|
||||||
|
|
||||||
|
$this->assertSame(1, SiteSetting::query()->count());
|
||||||
|
$this->assertSame(3, Service::query()->count());
|
||||||
|
$this->assertSame(3, PortfolioCase::query()->count());
|
||||||
|
$this->assertSame(5, Testimonial::query()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{0: string}>
|
||||||
|
*/
|
||||||
|
public static function nonAllowListedEnvironments(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'production' => ['production'],
|
||||||
|
'empty string' => [''],
|
||||||
|
'mixed case Production' => ['Production'],
|
||||||
|
'upper case PRODUCTION' => ['PRODUCTION'],
|
||||||
|
'trailing whitespace' => ['staging '],
|
||||||
|
'unrecognized arbitrary value' => ['whatever-typo'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guard is an allow-list of known-safe environments, not a deny-list
|
||||||
|
* of the single literal 'production'. Any value that is not exactly
|
||||||
|
* 'local', 'staging', or 'testing' — including a blank APP_ENV, a
|
||||||
|
* different case, stray whitespace, or a plain typo — must fail closed
|
||||||
|
* (no-op) rather than fail open (seed/overwrite data).
|
||||||
|
*/
|
||||||
|
#[DataProvider('nonAllowListedEnvironments')]
|
||||||
|
public function test_content_seeder_is_a_noop_on_any_non_allow_listed_environment(string $environment): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
$this->clearContentTables();
|
||||||
|
|
||||||
|
$this->app->detectEnvironment(fn (): string => $environment);
|
||||||
|
|
||||||
|
$this->runContentSeeder();
|
||||||
|
|
||||||
|
$this->assertSame(0, SiteSetting::query()->count());
|
||||||
|
$this->assertSame(0, Service::query()->count());
|
||||||
|
$this->assertSame(0, PortfolioCase::query()->count());
|
||||||
|
$this->assertSame(0, PortfolioImage::query()->count());
|
||||||
|
$this->assertSame(0, Testimonial::query()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit clean slate for scenarios whose assertions depend on an
|
||||||
|
* absolute, empty starting state ("empty production database" / "fresh
|
||||||
|
* demo seed produces exactly N records").
|
||||||
|
*
|
||||||
|
* This compensates for a pre-existing, out-of-scope test-isolation gap in
|
||||||
|
* this suite: RefreshDatabase is expected to roll back every test's
|
||||||
|
* writes, but a `SiteSetting` row created by `HomePageTest` (via
|
||||||
|
* `SiteSetting::instance()`) has been observed to survive into
|
||||||
|
* whichever test runs next when the full Feature suite executes
|
||||||
|
* sequentially (reproduced with a minimal probe test asserting
|
||||||
|
* `SiteSetting::query()->count() === 0`, which passes under `--filter`
|
||||||
|
* but fails as part of `--testsuite=Feature`). Prime suspect: `tests/Pest.php`
|
||||||
|
* applies `RefreshDatabase` suite-wide via
|
||||||
|
* `pest()->extend(TestCase::class)->use(RefreshDatabase::class)->in('Feature')`
|
||||||
|
* while several class-based tests (e.g. `HomePageTest`) also declare
|
||||||
|
* `use RefreshDatabase;` themselves — double application is worth
|
||||||
|
* checking first. Not fixed here: it is unrelated to MAN-103 and would
|
||||||
|
* grow this diff well beyond the seeder-gating fix.
|
||||||
|
*/
|
||||||
|
private function clearContentTables(): void
|
||||||
|
{
|
||||||
|
PortfolioImage::query()->delete();
|
||||||
|
PortfolioCase::query()->delete();
|
||||||
|
Service::query()->delete();
|
||||||
|
Testimonial::query()->delete();
|
||||||
|
SiteSetting::query()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{siteSettings: int, services: int, portfolioCases: int, portfolioImages: int, testimonials: int}
|
||||||
|
*/
|
||||||
|
private function contentCounts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'siteSettings' => SiteSetting::query()->count(),
|
||||||
|
'services' => Service::query()->count(),
|
||||||
|
'portfolioCases' => PortfolioCase::query()->count(),
|
||||||
|
'portfolioImages' => PortfolioImage::query()->count(),
|
||||||
|
'testimonials' => Testimonial::query()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runContentSeeder(): void
|
||||||
|
{
|
||||||
|
$this->artisan('db:seed', [
|
||||||
|
'--class' => ContentSeeder::class,
|
||||||
|
'--force' => true,
|
||||||
|
'--no-interaction' => true,
|
||||||
|
])->assertExitCode(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user