feat: Implement email/password and X OAuth authentication, replacing the dev-login mechanism.
This commit is contained in:
@@ -5,6 +5,63 @@ const assert = require("node:assert/strict");
|
||||
const { buildApp } = require("../src/app");
|
||||
const { hmacSHA256Hex } = require("../src/lib/signature");
|
||||
|
||||
function getTestCookieValue(cookieHeader, name) {
|
||||
const parts = String(cookieHeader || "").split(";").map((part) => part.trim());
|
||||
const prefix = `${name}=`;
|
||||
const valuePart = parts.find((part) => part.startsWith(prefix));
|
||||
if (!valuePart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(valuePart.slice(prefix.length));
|
||||
} catch {
|
||||
return valuePart.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
function createTestAuthAdapter() {
|
||||
return {
|
||||
isConfigured() {
|
||||
return true;
|
||||
},
|
||||
isXOAuthConfigured() {
|
||||
return true;
|
||||
},
|
||||
handlesPath() {
|
||||
return false;
|
||||
},
|
||||
async handleRoute() {
|
||||
return { status: 404, headers: {}, body: "not_found" };
|
||||
},
|
||||
async getAuthenticatedUserId(headers = {}) {
|
||||
return getTestCookieValue(headers.cookie, "xartaudio_user");
|
||||
},
|
||||
async signInWithEmail({ email }) {
|
||||
return {
|
||||
setCookie: `xartaudio_user=${encodeURIComponent(String(email))}; Path=/; HttpOnly`,
|
||||
};
|
||||
},
|
||||
async signUpWithEmail({ email }) {
|
||||
return {
|
||||
setCookie: `xartaudio_user=${encodeURIComponent(String(email))}; Path=/; HttpOnly`,
|
||||
};
|
||||
},
|
||||
async getXAuthorizationUrl() {
|
||||
return {
|
||||
url: "https://x.com/i/oauth2/authorize?state=test",
|
||||
setCookie: "xartaudio_oauth_state=test; Path=/; HttpOnly",
|
||||
};
|
||||
},
|
||||
async signOut() {
|
||||
return {
|
||||
ok: true,
|
||||
setCookie: "xartaudio_user=; Path=/; Max-Age=0",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(options = {}) {
|
||||
const baseConfig = {
|
||||
xWebhookSecret: "x-secret",
|
||||
@@ -17,7 +74,8 @@ function createApp(options = {}) {
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
betterAuthSecret: "test-better-auth-secret",
|
||||
betterAuthBasePath: "/api/auth",
|
||||
betterAuthDevPassword: "xartaudio-dev-password",
|
||||
xOAuthClientId: "x-client-id",
|
||||
xOAuthClientSecret: "x-client-secret",
|
||||
internalApiToken: "",
|
||||
convexDeploymentUrl: "",
|
||||
convexAuthToken: "",
|
||||
@@ -75,6 +133,9 @@ function createApp(options = {}) {
|
||||
|
||||
const appOptions = { ...options };
|
||||
delete appOptions.config;
|
||||
if (!appOptions.authAdapter) {
|
||||
appOptions.authAdapter = createTestAuthAdapter();
|
||||
}
|
||||
|
||||
return buildApp({
|
||||
config: mergedConfig,
|
||||
@@ -108,7 +169,7 @@ test("GET / renders landing page", async () => {
|
||||
const app = createApp();
|
||||
const response = await call(app, { method: "GET", path: "/" });
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.body, /From X Article to audiobook in one mention/);
|
||||
assert.match(response.body, /From X Article to audiobook/);
|
||||
});
|
||||
|
||||
test("GET /assets/styles.css serves compiled stylesheet", async () => {
|
||||
@@ -127,17 +188,17 @@ test("unauthenticated /app redirects to /login with returnTo", async () => {
|
||||
assert.match(response.headers.location, /returnTo=%2Fapp/);
|
||||
});
|
||||
|
||||
test("POST /auth/dev-login sets cookie and redirects", async () => {
|
||||
test("POST /auth/email/sign-in sets cookie and redirects", async () => {
|
||||
const app = createApp();
|
||||
const response = await call(app, {
|
||||
method: "POST",
|
||||
path: "/auth/dev-login",
|
||||
body: "userId=matiss&returnTo=%2Fapp",
|
||||
path: "/auth/email/sign-in",
|
||||
body: "email=matiss%40example.com&password=password123&returnTo=%2Fapp",
|
||||
});
|
||||
|
||||
assert.equal(response.status, 303);
|
||||
assert.equal(response.headers.location, "/app");
|
||||
assert.match(String(response.headers["set-cookie"]), /HttpOnly/);
|
||||
assert.match(String(response.headers["set-cookie"]), /xartaudio_user=matiss%40example\.com/);
|
||||
});
|
||||
|
||||
test("authenticated dashboard topup + simulate mention flow", async () => {
|
||||
@@ -954,15 +1015,15 @@ test("rate limits repeated login attempts from same IP", async () => {
|
||||
|
||||
const first = await call(app, {
|
||||
method: "POST",
|
||||
path: "/auth/dev-login",
|
||||
path: "/auth/email/sign-in",
|
||||
headers: { "x-forwarded-for": "5.5.5.5" },
|
||||
body: "userId=alice&returnTo=%2Fapp",
|
||||
body: "email=alice%40example.com&password=password123&returnTo=%2Fapp",
|
||||
});
|
||||
const second = await call(app, {
|
||||
method: "POST",
|
||||
path: "/auth/dev-login",
|
||||
path: "/auth/email/sign-in",
|
||||
headers: { "x-forwarded-for": "5.5.5.5" },
|
||||
body: "userId=alice&returnTo=%2Fapp",
|
||||
body: "email=alice%40example.com&password=password123&returnTo=%2Fapp",
|
||||
});
|
||||
|
||||
assert.equal(first.status, 303);
|
||||
|
||||
@@ -30,13 +30,12 @@ test("clearUserCookie expires session cookie", () => {
|
||||
assert.match(cookie, /Max-Age=0/);
|
||||
});
|
||||
|
||||
test("getAuthenticatedUserId prefers x-user-id header", () => {
|
||||
test("getAuthenticatedUserId resolves from cookie only", () => {
|
||||
const userId = getAuthenticatedUserId({
|
||||
"x-user-id": "header-user",
|
||||
cookie: "xartaudio_user=cookie-user",
|
||||
});
|
||||
|
||||
assert.equal(userId, "header-user");
|
||||
assert.equal(userId, "cookie-user");
|
||||
});
|
||||
|
||||
test("getAuthenticatedUserId falls back to cookie", () => {
|
||||
|
||||
@@ -5,20 +5,13 @@ const assert = require("node:assert/strict");
|
||||
const { createBetterAuthAdapter } = require("../src/integrations/better-auth");
|
||||
|
||||
function createMockAuthHandler() {
|
||||
const signedIn = new Set();
|
||||
|
||||
return async function handler(request) {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
if (path.endsWith("/sign-in/email")) {
|
||||
return new Response(JSON.stringify({ ok: false }), { status: 401 });
|
||||
}
|
||||
|
||||
if (path.endsWith("/sign-up/email")) {
|
||||
const body = await request.json();
|
||||
const userId = String(body.name);
|
||||
signedIn.add(userId);
|
||||
const userId = String(body.email);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -28,6 +21,28 @@ function createMockAuthHandler() {
|
||||
});
|
||||
}
|
||||
|
||||
if (path.endsWith("/sign-up/email")) {
|
||||
const body = await request.json();
|
||||
const userId = String(body.email);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"set-cookie": `xartaudio_better_auth=${userId}; Path=/; HttpOnly`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path.endsWith("/sign-in/social")) {
|
||||
return new Response(JSON.stringify({ url: "https://x.com/i/oauth2/authorize?state=test" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"set-cookie": "xartaudio_oauth_state=test; Path=/; HttpOnly",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path.endsWith("/get-session")) {
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
const match = cookie.match(/xartaudio_better_auth=([^;]+)/);
|
||||
@@ -62,15 +77,33 @@ function createMockAuthHandler() {
|
||||
};
|
||||
}
|
||||
|
||||
test("signInDevUser signs up and returns cookie when user does not exist", async () => {
|
||||
test("signInWithEmail returns session cookie", async () => {
|
||||
const adapter = createBetterAuthAdapter({
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
secret: "test-secret",
|
||||
authHandler: createMockAuthHandler(),
|
||||
});
|
||||
|
||||
const result = await adapter.signInDevUser("alice");
|
||||
assert.match(String(result.setCookie), /xartaudio_better_auth=alice/);
|
||||
const result = await adapter.signInWithEmail({
|
||||
email: "alice@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
assert.match(String(result.setCookie), /xartaudio_better_auth=alice@example\.com/);
|
||||
});
|
||||
|
||||
test("signUpWithEmail returns session cookie", async () => {
|
||||
const adapter = createBetterAuthAdapter({
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
secret: "test-secret",
|
||||
authHandler: createMockAuthHandler(),
|
||||
});
|
||||
|
||||
const result = await adapter.signUpWithEmail({
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
assert.match(String(result.setCookie), /xartaudio_better_auth=alice@example\.com/);
|
||||
});
|
||||
|
||||
test("getAuthenticatedUserId resolves session user from better-auth endpoint", async () => {
|
||||
@@ -86,6 +119,23 @@ test("getAuthenticatedUserId resolves session user from better-auth endpoint", a
|
||||
assert.equal(userId, "bob");
|
||||
});
|
||||
|
||||
test("getXAuthorizationUrl returns provider url when x oauth is configured", async () => {
|
||||
const adapter = createBetterAuthAdapter({
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
secret: "test-secret",
|
||||
xOAuthClientId: "x-client-id",
|
||||
xOAuthClientSecret: "x-client-secret",
|
||||
authHandler: createMockAuthHandler(),
|
||||
});
|
||||
|
||||
const result = await adapter.getXAuthorizationUrl({
|
||||
callbackURL: "http://localhost:3000/app",
|
||||
});
|
||||
|
||||
assert.match(result.url, /^https:\/\/x\.com\/i\/oauth2\/authorize/);
|
||||
assert.match(String(result.setCookie), /xartaudio_oauth_state=test/);
|
||||
});
|
||||
|
||||
test("handleRoute proxies requests into better-auth handler", async () => {
|
||||
const adapter = createBetterAuthAdapter({
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -48,6 +48,8 @@ test("config uses defaults when env is missing", () => {
|
||||
assert.equal(config.logLevel, "info");
|
||||
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
||||
assert.equal(config.betterAuthBasePath, "/api/auth");
|
||||
assert.equal(config.xOAuthClientId, "");
|
||||
assert.equal(config.xOAuthClientSecret, "");
|
||||
assert.equal(config.internalApiToken, "");
|
||||
assert.equal(config.qwenTtsModel, "qwen-tts-latest");
|
||||
assert.equal(config.minioSignedUrlTtlSec, 3600);
|
||||
@@ -66,7 +68,8 @@ test("config reads convex/qwen/minio overrides", () => {
|
||||
APP_BASE_URL: "https://xartaudio.app",
|
||||
BETTER_AUTH_SECRET: "prod-secret",
|
||||
BETTER_AUTH_BASE_PATH: "/api/auth",
|
||||
BETTER_AUTH_DEV_PASSWORD: "xartaudio-dev-password",
|
||||
X_OAUTH_CLIENT_ID: "x-client-id",
|
||||
X_OAUTH_CLIENT_SECRET: "x-client-secret",
|
||||
INTERNAL_API_TOKEN: "internal-token",
|
||||
CONVEX_DEPLOYMENT_URL: "https://example.convex.cloud",
|
||||
CONVEX_URL: "https://should-not-win.convex.cloud",
|
||||
@@ -89,6 +92,8 @@ test("config reads convex/qwen/minio overrides", () => {
|
||||
assert.equal(config.logLevel, "debug");
|
||||
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
||||
assert.equal(config.betterAuthSecret, "prod-secret");
|
||||
assert.equal(config.xOAuthClientId, "x-client-id");
|
||||
assert.equal(config.xOAuthClientSecret, "x-client-secret");
|
||||
assert.equal(config.internalApiToken, "internal-token");
|
||||
assert.equal(config.convexDeploymentUrl, "https://example.convex.cloud");
|
||||
assert.equal(config.convexAuthToken, "convex-token");
|
||||
|
||||
@@ -16,7 +16,8 @@ function createRuntimeConfig() {
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
betterAuthSecret: "test-better-auth-secret",
|
||||
betterAuthBasePath: "/api/auth",
|
||||
betterAuthDevPassword: "xartaudio-dev-password",
|
||||
xOAuthClientId: "",
|
||||
xOAuthClientSecret: "",
|
||||
internalApiToken: "",
|
||||
convexDeploymentUrl: "",
|
||||
convexAuthToken: "",
|
||||
@@ -132,10 +133,19 @@ test("createRuntime falls back to in-memory state when initial load fails", asyn
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(String(warnings[0].message), /falling back to in-memory state/);
|
||||
|
||||
const signUp = await runtime.app.handleRequest({
|
||||
method: "POST",
|
||||
path: "/auth/email/sign-up",
|
||||
headers: {},
|
||||
rawBody: "name=User+One&email=u1%40example.com&password=password123&returnTo=%2Fapp",
|
||||
query: {},
|
||||
});
|
||||
assert.equal(signUp.status, 303);
|
||||
|
||||
const response = await runtime.app.handleRequest({
|
||||
method: "POST",
|
||||
path: "/app/actions/topup",
|
||||
headers: { "x-user-id": "u1" },
|
||||
headers: { cookie: signUp.headers["set-cookie"] },
|
||||
rawBody: "amount=3",
|
||||
query: {},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ const assert = require("node:assert/strict");
|
||||
const {
|
||||
XWebhookPayloadSchema,
|
||||
PolarWebhookPayloadSchema,
|
||||
LoginFormSchema,
|
||||
EmailSignInFormSchema,
|
||||
EmailSignUpFormSchema,
|
||||
TopUpFormSchema,
|
||||
SimulateMentionFormSchema,
|
||||
parseOrThrow,
|
||||
@@ -31,13 +32,24 @@ test("validates Polar webhook payload with numeric coercion", () => {
|
||||
assert.equal(parsed.credits, 12);
|
||||
});
|
||||
|
||||
test("rejects invalid login username", () => {
|
||||
test("rejects invalid email sign-in payload", () => {
|
||||
assert.throws(
|
||||
() => parseOrThrow(LoginFormSchema, { userId: "!" }),
|
||||
/Username must be 2-40 characters using letters, numbers, _ or -/,
|
||||
() => parseOrThrow(EmailSignInFormSchema, { email: "not-an-email", password: "12345678" }),
|
||||
/Enter a valid email address/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validates email sign-up payload", () => {
|
||||
const parsed = parseOrThrow(EmailSignUpFormSchema, {
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
|
||||
assert.equal(parsed.name, "Alice");
|
||||
assert.equal(parsed.email, "alice@example.com");
|
||||
});
|
||||
|
||||
test("validates topup amount range", () => {
|
||||
assert.throws(() => parseOrThrow(TopUpFormSchema, { amount: "999" }), /Too big/);
|
||||
|
||||
|
||||
@@ -19,15 +19,17 @@ test("shell includes daisyui and pwa tags", () => {
|
||||
|
||||
test("landing page renders hero and flow sections", () => {
|
||||
const html = renderLandingPage({ authenticated: false, userId: null });
|
||||
assert.match(html, /From X Article to audiobook in one mention/);
|
||||
assert.match(html, /From X Article to audiobook/);
|
||||
assert.match(html, /id="how"/);
|
||||
assert.match(html, /id="pricing"/);
|
||||
});
|
||||
|
||||
test("login page renders username form", () => {
|
||||
test("login page renders email and x auth forms", () => {
|
||||
const html = renderLoginPage({ returnTo: "/audio/1" });
|
||||
assert.match(html, /action="\/auth\/dev-login"/);
|
||||
assert.match(html, /name="userId"/);
|
||||
assert.match(html, /action="\/auth\/x"/);
|
||||
assert.match(html, /action="\/auth\/email\/sign-in"/);
|
||||
assert.match(html, /action="\/auth\/email\/sign-up"/);
|
||||
assert.match(html, /name="email"/);
|
||||
assert.match(html, /value="\/audio\/1"/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user