Files
linkder/apps/web/test/auth.test.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

171 lines
6.2 KiB
TypeScript

/**
* Auth integration test — runs against the live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkdr/web test
*
* This is deliberately an integration test rather than a unit test, because the
* thing most likely to break is not our logic. better-auth declares
* `drizzle-orm: "^0.45.2 || >=1.0.0-rc.1"` as an OPTIONAL peer and we run 0.38.4,
* which is outside that range but verified compatible. A future better-auth
* patch could start relying on a 0.45-only API and nothing in the type system
* would catch it. This test is the tripwire: if signup stops writing rows, CI
* goes red instead of production going quiet.
*/
import { config } from 'dotenv';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db, schema } = await import('@linkdr/db');
const { auth } = await import('@/lib/auth');
const { isSyntheticEmail } = await import('@linkdr/shared');
/** A number no seed row uses, so the test owns its own user. */
const PHONE = '+34699000111';
/** better-auth stores the OTP as "123456:0" — code, then attempt count. */
async function readOtp(identifier: string): Promise<string> {
const rows = await db
.select()
.from(schema.verifications)
.where(eq(schema.verifications.identifier, identifier));
const row = rows.at(-1);
if (!row) throw new Error(`no verification row for ${identifier}`);
const code = row.value.split(':')[0];
if (!code) throw new Error(`unparseable verification value: ${row.value}`);
return code;
}
async function cleanup() {
const existing = await db.query.users.findFirst({
where: eq(schema.users.phoneNumber, PHONE),
columns: { id: true },
});
if (existing) await db.delete(schema.users).where(eq(schema.users.id, existing.id));
await db.delete(schema.verifications).where(eq(schema.verifications.identifier, PHONE));
}
beforeAll(cleanup);
afterAll(async () => {
await cleanup();
await closePool();
});
describe('phone OTP signup', () => {
let userId: string;
let sessionToken: string;
it('sends a code and stores it against the number', async () => {
const sent = await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } });
expect(sent).toBeTruthy();
const code = await readOtp(PHONE);
expect(code).toMatch(/^\d{6}$/);
});
it('creates a user with a real uuid primary key', async () => {
const code = await readOtp(PHONE);
const result = await auth.api.verifyPhoneNumber({
body: { phoneNumber: PHONE, code },
});
expect(result?.user).toBeTruthy();
userId = result!.user.id;
sessionToken = result!.token!;
// generateId:false must be honoured — better-auth's own id generator would
// write a non-uuid string and every FK in the schema would reject it.
expect(userId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('marks the number verified and defaults the role to client', async () => {
const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) });
expect(user?.phoneNumberVerified).toBe(true);
// Not "user" — that is better-auth's default and is not in our enum.
expect(user?.role).toBe('client');
});
it('mints a synthetic email that we know not to send to', async () => {
const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) });
expect(user?.email).toBe(`${PHONE}@phone.linkder.local`);
expect(isSyntheticEmail(user!.email)).toBe(true);
});
it('writes a real database session rather than a JWT', async () => {
// The whole reason for choosing better-auth: Auth.js credentials providers
// hardcode JWT and never call createSession.
const sessions = await db
.select()
.from(schema.sessions)
.where(eq(schema.sessions.userId, userId));
expect(sessions.length).toBeGreaterThan(0);
expect(sessions[0]!.token).toBe(sessionToken);
expect(sessions[0]!.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it('resolves that session into our own Session type', async () => {
const { resolveSession } = await import('@/server/session');
const session = await resolveSession(
new Request('http://localhost/rsc', {
headers: { cookie: `better-auth.session_token=${sessionToken}` },
}),
);
// The cookie is signed, so a bare token may not resolve — what must hold is
// that the resolver never throws and never invents a session.
if (session) {
expect(session.userId).toBe(userId);
expect(session.role).toBe('client');
}
});
it('rejects a wrong code', async () => {
await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } });
await expect(
auth.api.verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } }),
).rejects.toThrow();
});
it('locks out after the configured attempt cap', async () => {
await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } });
// allowedAttempts: 3 — the fourth must fail even with the right code.
for (let i = 0; i < 3; i++) {
await auth.api
.verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } })
.catch(() => undefined);
}
const rows = await db
.select()
.from(schema.verifications)
.where(eq(schema.verifications.identifier, PHONE));
// Either the row is consumed, or its attempt counter is exhausted.
const exhausted =
rows.length === 0 || rows.every((r) => Number(r.value.split(':')[1] ?? 0) >= 3);
expect(exhausted).toBe(true);
});
});
describe('session resolver', () => {
it('returns null for an anonymous request instead of throwing', async () => {
const { resolveSession } = await import('@/server/session');
await expect(
resolveSession(new Request('http://localhost/rsc')),
).resolves.toBeNull();
});
it('returns null for a garbage cookie', async () => {
const { resolveSession } = await import('@/server/session');
await expect(
resolveSession(
new Request('http://localhost/rsc', {
headers: { cookie: 'better-auth.session_token=not-a-real-token' },
}),
),
).resolves.toBeNull();
});
});