<?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: Ender Yentar</title>
    <description>The latest articles on DEV Community by Ender Yentar (@enderyentar).</description>
    <link>https://dev.to/enderyentar</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%2F4116457%2F050fafb4-8316-482e-b9e4-c3c94f8613a0.jpeg</url>
      <title>DEV Community: Ender Yentar</title>
      <link>https://dev.to/enderyentar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/enderyentar"/>
    <language>en</language>
    <item>
      <title>Our test clicked a button our users couldn't reach</title>
      <dc:creator>Ender Yentar</dc:creator>
      <pubDate>Sat, 19 Sep 2026 02:21:16 +0000</pubDate>
      <link>https://dev.to/enderyentar/our-test-clicked-a-button-our-users-couldnt-reach-2pkc</link>
      <guid>https://dev.to/enderyentar/our-test-clicked-a-button-our-users-couldnt-reach-2pkc</guid>
      <description>&lt;p&gt;We shipped a small blog editor inside our dashboard. The same evening I opened it and the form was cut off at the bottom of the screen. The body field was half visible. Everything under it, the FAQ section and the publish options, was simply gone. The mouse wheel did nothing. Page Down did nothing.&lt;/p&gt;

&lt;p&gt;The end-to-end test for that exact form was green. It filled the form, clicked "Add question" at the very bottom, published the post, and checked that it appeared on the public blog. Every step passed.&lt;/p&gt;

&lt;p&gt;So the test could reach a button that no human could reach. This post is about how that happens, and the one-line check that would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug itself
&lt;/h2&gt;

&lt;p&gt;Our dashboard has a mail view that fills the screen exactly, like a desktop mail client. The list and the reader each scroll on their own, and the outer shell never scrolls. The shell looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dash&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&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;Most pages don't render inside that full-height mode. They render inside a normal scrolling container instead. The code picks between the two with a condition, and that condition was a list of exclusions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMailFull&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;billing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;nav&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inbox-mail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blog editor was a new top-level page. I added it to the router and forgot to add it to this list. It opened in the mail mode, so it was rendered inside the fixed shell with &lt;code&gt;overflow: hidden&lt;/code&gt;. The form was taller than the screen, and the bottom of it was clipped with no way to scroll down.&lt;/p&gt;

&lt;p&gt;The fix was two words, &lt;code&gt;&amp;amp;&amp;amp; !blog&lt;/code&gt;, plus a comment on that line telling the next person to add new pages there. The interesting part is why the test said everything was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;overflow: hidden&lt;/code&gt; does not mean "cannot scroll"
&lt;/h2&gt;

&lt;p&gt;This is the part I had wrong in my head. An element with &lt;code&gt;overflow: hidden&lt;/code&gt; is still a scroll container. It clips its content and it hides the scrollbar. It does not listen to the mouse wheel or to the keyboard. But its &lt;code&gt;scrollTop&lt;/code&gt; is still a live property, and anything that scrolls it from code still works: &lt;code&gt;element.scrollTop = 500&lt;/code&gt;, &lt;code&gt;element.scrollIntoView()&lt;/code&gt;, and focus moving to an element inside it.&lt;/p&gt;

&lt;p&gt;Playwright's &lt;code&gt;click()&lt;/code&gt; runs actionability checks before it clicks. One of them is: if the element is not in view, scroll it into view. It does that from code, so an &lt;code&gt;overflow: hidden&lt;/code&gt; ancestor is no obstacle at all.&lt;/p&gt;

&lt;p&gt;I didn't want to put that in a post based on my reading of docs, so I measured it in a tiny page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
&amp;lt;style&amp;gt;
  body { margin: 0 }
  .shell { height: 100vh; overflow: hidden }
  .form { height: 2000px; position: relative }
  #btn { position: absolute; top: 1800px }
&amp;lt;/style&amp;gt;
&amp;lt;div class="shell"&amp;gt;&amp;lt;div class="form"&amp;gt;
  &amp;lt;button id="btn" onclick="window.clicked = true"&amp;gt;Add question&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.shell&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// What a person can do&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mouse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mouse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wheel&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="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;End&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;user scrollTop:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// What the test does&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#btn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&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="s2"&gt;clicked:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clicked&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="s2"&gt;test scrollTop:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;user scrollTop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;clicked&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;test scrollTop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wheel and the End key moved the page by zero pixels. The button stayed below the fold. Then &lt;code&gt;click()&lt;/code&gt; scrolled the same container by 1200 pixels on its own and clicked the button. From the test's point of view nothing was wrong, because for the test nothing was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing else caught it either
&lt;/h2&gt;

&lt;p&gt;This one got past every layer we have. The unit tests don't render the layout. The production build doesn't care about CSS. The end-to-end suite drove the real form in a real browser and the form worked.&lt;/p&gt;

&lt;p&gt;My first attempt at a fix was also wrong. I saw a cramped form, decided it was a width problem, and widened the editor. It shipped, it looked better, and the form was still cut off. I had checked the thing I expected instead of the thing that was reported. "I can't get to the bottom of the form" is a scrolling complaint, not a width complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that catches it
&lt;/h2&gt;

&lt;p&gt;The test now asserts the thing the user actually needs: the page is in the scrolling container, not the fixed one, and that container really scrolls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.content .blog-page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.content-fill .blog-page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveCount&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scroller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canScroll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scroller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;&amp;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;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canScroll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blog editor cannot be scrolled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A caveat on that last check: setting &lt;code&gt;scrollTop&lt;/code&gt; from code also works on an &lt;code&gt;overflow: hidden&lt;/code&gt; element, which is the whole point of this post. It is meaningful here only because it targets &lt;code&gt;.content&lt;/code&gt;, which is supposed to be &lt;code&gt;overflow-y: auto&lt;/code&gt;, together with the two assertions above it. If you want a check that doesn't depend on knowing your class names, drive the page the way a person does and then look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mouse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mouse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wheel&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="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Add question&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;toBeInViewport&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mouse.wheel&lt;/code&gt; goes through the browser's real scroll handling, so a clipped container stays at zero and &lt;code&gt;toBeInViewport()&lt;/code&gt; fails.&lt;/p&gt;

&lt;p&gt;Before trusting the new test I put the bug back, reverting the two-word fix, and ran it. It went red. Then I restored the fix and it went green. A regression test that you have never seen fail hasn't shown you anything yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;Playwright is doing the right thing. Auto-scrolling before a click is what you want in almost every test, otherwise every test would be full of manual scrolling. But it means &lt;code&gt;click()&lt;/code&gt; answers "does this button work?" and not "can a person get to this button?". Those are different questions, and a layout bug lives entirely in the gap between them.&lt;/p&gt;

&lt;p&gt;So now, for any page that is taller than the screen, one test reaches the bottom the way a user would: with the wheel, and with an assertion on what's visible, before it clicks anything.&lt;/p&gt;

&lt;p&gt;We also have a rule written next to that condition now: a new top-level page in the dashboard means checking that list. It's a list of exclusions, and a list of exclusions fails quietly when you forget to add to it. That is probably the real bug, and a good candidate for the next refactor.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://mailflat.net" rel="noopener noreferrer"&gt;MailFlat&lt;/a&gt;, a permanent encrypted inbox with an email API for developers and AI agents. I write about bugs that pass locally and fail in production.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We show your API key once, and here is the option we turned down</title>
      <dc:creator>Ender Yentar</dc:creator>
      <pubDate>Wed, 09 Sep 2026 20:59:54 +0000</pubDate>
      <link>https://dev.to/enderyentar/we-show-your-api-key-once-and-here-is-the-option-we-turned-down-8e0</link>
      <guid>https://dev.to/enderyentar/we-show-your-api-key-once-and-here-is-the-option-we-turned-down-8e0</guid>
      <description>&lt;p&gt;We shipped a change that takes something away. You used to be able to open an inbox in the dashboard and reveal your API key whenever you wanted. Now the full key appears once, at the moment it is created, and after that you only ever see &lt;code&gt;mf_sk_••••PeH0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nobody asked for that. We did it on purpose, and the reasoning is more interesting than the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  A schema task with a product decision inside it
&lt;/h2&gt;

&lt;p&gt;The task on the board said: drop two plaintext columns. It read like housekeeping. Migration, tests, done.&lt;/p&gt;

&lt;p&gt;Before touching it we went looking for everything that reads those columns. One of them fed a button. The inbox modal had a "Show" control that revealed the whole key, and a "Copy" that copied the whole key. Both of them read the plaintext column directly.&lt;/p&gt;

&lt;p&gt;So dropping the column was not housekeeping. It answered a question nobody had written down:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;can a user see their key again after they create it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a product decision. It was sitting inside a schema ticket, unlabelled, and it would have been decided by whoever happened to run the migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually store
&lt;/h2&gt;

&lt;p&gt;Before choosing, we wrote down what is already true, because two of the three options only make sense in contrast to it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Reversible&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API keys&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 with a pepper&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passwords&lt;/td&gt;
&lt;td&gt;scrypt 32768:8:1 with a salt&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages&lt;/td&gt;
&lt;td&gt;RSA-OAEP-2048 plus AES-256-GCM&lt;/td&gt;
&lt;td&gt;yes, but the private key lives in your browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One note, because it comes up every time: base64 is not encryption. It is an envelope, not a lock. If a key is base64 in your database, it is plaintext with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three options
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A. Show it once.&lt;/strong&gt; The key is displayed at creation, with a warning, and never again. Lost key means revoke and create a new one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B. Encrypt instead of hash.&lt;/strong&gt; Store the key encrypted with a server-side key, decrypt on request, show it whenever the user asks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C. Encrypt with the user's own key.&lt;/strong&gt; We already run end-to-end encryption for messages: the public key encrypts, the private key stays in the browser. The API key could be stored the same way and decrypted client side, with the server still blind.&lt;/p&gt;

&lt;p&gt;We chose A.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why B was rejected
&lt;/h2&gt;

&lt;p&gt;B is the option that keeps every feature. It is also the one that quietly cancels the reason the work existed.&lt;/p&gt;

&lt;p&gt;The whole point of hashing is a sentence we get to say: &lt;em&gt;if our database leaks, the keys in it are not usable.&lt;/em&gt; Option B replaces that with &lt;em&gt;if our database leaks, the keys are encrypted, and so is the key that decrypts them, which our application can reach.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An attacker with database access is usually one step from application access. B does not add a wall, it adds a drawer next to the wall.&lt;/p&gt;

&lt;p&gt;The way we phrased it internally: A lets us say "we cannot see this key either". B turns that into "we cannot see it, but we do keep it". Those are not the same promise, and only one of them survives a bad day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why C is on a shelf and not in the bin
&lt;/h2&gt;

&lt;p&gt;C is genuinely good. The infrastructure exists, it is running in production for message bodies, and it would give users their key back without the server ever reading it.&lt;/p&gt;

&lt;p&gt;It has one real cost: it forks the system.&lt;/p&gt;

&lt;p&gt;Users can turn end-to-end encryption off, and keys created through the API have no browser to decrypt in. So C is not "the same thing, better". It is a second branch that has to be built, tested and explained alongside the first. Two paths where there is currently one.&lt;/p&gt;

&lt;p&gt;So we wrote C up as its own plan, and then wrote down what would have to be measured before we build it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Users regularly landing in a revoke-and-recreate loop, visible as short-lived key rows on the same inbox.&lt;/li&gt;
&lt;li&gt;Real support requests along the lines of "I cannot see my key".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are currently zero. Not "we think they are low". Zero, with a query behind it.&lt;/p&gt;

&lt;p&gt;That last part is the bit worth stealing. "We might do this later" is a way of avoiding a decision. "We will do this when this number stops being zero" is a decision, and it has an owner and a trigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it
&lt;/h2&gt;

&lt;p&gt;Four code paths read or wrote the plaintext column: the "does this inbox already have a key" check, the write on creation, the clear on revoke, and the list endpoint, which was returning the full key in its response body.&lt;/p&gt;

&lt;p&gt;Authentication was not one of them. It had been moved to the hash a few weeks earlier, which is the only reason this change did not touch the login path at all. Being able to say that, with a line number, was worth more than any amount of confidence.&lt;/p&gt;

&lt;p&gt;The revoke button stayed exactly where it was. If you lose your key, that is your exit, and removing the reveal makes revoke more important rather than less.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof that counted
&lt;/h2&gt;

&lt;p&gt;Production verification was five measurements on a throwaway inbox:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inbox creation returns no key&lt;/li&gt;
&lt;li&gt;Key generation returns the plaintext once&lt;/li&gt;
&lt;li&gt;Both plaintext columns are null afterwards&lt;/li&gt;
&lt;li&gt;The list endpoint returns a masked string, not the key&lt;/li&gt;
&lt;li&gt;A live HTTP request using that key returns 200&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first four say nothing leaks. The fifth says it still works. Either one alone is a trap. A system that leaks nothing because it is broken passes 1 through 4 perfectly.&lt;/p&gt;

&lt;p&gt;We are still running with the old columns in place. They will be dropped in a separate step, after a week of the new path being live, with a separate approval. There is no reason to combine an irreversible migration with a behaviour change on the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell ourselves a week earlier
&lt;/h2&gt;

&lt;p&gt;Before you run a migration, grep for what reads the column. Not to check the code compiles, but because a column that feeds a button is not a storage detail, it is a promise to a user, and someone should decide about it on purpose.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://mailflat.net/docs/end-to-end-encryption" rel="noopener noreferrer"&gt;MailFlat&lt;/a&gt; this way throughout: the server holds ciphertext it cannot read, and the parts we cannot recover for you are the parts we are proudest of.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>postgres</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your backfill is a photograph</title>
      <dc:creator>Ender Yentar</dc:creator>
      <pubDate>Wed, 09 Sep 2026 03:24:07 +0000</pubDate>
      <link>https://dev.to/enderyentar/your-backfill-is-a-photograph-2kgj</link>
      <guid>https://dev.to/enderyentar/your-backfill-is-a-photograph-2kgj</guid>
      <description>&lt;p&gt;We were getting ready to make a column &lt;code&gt;NOT NULL&lt;/code&gt;. Standard preparation: count the rows that would violate it. The count was not zero.&lt;/p&gt;

&lt;p&gt;Seven API keys had no owning organization. That looked like a key problem, so we went looking at how keys are created.&lt;/p&gt;

&lt;p&gt;It was not a key problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keys were innocent
&lt;/h2&gt;

&lt;p&gt;All seven belonged to the same account. And that account had no organization either. Widening the query, two accounts were in that state. Both had signed up recently, within the same three week window.&lt;/p&gt;

&lt;p&gt;That reframed the question. Keys read &lt;code&gt;organization_id&lt;/code&gt; off the user who owns them. If the user has none, every key that user creates is born without one. The keys were downstream of something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;Two weeks earlier we had run a migration that created an organization for every existing user and linked them up. It worked. Every row we had at that moment was correct.&lt;/p&gt;

&lt;p&gt;Then we shipped it and moved on.&lt;/p&gt;

&lt;p&gt;Nobody wired the signup path.&lt;/p&gt;

&lt;p&gt;The proof took one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"Organization("&lt;/span&gt; backend/app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero hits in application code. The only places that constructed an &lt;code&gt;Organization&lt;/code&gt; were the migration itself and the tests. So from the moment the migration finished, every new account was born outside the ownership chain, and the data drifted a little further from correct with each signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody noticed for two weeks
&lt;/h2&gt;

&lt;p&gt;Because nothing read the column yet.&lt;/p&gt;

&lt;p&gt;The organization was groundwork for permission checks that had not shipped. No request failed. No error was logged. No user saw anything wrong. The system behaved exactly as it had before, because the broken part was not load bearing.&lt;/p&gt;

&lt;p&gt;This is the uncomfortable shape of the bug: it produced no symptom, and the absence of a symptom is what let it grow. It only surfaced because we went looking for something else, and it had been quietly blocking that something else the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  We had already written the lesson down
&lt;/h2&gt;

&lt;p&gt;While reading the code, we found this in a helper module, in a comment above a completely different function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A backfill is a photograph. A dual write is keeping the photograph current.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone on this project learned that lesson, wrote it down, applied it to inboxes, and then did not apply it to users. The knowledge was in the repository. It just was not attached to the thing that needed it.&lt;/p&gt;

&lt;p&gt;A backfill answers "what is true right now". It cannot answer "what stays true tomorrow". Those are two different jobs and shipping the first one feels like finishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix we did not make
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to create the organization at signup. We counted the paths that create a user:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normal email signup&lt;/li&gt;
&lt;li&gt;Google sign-in&lt;/li&gt;
&lt;li&gt;Two separate billing paths&lt;/li&gt;
&lt;li&gt;Admin seeding&lt;/li&gt;
&lt;li&gt;The demo system user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six call sites. Adding the same three lines to six places would have made today's bug six times more likely, not less, because the seventh path gets written next month by someone who never reads this post.&lt;/p&gt;

&lt;p&gt;So we took the guarantee off the caller entirely. It is a session level hook that runs before every flush:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@event.listens_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_flush&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_attach_personal_organization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Assign the relationship, not the foreign key. See below.
&lt;/span&gt;            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only looks at &lt;code&gt;session.new&lt;/code&gt;, so flushing an existing user a second time does not open a second organization. If &lt;code&gt;organization_id&lt;/code&gt; is already set, it leaves it alone, which is what lets the backfill migration and the tests do their own thing.&lt;/p&gt;

&lt;p&gt;Now it does not matter which path creates the user, or whether that path knows organizations exist.&lt;/p&gt;

&lt;p&gt;There is a real cost to this and it is worth saying out loud: &lt;code&gt;grep -rn "Organization("&lt;/code&gt; still finds nothing useful in the application code. We traded an explicit call for an invisible one. The mitigation is signposting: the model module says in its docstring that organizations are born in a &lt;code&gt;before_flush&lt;/code&gt; event and that grepping will not find the call. If you use this pattern, write that sign, because the next person will grep first.&lt;/p&gt;

&lt;p&gt;The test says the same thing out loud. It does not go through the signup endpoint, because that would only prove the endpoint works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_guarantee_does_not_depend_on_the_caller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw-insert@test.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rawinsert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&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;organization_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone later moves the guarantee back into the signup handler, this test goes red. That is its entire job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that bit us on the way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You cannot flush inside &lt;code&gt;before_flush&lt;/code&gt;.&lt;/strong&gt; The first version added the organization, flushed to get its id, and assigned it to the foreign key. SQLAlchemy raises "Session is already flushing" and all four tests fail at once. The fix was to stop thinking in ids: assign the relationship, and let SQLAlchemy work out the insert order and the key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backfill migration passed locally and failed on Postgres.&lt;/strong&gt; It used &lt;code&gt;sa.table()&lt;/code&gt;, the lightweight construct, which has no primary key definition, so &lt;code&gt;inserted_primary_key&lt;/code&gt; comes back empty and indexing it raises. &lt;code&gt;sa.Table()&lt;/code&gt; with an explicit primary key column fixes it.&lt;/p&gt;

&lt;p&gt;We never would have seen this locally, because our test suite runs on SQLite and Alembic does not run there at all. A small staging box with the production schema caught it. That is a &lt;a href="https://dev.to/enderyentar/sqlite-doesnt-enforce-foreign-keys-by-default-and-it-cost-us-three-bugs-1ii9"&gt;separate story&lt;/a&gt;, and it is the same story.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do now
&lt;/h2&gt;

&lt;p&gt;After any backfill, three questions before it counts as shipped:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What creates new rows of this kind, and does that path set the field?&lt;/li&gt;
&lt;li&gt;If the answer is "nothing reads it yet", what will read it, and when?&lt;/li&gt;
&lt;li&gt;Is the guarantee attached to a caller, or to the data?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first question is the one we skipped. The third is the one that actually fixed it.&lt;/p&gt;

&lt;p&gt;Production is clean now: no accounts without an organization, no keys without an owner, no broken links between the two. The proof was taken inside a transaction that was rolled back, so verifying the fix did not create a row to explain later.&lt;/p&gt;

&lt;p&gt;We hit this while building &lt;a href="https://mailflat.net/docs/api-keys" rel="noopener noreferrer"&gt;MailFlat&lt;/a&gt;, where every API key belongs to an inbox, which belongs to an account, which belongs to an organization. A chain is only as good as the day someone forgets to attach the next link.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>migrations</category>
      <category>database</category>
    </item>
    <item>
      <title>SQLite doesn't enforce foreign keys by default, and it cost us three bugs</title>
      <dc:creator>Ender Yentar</dc:creator>
      <pubDate>Tue, 08 Sep 2026 21:17:38 +0000</pubDate>
      <link>https://dev.to/enderyentar/sqlite-doesnt-enforce-foreign-keys-by-default-and-it-cost-us-three-bugs-1ii9</link>
      <guid>https://dev.to/enderyentar/sqlite-doesnt-enforce-foreign-keys-by-default-and-it-cost-us-three-bugs-1ii9</guid>
      <description>&lt;p&gt;Our test suite runs on SQLite. Production runs on PostgreSQL. That is a common setup and for a long time it felt free: tests were fast, isolated, and green.&lt;/p&gt;

&lt;p&gt;Over about three weeks we hit three separate bugs that all came from the same gap. None of them were caught by 1200+ passing tests, because the tests were running on an engine that quietly forgives what production rejects.&lt;/p&gt;

&lt;p&gt;Here they are, in the order we found them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Two tests that were red in production and green locally
&lt;/h2&gt;

&lt;p&gt;We were making a schema change, so we ran the suite against a real Postgres instance for once. Two tests failed. On SQLite the same commit was 1241/1241 green.&lt;/p&gt;

&lt;p&gt;First reaction: today's change broke something. We stashed it and re-ran against a fresh Postgres database. &lt;strong&gt;The same two tests failed.&lt;/strong&gt; So they had been broken for a while and nobody had seen it.&lt;/p&gt;

&lt;p&gt;Both were test bugs, not product bugs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# To simulate "an inbox owned by someone else"
&lt;/span&gt;&lt;span class="n"&gt;other_user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;   &lt;span class="c1"&gt;# a user id that does not exist
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite accepts that row. Postgres rejects it with &lt;code&gt;inboxes_user_id_fkey&lt;/code&gt;. And the test was weak on top of being broken: it wanted to prove "another person's inbox is not accessible", but what it actually created was "an inbox owned by nobody".&lt;/p&gt;

&lt;p&gt;The second one was subtler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# no ORDER BY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unordered &lt;code&gt;.first()&lt;/code&gt;. SQLite happened to return the admin the test needed. Postgres returned a different admin, and the test failed looking for an inbox that belonged to someone else.&lt;/p&gt;

&lt;p&gt;Neither bug was in the product. Both were in tests that had been passing for weeks while measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A migration that could not fail locally, because it never ran locally
&lt;/h2&gt;

&lt;p&gt;Later we wrote a data migration to backfill some rows. It looked fine. It passed review. Then we ran it against Postgres and it blew up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt;
&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inserted_primary_key&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="c1"&gt;# IndexError: tuple index out of range
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sa.table()&lt;/code&gt; is SQLAlchemy's lightweight table construct. It has no primary key definition, so the driver never asks for the generated id and &lt;code&gt;inserted_primary_key&lt;/code&gt; comes back empty. The fix is a real &lt;code&gt;sa.Table()&lt;/code&gt; with an explicit primary key column.&lt;/p&gt;

&lt;p&gt;The point is not the API detail. The point is this: &lt;strong&gt;our test suite does not run Alembic at all.&lt;/strong&gt; Tests create the schema directly. So migration code the code that runs against production data, once, with no undo was the least tested code in the repository. Not under-tested. Zero lines executed.&lt;/p&gt;

&lt;p&gt;We caught this one on a staging box with real Postgres. If we had not had one, we would have found it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deleted rows that came back to life
&lt;/h2&gt;

&lt;p&gt;The third one was the strangest.&lt;/p&gt;

&lt;p&gt;We changed a uniqueness check to ask the &lt;code&gt;api_keys&lt;/code&gt; table instead of a legacy column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revoked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This inbox already has a key.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suddenly, brand new inboxes started reporting that they already had a key.&lt;/p&gt;

&lt;p&gt;The chain took a while to see:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;api_keys.inbox_id&lt;/code&gt; has &lt;code&gt;ondelete="CASCADE"&lt;/code&gt;. In production Postgres, deleting an inbox
deletes its key rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite does not enforce foreign keys unless you turn them on.&lt;/strong&gt; So in tests, deleting
an inbox left its key rows behind as orphans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite reuses row ids.&lt;/strong&gt; A newly created inbox could take the id of a deleted one, and
inherit its orphaned key rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So a fresh inbox "already had a key" a key belonging to an inbox that had been deleted.&lt;/p&gt;

&lt;p&gt;The old code never saw this, because the old check read a column on the inbox itself, and on a new inbox that column is NULL. The bug was not new. It became visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it does not fix
&lt;/h2&gt;

&lt;p&gt;One line, in the engine setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@event.listens_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sqlite_pragmas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbapi_connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection_record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dbapi_connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA foreign_keys=ON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite ships with foreign key enforcement &lt;strong&gt;off&lt;/strong&gt; for backwards compatibility. It has been that way for years and it is documented, but the default is silence: no warning, no error, just a database that accepts rows Postgres would refuse.&lt;/p&gt;

&lt;p&gt;Turning it on made our tests behave like production. Case 3 disappeared. Case 1 would have been caught the day it was written.&lt;/p&gt;

&lt;p&gt;But be clear about what one pragma does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not make SQLite run your migrations. Case 2 is still invisible locally.&lt;/li&gt;
&lt;li&gt;It does not change id allocation. SQLite still reuses ids; Postgres sequences do not.&lt;/li&gt;
&lt;li&gt;It does not make the two engines equivalent. It closes one specific gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we changed beyond the pragma
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Migrations get a real Postgres before they run anywhere else.&lt;/strong&gt; Not the full suite, just the migration, against a database that has the production schema. Ours runs on a small staging machine. Twice now, that box caught something that SQLite structurally could not: once the migration above, once a duplicate revision id that had forked the migration tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Green" now has a scope.&lt;/strong&gt; A green suite means "green on SQLite". That sentence used to be implicit and it was doing a lot of quiet damage. Writing it down changed how we read the result.&lt;/p&gt;

&lt;p&gt;The uncomfortable version of the lesson: for three weeks our tests were measuring a different database than the one our users touch. They were not lying. We were reading them as if they said more than they did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We hit these while building &lt;a href="https://mailflat.net" rel="noopener noreferrer"&gt;MailFlat&lt;/a&gt;, an email API where agents and test suites get real inboxes. The bug journal these came from is kept per-incident, symptom first, which is why three separate weeks turned out to be one story.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>testing</category>
      <category>python</category>
      <category>database</category>
    </item>
  </channel>
</rss>
