972 lines
26 KiB
JavaScript
972 lines
26 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",
|
|
xBearerToken: "",
|
|
xBotUserId: "",
|
|
polarAccessToken: "",
|
|
polarServer: "production",
|
|
polarProductIds: [],
|
|
appBaseUrl: "http://localhost:3000",
|
|
betterAuthSecret: "test-better-auth-secret",
|
|
betterAuthBasePath: "/api/auth",
|
|
betterAuthDevPassword: "xartaudio-dev-password",
|
|
internalApiToken: "",
|
|
convexDeploymentUrl: "",
|
|
convexAuthToken: "",
|
|
convexStateQuery: "state:getLatestSnapshot",
|
|
convexStateMutation: "state:saveSnapshot",
|
|
qwenTtsApiKey: "",
|
|
qwenTtsBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
qwenTtsModel: "qwen-tts-latest",
|
|
qwenTtsVoice: "Cherry",
|
|
qwenTtsFormat: "mp3",
|
|
minioBucket: "",
|
|
minioEndPoint: "",
|
|
minioPort: 443,
|
|
minioUseSSL: true,
|
|
minioRegion: "us-east-1",
|
|
minioAccessKey: "",
|
|
minioSecretKey: "",
|
|
minioSignedUrlTtlSec: 3600,
|
|
rateLimits: {
|
|
webhookPerMinute: 120,
|
|
authPerMinute: 30,
|
|
actionPerMinute: 60,
|
|
},
|
|
abuse: {
|
|
maxJobsPerUserPerDay: 0,
|
|
cooldownSec: 0,
|
|
denyUserIds: [],
|
|
},
|
|
credit: {
|
|
baseCredits: 1,
|
|
includedChars: 25000,
|
|
stepChars: 10000,
|
|
stepCredits: 1,
|
|
maxCharsPerArticle: 120000,
|
|
},
|
|
};
|
|
|
|
const overrideConfig = options.config || {};
|
|
const mergedConfig = {
|
|
...baseConfig,
|
|
...overrideConfig,
|
|
rateLimits: {
|
|
...baseConfig.rateLimits,
|
|
...(overrideConfig.rateLimits || {}),
|
|
},
|
|
abuse: {
|
|
...baseConfig.abuse,
|
|
...(overrideConfig.abuse || {}),
|
|
},
|
|
credit: {
|
|
...baseConfig.credit,
|
|
...(overrideConfig.credit || {}),
|
|
},
|
|
};
|
|
|
|
const appOptions = { ...options };
|
|
delete appOptions.config;
|
|
|
|
return buildApp({
|
|
config: mergedConfig,
|
|
...appOptions,
|
|
});
|
|
}
|
|
|
|
async function call(app, { method, path, headers = {}, body = "", query = {} }) {
|
|
return app.handleRequest({
|
|
method,
|
|
path,
|
|
headers,
|
|
rawBody: body,
|
|
query,
|
|
});
|
|
}
|
|
|
|
async function postJSONWebhook(app, path, payload, secret, extraHeaders) {
|
|
const rawBody = JSON.stringify(payload);
|
|
const sig = hmacSHA256Hex(rawBody, secret);
|
|
|
|
return call(app, {
|
|
method: "POST",
|
|
path,
|
|
headers: { "x-signature": `sha256=${sig}`, ...(extraHeaders || {}) },
|
|
body: rawBody,
|
|
});
|
|
}
|
|
|
|
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/);
|
|
});
|
|
|
|
test("GET /assets/styles.css serves compiled stylesheet", async () => {
|
|
const app = createApp();
|
|
const response = await call(app, { method: "GET", path: "/assets/styles.css" });
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers["content-type"], /text\/css/);
|
|
assert.match(response.body, /\.btn/);
|
|
});
|
|
|
|
test("unauthenticated /app redirects to /login with returnTo", async () => {
|
|
const app = createApp();
|
|
const response = await 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", async () => {
|
|
const app = createApp();
|
|
const response = await 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(String(response.headers["set-cookie"]), /HttpOnly/);
|
|
});
|
|
|
|
test("authenticated dashboard topup + simulate mention flow", async () => {
|
|
const app = createApp();
|
|
const cookieHeader = "xartaudio_user=alice";
|
|
|
|
const topup = await 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 = await 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 = await 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", async () => {
|
|
const app = createApp();
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=owner" },
|
|
body: "amount=5",
|
|
});
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
body: "amount=5",
|
|
});
|
|
|
|
const generated = await 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 = await call(app, {
|
|
method: "GET",
|
|
path: audioPath,
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
assert.match(beforeUnlock.body, /Unlock required: 1 credits/);
|
|
|
|
const unlock = await call(app, {
|
|
method: "POST",
|
|
path: `/audio/${assetId}/unlock`,
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
assert.equal(unlock.status, 303);
|
|
|
|
const afterUnlock = await call(app, {
|
|
method: "GET",
|
|
path: audioPath,
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
assert.match(afterUnlock.body, /Access granted/);
|
|
|
|
const wallet = await 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("owner can delete audio while non owner is forbidden", async () => {
|
|
const app = createApp();
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=owner-delete" },
|
|
body: "amount=5",
|
|
});
|
|
|
|
const generated = await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=owner-delete" },
|
|
body: "title=Delete+Me&body=Body",
|
|
});
|
|
const assetId = generated.headers.location.split("?")[0].replace("/audio/", "");
|
|
|
|
const forbidden = await call(app, {
|
|
method: "DELETE",
|
|
path: `/api/audio/${assetId}`,
|
|
headers: { cookie: "xartaudio_user=someone-else" },
|
|
});
|
|
assert.equal(forbidden.status, 403);
|
|
|
|
const deleted = await call(app, {
|
|
method: "DELETE",
|
|
path: `/api/audio/${assetId}`,
|
|
headers: { cookie: "xartaudio_user=owner-delete" },
|
|
});
|
|
assert.equal(deleted.status, 200);
|
|
assert.equal(JSON.parse(deleted.body).status, "deleted");
|
|
|
|
const pageAfterDelete = await call(app, {
|
|
method: "GET",
|
|
path: `/audio/${assetId}`,
|
|
headers: { cookie: "xartaudio_user=owner-delete" },
|
|
});
|
|
assert.match(pageAfterDelete.body, /Audio not found/);
|
|
});
|
|
|
|
test("audio page uses signed storage URL when storage adapter is configured", async () => {
|
|
const app = createApp({
|
|
storageAdapter: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async getSignedDownloadUrl(key) {
|
|
return `https://signed.local/${key}`;
|
|
},
|
|
async uploadAudio() {},
|
|
},
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=owner" },
|
|
body: "amount=2",
|
|
});
|
|
|
|
const generated = await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=owner" },
|
|
body: "title=Signed&body=Audio+Body",
|
|
});
|
|
|
|
const audioPath = generated.headers.location.split("?")[0];
|
|
const page = await call(app, {
|
|
method: "GET",
|
|
path: audioPath,
|
|
headers: { cookie: "xartaudio_user=owner" },
|
|
});
|
|
|
|
assert.match(page.body, /https:\/\/signed\.local\/audio\/1\.mp3/);
|
|
});
|
|
|
|
test("/api/x/mentions returns upstream mentions when configured", async () => {
|
|
const app = createApp({
|
|
xAdapter: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async listMentions({ sinceId }) {
|
|
return [{ id: "m1", sinceId: sinceId || null }];
|
|
},
|
|
},
|
|
});
|
|
|
|
const response = await call(app, {
|
|
method: "GET",
|
|
path: "/api/x/mentions",
|
|
query: { sinceId: "100" },
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body);
|
|
assert.equal(body.mentions.length, 1);
|
|
assert.equal(body.mentions[0].id, "m1");
|
|
});
|
|
|
|
test("simulate mention schedules background audio generation when service is configured", async () => {
|
|
const queued = [];
|
|
const app = createApp({
|
|
audioGenerationService: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async enqueueJob(payload) {
|
|
queued.push(payload);
|
|
},
|
|
},
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "amount=3",
|
|
});
|
|
|
|
const simulate = await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "title=T&body=hello+world",
|
|
});
|
|
|
|
assert.equal(simulate.status, 303);
|
|
assert.equal(queued.length, 1);
|
|
assert.equal(typeof queued[0].assetId, "string");
|
|
assert.equal(queued[0].text, "hello world");
|
|
});
|
|
|
|
test("failed background generation refunds charged credits", async () => {
|
|
const app = createApp({
|
|
audioGenerationService: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async enqueueJob(payload) {
|
|
payload.onFailed(new Error("tts_outage"));
|
|
},
|
|
},
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "amount=3",
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "title=T&body=hello+world",
|
|
});
|
|
|
|
const wallet = await call(app, {
|
|
method: "GET",
|
|
path: "/api/me/wallet",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
});
|
|
assert.equal(JSON.parse(wallet.body).balance, 3);
|
|
});
|
|
|
|
test("internal worker endpoints require token and can complete jobs", async () => {
|
|
const queued = [];
|
|
const app = createApp({
|
|
config: {
|
|
internalApiToken: "internal-token",
|
|
},
|
|
audioGenerationService: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async enqueueJob(payload) {
|
|
queued.push(payload);
|
|
},
|
|
},
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "amount=2",
|
|
});
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "title=Queued&body=hello",
|
|
});
|
|
|
|
const job = app.engine.listJobsForUser("alice")[0];
|
|
assert.equal(job.status, "synthesizing");
|
|
|
|
const denied = await call(app, {
|
|
method: "POST",
|
|
path: `/internal/jobs/${job.id}/complete`,
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(denied.status, 401);
|
|
|
|
const completed = await call(app, {
|
|
method: "POST",
|
|
path: `/internal/jobs/${job.id}/complete`,
|
|
headers: { "x-internal-token": "internal-token" },
|
|
body: JSON.stringify({
|
|
asset: {
|
|
storageKey: "audio/worker.mp3",
|
|
sizeBytes: 999,
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert.equal(completed.status, 200);
|
|
const completedBody = JSON.parse(completed.body);
|
|
assert.equal(completedBody.job.status, "completed");
|
|
|
|
const readJob = await call(app, {
|
|
method: "GET",
|
|
path: `/api/jobs/${job.id}`,
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
});
|
|
assert.equal(readJob.status, 200);
|
|
assert.equal(JSON.parse(readJob.body).job.status, "completed");
|
|
});
|
|
|
|
test("internal retention endpoint prunes stale content and assets", async () => {
|
|
const app = createApp({
|
|
config: {
|
|
internalApiToken: "internal-token",
|
|
},
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=retention-owner" },
|
|
body: "amount=2",
|
|
});
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/simulate-mention",
|
|
headers: { cookie: "xartaudio_user=retention-owner" },
|
|
body: "title=Retention&body=Body",
|
|
});
|
|
|
|
const job = app.engine.listJobsForUser("retention-owner")[0];
|
|
const asset = app.engine.getAsset(job.assetId, { includeDeleted: true });
|
|
job.createdAt = "2020-01-01T00:00:00.000Z";
|
|
asset.createdAt = "2020-01-01T00:00:00.000Z";
|
|
|
|
const denied = await call(app, {
|
|
method: "POST",
|
|
path: "/internal/retention/run",
|
|
body: JSON.stringify({ rawArticleHours: 1, audioDays: 1 }),
|
|
});
|
|
assert.equal(denied.status, 401);
|
|
|
|
const run = await call(app, {
|
|
method: "POST",
|
|
path: "/internal/retention/run",
|
|
headers: { "x-internal-token": "internal-token" },
|
|
body: JSON.stringify({ rawArticleHours: 1, audioDays: 1 }),
|
|
});
|
|
assert.equal(run.status, 200);
|
|
const summary = JSON.parse(run.body).summary;
|
|
assert.equal(summary.prunedArticleBodies >= 1, true);
|
|
assert.equal(summary.deletedAssets >= 1, true);
|
|
|
|
const page = await call(app, {
|
|
method: "GET",
|
|
path: `/audio/${job.assetId}`,
|
|
headers: { cookie: "xartaudio_user=retention-owner" },
|
|
});
|
|
assert.match(page.body, /Audio not found/);
|
|
});
|
|
|
|
test("internal endpoints are disabled when no token configured", async () => {
|
|
const app = createApp();
|
|
const response = await call(app, {
|
|
method: "POST",
|
|
path: "/internal/retention/run",
|
|
body: "{}",
|
|
});
|
|
assert.equal(response.status, 503);
|
|
assert.equal(JSON.parse(response.body).error, "internal_api_disabled");
|
|
});
|
|
|
|
test("/api/payments/create-checkout returns 503 when Polar is not configured", async () => {
|
|
const app = createApp();
|
|
|
|
const response = await call(app, {
|
|
method: "POST",
|
|
path: "/api/payments/create-checkout",
|
|
headers: { cookie: "xartaudio_user=viewer" },
|
|
});
|
|
|
|
assert.equal(response.status, 503);
|
|
assert.equal(JSON.parse(response.body).error, "polar_checkout_not_configured");
|
|
});
|
|
|
|
test("/api/payments/create-checkout returns checkout URL when adapter is configured", async () => {
|
|
const app = createApp({
|
|
polarAdapter: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async createCheckoutSession() {
|
|
return { id: "chk_1", url: "https://polar.sh/checkout/chk_1" };
|
|
},
|
|
parseWebhookEvent() {
|
|
return null;
|
|
},
|
|
extractTopUp(payload) {
|
|
return payload;
|
|
},
|
|
},
|
|
});
|
|
|
|
const response = await call(app, {
|
|
method: "POST",
|
|
path: "/api/payments/create-checkout",
|
|
headers: { cookie: "xartaudio_user=buyer" },
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body);
|
|
assert.equal(body.checkoutId, "chk_1");
|
|
assert.equal(body.checkoutUrl, "https://polar.sh/checkout/chk_1");
|
|
});
|
|
|
|
test("X webhook invalid signature is rejected", async () => {
|
|
const app = createApp();
|
|
const response = await 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", async () => {
|
|
const app = createApp();
|
|
|
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret");
|
|
const response = await 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("X webhook supports CRC challenge response", async () => {
|
|
const app = createApp();
|
|
const response = await call(app, {
|
|
method: "GET",
|
|
path: "/api/webhooks/x",
|
|
query: { crc_token: "token-123" },
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body);
|
|
assert.match(body.response_token, /^sha256=/);
|
|
});
|
|
|
|
test("X webhook can normalize mentionTweetId payload and reply via adapter", async () => {
|
|
const replies = [];
|
|
const app = createApp({
|
|
xAdapter: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async listMentions() {
|
|
return [];
|
|
},
|
|
async fetchParentPostFromMention() {
|
|
return {
|
|
id: "parent-1",
|
|
authorId: "author-1",
|
|
article: { id: "article-1", title: "From X", body: "Article body" },
|
|
};
|
|
},
|
|
async replyToMention(payload) {
|
|
replies.push(payload);
|
|
},
|
|
},
|
|
});
|
|
|
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u10", credits: 5, eventId: "evt10" }, "polar-secret");
|
|
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionTweetId: "mention-10",
|
|
callerUserId: "u10",
|
|
}, "x-secret");
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body);
|
|
assert.equal(body.status, "completed");
|
|
assert.equal(body.replied, true);
|
|
assert.equal(replies.length, 1);
|
|
assert.equal(replies[0].mentionTweetId, "mention-10");
|
|
assert.match(replies[0].text, /audiobook/i);
|
|
});
|
|
|
|
test("X webhook replies with not article message when parent is not article", async () => {
|
|
const replies = [];
|
|
const app = createApp({
|
|
xAdapter: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async listMentions() {
|
|
return [];
|
|
},
|
|
async fetchParentPostFromMention() {
|
|
return { id: "parent-2", text: "not article" };
|
|
},
|
|
async replyToMention(payload) {
|
|
replies.push(payload);
|
|
},
|
|
},
|
|
});
|
|
|
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u11", credits: 5, eventId: "evt11" }, "polar-secret");
|
|
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionTweetId: "mention-11",
|
|
callerUserId: "u11",
|
|
}, "x-secret");
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body);
|
|
assert.equal(body.status, "not_article");
|
|
assert.equal(body.replied, true);
|
|
assert.equal(replies.length, 1);
|
|
assert.match(replies[0].text, /not an X Article/);
|
|
});
|
|
|
|
test("X webhook can normalize tweet_create_events payload", async () => {
|
|
const app = createApp({
|
|
xAdapter: {
|
|
isConfigured() {
|
|
return true;
|
|
},
|
|
async listMentions() {
|
|
return [];
|
|
},
|
|
async fetchParentPostFromMention(mentionTweetId) {
|
|
assert.equal(mentionTweetId, "mention-evt-1");
|
|
return {
|
|
id: "parent-evt-1",
|
|
authorId: "author-evt-1",
|
|
article: { id: "article-evt-1", title: "Evt", body: "Body" },
|
|
};
|
|
},
|
|
async replyToMention() {},
|
|
},
|
|
});
|
|
|
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u-evt", credits: 4, eventId: "evt-seed" }, "polar-secret");
|
|
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
tweet_create_events: [
|
|
{
|
|
id_str: "mention-evt-1",
|
|
user: { id_str: "u-evt" },
|
|
},
|
|
],
|
|
}, "x-secret");
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(JSON.parse(response.body).status, "completed");
|
|
});
|
|
|
|
test("Polar webhook uses adapter parsing for standard webhook headers", async () => {
|
|
const app = createApp({
|
|
polarAdapter: {
|
|
isConfigured() {
|
|
return false;
|
|
},
|
|
async createCheckoutSession() {
|
|
return null;
|
|
},
|
|
parseWebhookEvent() {
|
|
return {
|
|
type: "order.paid",
|
|
data: {
|
|
id: "ord_1",
|
|
metadata: { xartaudio_user_id: "u9", xartaudio_credits: "7" },
|
|
},
|
|
};
|
|
},
|
|
extractTopUp(event) {
|
|
return {
|
|
userId: event.data.metadata.xartaudio_user_id,
|
|
credits: Number.parseInt(event.data.metadata.xartaudio_credits, 10),
|
|
eventId: event.data.id,
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
const response = await call(app, {
|
|
method: "POST",
|
|
path: "/api/webhooks/polar",
|
|
headers: {
|
|
"webhook-id": "wh_1",
|
|
"webhook-timestamp": "1",
|
|
"webhook-signature": "sig",
|
|
},
|
|
body: "{\"type\":\"order.paid\"}",
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
const wallet = await call(app, {
|
|
method: "GET",
|
|
path: "/api/me/wallet",
|
|
headers: { cookie: "xartaudio_user=u9" },
|
|
});
|
|
assert.equal(JSON.parse(wallet.body).balance, 7);
|
|
});
|
|
|
|
test("emits persistence snapshots on mutating actions", async () => {
|
|
const snapshots = [];
|
|
const app = createApp({
|
|
onMutation(state) {
|
|
snapshots.push(state);
|
|
},
|
|
});
|
|
|
|
await call(app, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=alice" },
|
|
body: "amount=5",
|
|
});
|
|
|
|
await 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("can boot app from previously persisted state snapshot", async () => {
|
|
const snapshots = [];
|
|
const app1 = createApp({
|
|
onMutation(state) {
|
|
snapshots.push(state);
|
|
},
|
|
});
|
|
|
|
await call(app1, {
|
|
method: "POST",
|
|
path: "/app/actions/topup",
|
|
headers: { cookie: "xartaudio_user=restart-user" },
|
|
body: "amount=6",
|
|
});
|
|
|
|
const persistedState = snapshots[snapshots.length - 1];
|
|
const app2 = createApp({
|
|
initialState: persistedState,
|
|
});
|
|
|
|
const wallet = await call(app2, {
|
|
method: "GET",
|
|
path: "/api/me/wallet",
|
|
headers: { cookie: "xartaudio_user=restart-user" },
|
|
});
|
|
|
|
assert.equal(wallet.status, 200);
|
|
assert.equal(JSON.parse(wallet.body).balance, 6);
|
|
});
|
|
|
|
test("rate limits repeated webhook calls", async () => {
|
|
const app = createApp({
|
|
config: {
|
|
rateLimits: {
|
|
webhookPerMinute: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const first = await 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 = await 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("anti abuse deny list blocks webhook generation", async () => {
|
|
const app = createApp({
|
|
config: {
|
|
abuse: {
|
|
denyUserIds: ["blocked-user"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const response = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionPostId: "m-deny",
|
|
callerUserId: "blocked-user",
|
|
parentPost: {
|
|
id: "p-deny",
|
|
article: { id: "a-deny", title: "T", body: "hello" },
|
|
},
|
|
}, "x-secret");
|
|
|
|
assert.equal(response.status, 429);
|
|
assert.equal(JSON.parse(response.body).error, "user_denied");
|
|
});
|
|
|
|
test("anti abuse daily limit blocks second generated job", async () => {
|
|
const app = createApp({
|
|
config: {
|
|
abuse: {
|
|
maxJobsPerUserPerDay: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u-limit", credits: 4, eventId: "evt-limit" }, "polar-secret");
|
|
const first = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionPostId: "m-limit-1",
|
|
callerUserId: "u-limit",
|
|
parentPost: {
|
|
id: "p-limit-1",
|
|
article: { id: "a-limit-1", title: "T1", body: "hello" },
|
|
},
|
|
}, "x-secret");
|
|
|
|
const second = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionPostId: "m-limit-2",
|
|
callerUserId: "u-limit",
|
|
parentPost: {
|
|
id: "p-limit-2",
|
|
article: { id: "a-limit-2", title: "T2", body: "hello" },
|
|
},
|
|
}, "x-secret");
|
|
|
|
assert.equal(first.status, 200);
|
|
assert.equal(second.status, 429);
|
|
assert.equal(JSON.parse(second.body).error, "daily_limit_exceeded");
|
|
});
|
|
|
|
test("anti abuse cooldown reports retry delay", async () => {
|
|
const app = createApp({
|
|
config: {
|
|
abuse: {
|
|
cooldownSec: 60,
|
|
},
|
|
},
|
|
});
|
|
|
|
await postJSONWebhook(app, "/api/webhooks/polar", { userId: "u-cool", credits: 4, eventId: "evt-cool" }, "polar-secret");
|
|
const first = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionPostId: "m-cool-1",
|
|
callerUserId: "u-cool",
|
|
parentPost: {
|
|
id: "p-cool-1",
|
|
article: { id: "a-cool-1", title: "T1", body: "hello" },
|
|
},
|
|
}, "x-secret");
|
|
|
|
const second = await postJSONWebhook(app, "/api/webhooks/x", {
|
|
mentionPostId: "m-cool-2",
|
|
callerUserId: "u-cool",
|
|
parentPost: {
|
|
id: "p-cool-2",
|
|
article: { id: "a-cool-2", title: "T2", body: "hello" },
|
|
},
|
|
}, "x-secret");
|
|
|
|
assert.equal(first.status, 200);
|
|
assert.equal(second.status, 429);
|
|
const body = JSON.parse(second.body);
|
|
assert.equal(body.error, "cooldown_active");
|
|
assert.equal(typeof body.retryAfterSec, "number");
|
|
});
|
|
|
|
test("rate limits repeated login attempts from same IP", async () => {
|
|
const app = createApp({
|
|
config: {
|
|
rateLimits: {
|
|
authPerMinute: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const first = await call(app, {
|
|
method: "POST",
|
|
path: "/auth/dev-login",
|
|
headers: { "x-forwarded-for": "5.5.5.5" },
|
|
body: "userId=alice&returnTo=%2Fapp",
|
|
});
|
|
const second = await 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/);
|
|
});
|