304 lines
7.7 KiB
JavaScript
304 lines
7.7 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { buildApp } = require("../src/app");
|
|
const { hmacSHA256Hex } = require("../src/lib/signature");
|
|
|
|
function createApp(options = {}) {
|
|
const baseConfig = {
|
|
xWebhookSecret: "x-secret",
|
|
polarWebhookSecret: "polar-secret",
|
|
rateLimits: {
|
|
webhookPerMinute: 120,
|
|
authPerMinute: 30,
|
|
actionPerMinute: 60,
|
|
},
|
|
credit: {
|
|
baseCredits: 1,
|
|
includedChars: 25000,
|
|
stepChars: 10000,
|
|
stepCredits: 1,
|
|
maxCharsPerArticle: 120000,
|
|
},
|
|
};
|
|
|
|
const overrideConfig = options.config || {};
|
|
const mergedConfig = {
|
|
...baseConfig,
|
|
...overrideConfig,
|
|
rateLimits: {
|
|
...baseConfig.rateLimits,
|
|
...(overrideConfig.rateLimits || {}),
|
|
},
|
|
credit: {
|
|
...baseConfig.credit,
|
|
...(overrideConfig.credit || {}),
|
|
},
|
|
};
|
|
|
|
const appOptions = { ...options };
|
|
delete appOptions.config;
|
|
|
|
return buildApp({
|
|
config: mergedConfig,
|
|
...appOptions,
|
|
});
|
|
}
|
|
|
|
function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
|
return app.handleRequest({
|
|
method,
|
|
path,
|
|
headers,
|
|
rawBody: body,
|
|
query,
|
|
});
|
|
}
|
|
|
|
function postJSONWebhook(app, path, payload, secret) {
|
|
const rawBody = JSON.stringify(payload);
|
|
const sig = hmacSHA256Hex(rawBody, secret);
|
|
|
|
return call(app, {
|
|
method: "POST",
|
|
path,
|
|
headers: { "x-signature": `sha256=${sig}` },
|
|
body: rawBody,
|
|
});
|
|
}
|
|
|
|
test("GET / renders landing page", () => {
|
|
const app = createApp();
|
|
const response = call(app, { method: "GET", path: "/" });
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.body, /From X Article to audiobook in one mention/);
|
|
});
|
|
|
|
test("unauthenticated /app redirects to /login with returnTo", () => {
|
|
const app = createApp();
|
|
const response = call(app, { method: "GET", path: "/app" });
|
|
assert.equal(response.status, 303);
|
|
assert.match(response.headers.location, /^\/login\?/);
|
|
assert.match(response.headers.location, /returnTo=%2Fapp/);
|
|
});
|
|
|
|
test("POST /auth/dev-login sets cookie and redirects", () => {
|
|
const app = createApp();
|
|
const response = call(app, {
|
|
method: "POST",
|
|
path: "/auth/dev-login",
|
|
body: "userId=matiss&returnTo=%2Fapp",
|
|
});
|
|
|
|
assert.equal(response.status, 303);
|
|
assert.equal(response.headers.location, "/app");
|
|
assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/);
|
|
});
|
|
|
|
test("authenticated dashboard topup + simulate mention flow", () => {
|
|
const app = createApp();
|
|
const cookieHeader = "xartaudio_user=alice";
|
|
|
|
const topup = call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: cookieHeader },
|
|
body: "amount=8",
|
|
});
|
|
assert.equal(topup.status, 303);
|
|
assert.match(topup.headers.location, /Added%208%20credits/);
|
|
|
|
const simulate = call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: cookieHeader },
|
|
body: "title=Hello&body=This+is+the+article+body",
|
|
});
|
|
assert.equal(simulate.status, 303);
|
|
assert.match(simulate.headers.location, /^\/audio\//);
|
|
|
|
const dashboard = call(app, {
|
|
method: "GET",
|
|
path: "/app",
|
|
headers: { cookie: cookieHeader },
|
|
});
|
|
assert.equal(dashboard.status, 200);
|
|
assert.match(dashboard.body, /Recent audiobooks/);
|
|
assert.match(dashboard.body, /Hello/);
|
|
});
|
|
|
|
test("audio flow requires auth for unlock and supports permanent unlock", () => {
|
|
const app = createApp();
|
|
|
|
call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=owner" },
|
|
body: "amount=5",
|
|
});
|
|
call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
body: "amount=5",
|
|
});
|
|
|
|
const generated = call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=owner" },
|
|
body: "title=Owned+Audio&body=Body",
|
|
});
|
|
|
|
const audioPath = generated.headers.location.split("?")[0];
|
|
const assetId = audioPath.replace("/audio/", "");
|
|
|
|
const beforeUnlock = call(app, {
|
|
method: "GET",
|
|
path: audioPath,
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
|
|
|
|
const unlock = call(app, {
|
|
method: "POST",
|
|
path: `/audio/${assetId}/unlock`,
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
assert.equal(unlock.status, 303);
|
|
|
|
const afterUnlock = call(app, {
|
|
method: "GET",
|
|
path: audioPath,
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
assert.match(afterUnlock.body, /Access granted/);
|
|
|
|
const wallet = call(app, {
|
|
method: "GET",
|
|
path: "/api/me/wallet",
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
const walletData = JSON.parse(wallet.body);
|
|
assert.equal(walletData.balance, 4);
|
|
});
|
|
|
|
test("X webhook invalid signature is rejected", () => {
|
|
const app = createApp();
|
|
const response = call(app, {
|
|
method: "POST",
|
|
path: "/api/webhooks/x",
|
|
headers: { "x-signature": "sha256=deadbeef" },
|
|
body: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }),
|
|
});
|
|
|
|
assert.equal(response.status, 401);
|
|
});
|
|
|
|
test("X webhook valid flow processes article", () => {
|
|
const app = createApp();
|
|
|
|
postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
|
const response = postJSONWebhook(
|
|
app,
|
|
"/api/webhooks/x",
|
|
{
|
|
mentionPostId: "m2",
|
|
callerUserId: "u1",
|
|
parentPost: {
|
|
id: "p2",
|
|
article: { id: "a2", title: "T", body: "Hello" },
|
|
},
|
|
},
|
|
"x-secret",
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body);
|
|
assert.equal(body.status, "completed");
|
|
assert.equal(body.creditsCharged, 1);
|
|
});
|
|
|
|
test("emits persistence snapshots on mutating actions", () => {
|
|
const snapshots = [];
|
|
const app = createApp({
|
|
onMutation(state) {
|
|
snapshots.push(state);
|
|
},
|
|
});
|
|
|
|
call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "amount=5",
|
|
});
|
|
|
|
call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "title=Persisted&body=hello",
|
|
});
|
|
|
|
assert.equal(snapshots.length >= 2, true);
|
|
const latest = snapshots[snapshots.length - 1];
|
|
assert.equal(latest.version, 1);
|
|
assert.equal(typeof latest.updatedAt, "string");
|
|
assert.equal(typeof latest.engine, "object");
|
|
});
|
|
|
|
test("rate limits repeated webhook calls", () => {
|
|
const app = createApp({
|
|
config: {
|
|
rateLimits: {
|
|
webhookPerMinute: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const first = call(app, {
|
|
method: "POST",
|
|
path: "/api/webhooks/x",
|
|
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const second = call(app, {
|
|
method: "POST",
|
|
path: "/api/webhooks/x",
|
|
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
assert.equal(first.status, 401);
|
|
assert.equal(second.status, 429);
|
|
});
|
|
|
|
test("rate limits repeated login attempts from same IP", () => {
|
|
const app = createApp({
|
|
config: {
|
|
rateLimits: {
|
|
authPerMinute: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const first = call(app, {
|
|
method: "POST",
|
|
path: "/auth/dev-login",
|
|
headers: { "x-forwarded-for": "5.5.5.5" },
|
|
body: "userId=alice&returnTo=%2Fapp",
|
|
});
|
|
const second = call(app, {
|
|
method: "POST",
|
|
path: "/auth/dev-login",
|
|
headers: { "x-forwarded-for": "5.5.5.5" },
|
|
body: "userId=alice&returnTo=%2Fapp",
|
|
});
|
|
|
|
assert.equal(first.status, 303);
|
|
assert.equal(second.status, 303);
|
|
assert.match(second.headers.location, /Too%20many%20requests/);
|
|
});
|