<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Seyifunmi Adedayo</title>
    <description>The latest articles on DEV Community by Seyifunmi Adedayo (@hackid02).</description>
    <link>https://dev.to/hackid02</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4198574%2F9bb2bf1c-d519-4f8a-a616-971532030295.png</url>
      <title>DEV Community: Seyifunmi Adedayo</title>
      <link>https://dev.to/hackid02</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hackid02"/>
    <language>en</language>
    <item>
      <title>I planted a security bug to test my own CI. My merge gate let it through.</title>
      <dc:creator>Seyifunmi Adedayo</dc:creator>
      <pubDate>Wed, 21 Oct 2026 14:22:53 +0000</pubDate>
      <link>https://dev.to/hackid02/i-planted-a-security-bug-to-test-my-own-ci-my-merge-gate-let-it-through-5843</link>
      <guid>https://dev.to/hackid02/i-planted-a-security-bug-to-test-my-own-ci-my-merge-gate-let-it-through-5843</guid>
      <description>&lt;p&gt;I built &lt;strong&gt;Nudge&lt;/strong&gt; for the TestSprite Hackathon (Season 4). It's invoicing for freelancers with one feature I've always wanted: &lt;strong&gt;read receipts&lt;/strong&gt;. Every client gets a private portal that shows only their own invoices. The moment a client opens one, the freelancer's dashboard flips from &lt;em&gt;Sent&lt;/em&gt; to &lt;em&gt;Viewed&lt;/em&gt;, live, without a refresh.&lt;/p&gt;

&lt;p&gt;That feature is the easy part. The hard part is the sentence before it: &lt;strong&gt;only their own invoices&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post covers how I made the tests prove that on every pull request. Then I deliberately broke it, and found out my safety net had a hole in it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo&lt;/strong&gt; (one click, no sign-up): &lt;a href="https://nudge-spike.vercel.app" rel="noopener noreferrer"&gt;https://nudge-spike.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/hackid02/nudge-spike" rel="noopener noreferrer"&gt;https://github.com/hackid02/nudge-spike&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2-minute video:&lt;/strong&gt; in the repo README&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug I was most afraid of
&lt;/h2&gt;

&lt;p&gt;Picture one freelancer, Maya, with two clients: Northwind and Lumen Labs.&lt;/p&gt;

&lt;p&gt;Cross-&lt;em&gt;freelancer&lt;/em&gt; isolation is easy to remember. Most apps get it right because every query already filters by the logged-in user. Cross-&lt;em&gt;client&lt;/em&gt; isolation inside one freelancer's account is where real apps leak. Both clients belong to the same tenant, so a lazy &lt;code&gt;where freelancer_id = ?&lt;/code&gt; looks correct and isn't.&lt;/p&gt;

&lt;p&gt;In Nudge, ownership lives in the SQL &lt;code&gt;WHERE&lt;/code&gt; clause, not in the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
   &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;-- the line this whole post is about&lt;/span&gt;
   &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'draft'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns nothing, the page renders one generic message: &lt;strong&gt;"Invoice not found."&lt;/strong&gt; That message is the same for another client's invoice, another tenant's invoice, your own unsent draft, and an ID that doesn't exist. Anything more specific would confirm that the invoice exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests that assert absence, not "an error appeared"
&lt;/h2&gt;

&lt;p&gt;A test that checks "the page shows an error" passes on a 200 response that happens to show someone else's data next to a toast. So the isolation tests assert the &lt;strong&gt;absence of specific values&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sign in as Client A. Open &lt;code&gt;/portal/invoices/INV-1002&lt;/code&gt; (Lumen's invoice). Verify the page shows "Invoice not found." and does &lt;strong&gt;not&lt;/strong&gt; contain &lt;code&gt;INV-1002&lt;/code&gt; or &lt;code&gt;$640.00&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The full suite is 15 TestSprite end-to-end tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tenant isolation, including URL guessing, drafts, another freelancer's data, and both clients' CSV exports&lt;/li&gt;
&lt;li&gt;role routing and signed-out redirects&lt;/li&gt;
&lt;li&gt;an exact wrong-password message&lt;/li&gt;
&lt;li&gt;invoice totals to the cent&lt;/li&gt;
&lt;li&gt;send, record payment, void (refused on paid invoices), and delete with confirmation&lt;/li&gt;
&lt;li&gt;the read receipt itself&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Every PR gets its own database
&lt;/h2&gt;

&lt;p&gt;Shared test data is how you get flaky end-to-end tests. If two PRs run against the same database, one run's "send invoice" breaks the other run's "invoice is a draft".&lt;/p&gt;

&lt;p&gt;So each pull request gets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Its own Vercel preview&lt;/strong&gt; at a predictable URL: &lt;code&gt;nudge-spike-git-{branch}-….vercel.app&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its own Neon database branch&lt;/strong&gt;, created by the Neon–Vercel integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fresh seed at build time.&lt;/strong&gt; &lt;code&gt;vercel-build&lt;/code&gt; runs a reset script before &lt;code&gt;next build&lt;/code&gt;. The script guards against ever touching production:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[preview-reset] not a preview, skipping (guard)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the deployment reports ready, the TestSprite GitHub App runs the suite against that preview, posts a &lt;strong&gt;TestSprite V3&lt;/strong&gt; status and a comment on the PR, and a ruleset on &lt;code&gt;main&lt;/code&gt; requires that status.&lt;/p&gt;

&lt;p&gt;Fixtures are split, too. Read-only invoices (&lt;code&gt;INV-10xx&lt;/code&gt;) are never modified by any test. Each test that changes data owns exactly one disposable invoice (&lt;code&gt;INV-11xx&lt;/code&gt;). Seed dates are relative to &lt;code&gt;now()&lt;/code&gt;, so nothing drifts into &lt;em&gt;Overdue&lt;/em&gt; halfway through judging.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the suite caught first
&lt;/h2&gt;

&lt;p&gt;Before any of the planted-bug drama, a real result: the test &lt;strong&gt;"Maya can delete a draft invoice after confirming"&lt;/strong&gt; came back &lt;strong&gt;Blocked&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Delete used the browser's native &lt;code&gt;window.confirm()&lt;/code&gt;. Automated browsers close native dialogs on their own, so the Cancel path could never be exercised, by TestSprite or anyone else's harness. I replaced it with an in-page confirmation dialog (&lt;code&gt;role="alertdialog"&lt;/code&gt;, Cancel and Escape close it, Confirm submits). On the next PR the test clicked Cancel, checked the invoice was still there, clicked Delete again, confirmed, and checked it was gone. Blocked became Passed.&lt;/p&gt;

&lt;p&gt;It's a small thing, but it's the loop doing its job: the test exposed a UX choice that was also a testability bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I tried to break it on purpose
&lt;/h2&gt;

&lt;p&gt;Every PR so far had been green. That bothered me. &lt;strong&gt;A gate you've never seen fail isn't a gate. It's a decoration.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I opened &lt;a href="https://github.com/hackid02/nudge-spike/pull/10" rel="noopener noreferrer"&gt;PR #10&lt;/a&gt;, titled &lt;em&gt;"DO NOT MERGE: planted tenant leak"&lt;/em&gt;. It deletes exactly one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  where i.id = ${invoiceId}
&lt;span class="gd"&gt;-   and i.client_id = ${clientId}
&lt;/span&gt;    and i.status &amp;lt;&amp;gt; 'draft'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally, my HTTP isolation script confirmed the leak: Client A could now read Lumen's INV-1002 and another freelancer's INV-2001.&lt;/p&gt;

&lt;p&gt;TestSprite ran on the PR's own preview and database. It caught it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Ran into trouble — 1 failed.&lt;/strong&gt; Client A could open another client's invoice directly. The page displayed invoice INV-1002, billed to Lumen Labs. The total shown was $640.00.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I looked at the status check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;TestSprite V3: success — 14/15 passed · 1 failed&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Success. With a failing security test. GitHub said the PR was mergeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hole in my safety net
&lt;/h2&gt;

&lt;p&gt;The tests worked. The ruleset worked. The missing piece was a toggle on the TestSprite trigger: &lt;strong&gt;Merge gate: "Report results as a status check, blocking merge on failure."&lt;/strong&gt; It was off. With it off, the run reports its findings in a comment but sends GitHub a passing status. My required check was guaranteed to be green no matter what the tests found.&lt;/p&gt;

&lt;p&gt;Every PR since TestSprite was wired in had come back green, and I believed my merges were gated. They weren't. Those PRs happened to pass, so nothing slipped through, but I only learned the gate was open because I tried to walk through it.&lt;/p&gt;

&lt;p&gt;I switched the toggle on and re-ran the same code:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbp7nvcjpnvg2xpiuaaxr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbp7nvcjpnvg2xpiuaaxr.png" alt="PR #10: TestSprite V3 failing, marked Required, merge button disabled" width="800" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;TestSprite V3: failure — 14/15 passed · 1 failed&lt;/code&gt;&lt;/strong&gt;, marked &lt;strong&gt;Required&lt;/strong&gt;, and the merge button greyed out. I closed the PR unmerged. &lt;code&gt;main&lt;/code&gt; and production were never touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone wiring up PR testing
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plant a bug before you trust the gate.&lt;/strong&gt; One deleted line, one PR, closed unmerged. It's the cheapest proof you can get, and it found a real misconfiguration in my setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the status, not just the comment.&lt;/strong&gt; The bot comment said "1 failed". The status GitHub enforces said "success". Those are two different things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every PR its own data.&lt;/strong&gt; Per-branch databases made every red check reproducible and every green check meaningful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert absence.&lt;/strong&gt; For access control, "the secret value is not on the page" beats "an error appeared".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what isn't built.&lt;/strong&gt; Nudge doesn't create invoices, invite clients, send email or take payments. I spent the week making isolation and the invoice lifecycle provably correct instead, and the README says so.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The rest of the setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A second, credit-free test layer: GitHub Actions runs &lt;code&gt;npm test&lt;/code&gt; (21 checks on the money maths: half-up rounding, tax in basis points, formatting) on every PR.&lt;/li&gt;
&lt;li&gt;The live demo is shared by every visitor, so a nightly GitHub Action reseeds it to the known fixtures.&lt;/li&gt;
&lt;li&gt;Stack: Next.js (App Router), Postgres on Neon, Tailwind v4, Vercel previews, TestSprite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try the attack yourself: open the &lt;a href="https://nudge-spike.vercel.app" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;, click &lt;strong&gt;Client A&lt;/strong&gt;, and go to &lt;code&gt;/portal/invoices/INV-1002&lt;/code&gt;. You'll get "Invoice not found." That's the point.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built for the TestSprite Hackathon, Season 4.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>nextjs</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
