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>
171 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|