Browse Source

Enabling 2FA fails (#372)

* Give the two-factor table the columns its plugin writes

* Tests no longer depend on what the shell has in its environment
Bernt Christian Egeland 3 weeks ago
parent
commit
a468981b75

+ 4 - 0
prisma/migrations/20260909210000_two_factor_verification_state/migration.sql

@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "two_factor" ADD COLUMN     "verified" BOOLEAN NOT NULL DEFAULT true,
+ADD COLUMN     "failedVerificationCount" INTEGER NOT NULL DEFAULT 0,
+ADD COLUMN     "lockedUntil" TIMESTAMP(3);

+ 12 - 5
prisma/schema/auth.prisma

@@ -104,11 +104,18 @@ model Verification {
 }
 
 model TwoFactor {
-  id          String @id @default(cuid())
-  secret      String
-  backupCodes String
-  userId      String @unique
-  user        User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+  id                      String    @id @default(cuid())
+  secret                  String
+  backupCodes             String
+  userId                  String    @unique
+  user                    User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  // better-auth's two-factor plugin writes these three since 1.6: a secret
+  // stays unverified until the first code is entered, and repeated wrong
+  // codes lock the method for a while. Rows from before default to verified,
+  // because those people already proved their app worked.
+  verified                Boolean   @default(true)
+  failedVerificationCount Int       @default(0)
+  lockedUntil             DateTime?
 
   @@map("two_factor")
 }

+ 7 - 7
prisma/schema/integrations.prisma

@@ -136,16 +136,16 @@ model ExternalCalendarEvent {
 /// keyed by model rather than by workshop or vehicle, so a 2003 Accord is
 /// fetched once for everyone. A cache: rebuilt by the connector, never edited.
 model VehicleSafetyReport {
-  id     String @id @default(cuid())
+  id             String   @id @default(cuid())
   /// Connector id, such as "nhtsa".
-  source String
+  source         String
   /// Make, model and year as the workshop wrote them, upper-cased and trimmed.
-  make   String
-  model  String
-  year   Int
+  make           String
+  model          String
+  year           Int
   /// False when the authority answered but had no such model.
-  found  Boolean @default(true)
-  data   Json
+  found          Boolean  @default(true)
+  data           Json
   /// Counts lifted out of data so a list can show a badge without parsing it.
   recallCount    Int      @default(0)
   complaintCount Int      @default(0)

+ 45 - 0
src/__tests__/auth/two-factor-schema.test.ts

@@ -0,0 +1,45 @@
+// @vitest-environment node
+/**
+ * better-auth's two-factor plugin writes whatever its own schema declares,
+ * and Prisma refuses a column it does not know. A field the plugin gained
+ * in an upgrade and the model did not is "Failed to enable 2FA" for every
+ * user, found in production. This holds the two in step.
+ */
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { twoFactor } from 'better-auth/plugins/two-factor'
+
+const model = readFileSync('prisma/schema/auth.prisma', 'utf8')
+const modelText = model.slice(model.indexOf('model TwoFactor {'))
+const body = modelText.slice(0, modelText.indexOf('\n}'))
+
+describe('two-factor schema', () => {
+  it('has a column for every field the plugin may write', () => {
+    // The plugin carries its own schema; the exported instance is the only
+    // public way at it.
+    const plugin = twoFactor() as unknown as {
+      schema: { twoFactor: { fields: Record<string, unknown> } }
+    }
+    const fields = Object.keys(plugin.schema.twoFactor.fields)
+    expect(fields).toEqual(
+      expect.arrayContaining(['verified', 'failedVerificationCount', 'lockedUntil'])
+    )
+    for (const field of fields) {
+      expect(body, `TwoFactor model is missing "${field}"`).toMatch(
+        new RegExp(`^\\s+${field}\\s`, 'm')
+      )
+    }
+  })
+
+  it('has a migration adding the verification state', () => {
+    const migration = readFileSync(
+      'prisma/migrations/20260909210000_two_factor_verification_state/migration.sql',
+      'utf8'
+    )
+    for (const column of ['verified', 'failedVerificationCount', 'lockedUntil']) {
+      expect(migration).toContain(`"${column}"`)
+    }
+    // Existing rows belong to people who already verified their app.
+    expect(migration).toMatch(/"verified" BOOLEAN NOT NULL DEFAULT true/)
+  })
+})

+ 2 - 0
src/__tests__/lib/support.test.ts

@@ -35,6 +35,8 @@ describe('isSupportEnabled', () => {
   })
 
   it('is off when no mode is configured at all', async () => {
+    // A developer's shell may carry the cloud mode; this case is about its absence.
+    vi.stubEnv('TORQVOICE_MODE', '')
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     mockFindUnique.mockResolvedValue({ value: 'true' } as any)
     expect(await isSupportEnabled()).toBe(false)

+ 8 - 3
src/__tests__/lib/whatsapp-adapters.test.ts

@@ -7,7 +7,7 @@
  * that has to survive whatever the provider posts back.
  */
 
-import { describe, it, expect } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import { createHmac } from 'crypto'
 import { buildMetaPayload, metaAdapter } from '@/lib/whatsapp/adapters/meta'
 import { buildTwilioForm, twilioAdapter } from '@/lib/whatsapp/adapters/twilio'
@@ -263,8 +263,13 @@ describe('twilio payloads', () => {
 })
 
 describe('twilio webhook', () => {
-  // Twilio signs the public URL plus the form with the auth token. Nothing
-  // configures NEXT_PUBLIC_APP_URL here, so the request's own origin is it.
+  // Twilio signs the public URL plus the form with the auth token. The
+  // adapter checks against the configured public address, so that address
+  // is pinned to the request's origin here: on a machine with a real one in
+  // the environment, every signature below would be for the wrong host.
+  beforeAll(() => vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test'))
+  afterAll(() => vi.unstubAllEnvs())
+
   function inbound(fields: Record<string, string>, token = 'tok_abc', authToken = 'secret') {
     const url = `https://app.test/api/webhooks/whatsapp/twilio/org_1?token=${token}`
     const body = new URLSearchParams(fields)