mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734715f461 | ||
|
|
b290c1d922 | ||
|
|
dd068e31d0 | ||
|
|
90cffbfcde | ||
|
|
25acf14c8b | ||
|
|
be781c5f1b | ||
|
|
4cb0730875 | ||
|
|
00b2fcdea3 | ||
|
|
f70347b579 | ||
|
|
3d9691b11a | ||
|
|
c2fe813929 | ||
|
|
ef7fc05dcc | ||
|
|
1c2d33f149 | ||
|
|
b1fdc8d665 | ||
|
|
1ecc23cf83 | ||
|
|
7230580b18 | ||
|
|
1460751b41 | ||
|
|
4f96d371f4 | ||
|
|
c5cf1dc390 | ||
|
|
503a450b40 | ||
|
|
f4dfbd735b | ||
|
|
81402918d2 | ||
|
|
32c950de5b | ||
|
|
792361507e | ||
|
|
4f267fb83e |
No files matched your search
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 60;
|
||||
const CURRENT_SCHEMA_VERSION = 61;
|
||||
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
|
||||
const sqliteConfig = (
|
||||
|
||||
@@ -94,6 +94,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
[60, ['0065_app-feedback.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065. Opt-in is the new `apps.feedback_enabled` column (developer-writable
|
||||
-- through the regular `puter.apps.update` path). Each row of `app_feedback`
|
||||
-- is one message a signed-in user submitted through the GUI feedback dialog;
|
||||
-- a copy is emailed to the app owner unless the per-app daily email cap
|
||||
-- suppressed it (`email_sent` records which). `app_uid` is denormalized
|
||||
-- alongside `app_id` so rows stay attributable after an app is deleted.
|
||||
-- `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- always runs first) and the table uses `CREATE TABLE IF NOT EXISTS`, so the
|
||||
-- directory can replay safely.
|
||||
|
||||
CALL _puter_add_col('apps', 'feedback_enabled', '`feedback_enabled` tinyint(1) DEFAULT ''0''');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`uid` CHAR(36) NOT NULL,
|
||||
`app_id` INT NOT NULL,
|
||||
`app_uid` CHAR(40) NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`source_env` VARCHAR(16) DEFAULT NULL,
|
||||
`source_origin` VARCHAR(2048) DEFAULT NULL,
|
||||
`email_sent` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_app_feedback_uid` (`uid`),
|
||||
KEY `idx_app_feedback_app_created` (`app_id`, `created_at`),
|
||||
KEY `idx_app_feedback_user_created` (`user_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065 / MySQL mysql_mig_20. Opt-in is the new `apps.feedback_enabled` column
|
||||
-- (developer-writable through the regular `puter.apps.update` path). Each row
|
||||
-- of `app_feedback` is one message a signed-in user submitted through the GUI
|
||||
-- feedback dialog; a copy is emailed to the app owner unless the per-app
|
||||
-- daily email cap suppressed it (`email_sent` records which). `app_uid` is
|
||||
-- denormalized alongside `app_id` so rows stay attributable after an app is
|
||||
-- deleted. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN IF NOT EXISTS feedback_enabled boolean NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_feedback (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uid CHAR(36) NOT NULL UNIQUE,
|
||||
app_id BIGINT NOT NULL,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source_env VARCHAR(16) DEFAULT NULL,
|
||||
source_origin VARCHAR(2048) DEFAULT NULL,
|
||||
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON app_feedback (app_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON app_feedback (user_id, created_at);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Opt-in is the new
|
||||
-- `apps.feedback_enabled` column (developer-writable through the regular
|
||||
-- `puter.apps.update` path; a dedicated column rather than a `metadata` key
|
||||
-- because Dev Center saves replace the whole metadata blob and would erase
|
||||
-- it). Each row of `app_feedback` is one message a signed-in user submitted
|
||||
-- through the GUI feedback dialog; a copy is emailed to the app owner unless
|
||||
-- the per-app daily email cap suppressed it (`email_sent` records which).
|
||||
-- `app_uid` is denormalized alongside `app_id` so rows stay attributable
|
||||
-- after an app is deleted (abuse forensics). `source_env` is 'app' (desktop
|
||||
-- dialog) or 'web' (puter.com popup opened from an external site);
|
||||
-- `source_origin` is the popup opener's browser-attested origin, null for
|
||||
-- desktop submissions. `created_at` is unix seconds. The (user_id,
|
||||
-- created_at) and (app_id, created_at) indexes serve the sliding-window
|
||||
-- rate-limit counts in AppFeedbackStore.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN "feedback_enabled" tinyint(1) DEFAULT '0';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"uid" TEXT NOT NULL UNIQUE,
|
||||
"app_id" INTEGER NOT NULL,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"source_env" TEXT DEFAULT NULL, -- 'app' | 'web'
|
||||
"source_origin" TEXT DEFAULT NULL, -- attested opener origin (web popups)
|
||||
"email_sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON `app_feedback` (`app_id`, `created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON `app_feedback` (`user_id`, `created_at`);
|
||||
@@ -179,6 +179,29 @@ describe('EmailClient — template rendering', () => {
|
||||
expect(captured.html).toContain('<strong>424242</strong>');
|
||||
});
|
||||
|
||||
it('does not HTML-escape values in the plain-text subject header', async () => {
|
||||
const client = startClient();
|
||||
const captured: { subject?: string } = {};
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
captured.subject = options.subject;
|
||||
return null;
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'app-user-feedback', {
|
||||
owner_username: 'dev',
|
||||
sender_username: 'user',
|
||||
sender_email: null,
|
||||
app_title: "Bob's App & Games",
|
||||
app_name: 'bobs-app',
|
||||
app_link: 'https://puter.example/app/bobs-app',
|
||||
message: 'hi',
|
||||
});
|
||||
|
||||
// A subject is not HTML — entities would render literally in the
|
||||
// recipient's mail client.
|
||||
expect(captured.subject).toBe("New user feedback for Bob's App & Games");
|
||||
});
|
||||
|
||||
it('escapes html and converts newlines in nl2br values', async () => {
|
||||
const client = startClient();
|
||||
let html = '';
|
||||
|
||||
@@ -152,11 +152,16 @@ export class EmailClient extends PuterClient {
|
||||
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/** Render a template and send it to `to`. */
|
||||
/**
|
||||
* Render a template and send it to `to`. `options.replyTo` sets the
|
||||
* Reply-To header (e.g. so a recipient can respond to the originator of the
|
||||
* message rather than the no-reply From address).
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
): Promise<void> {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
@@ -168,6 +173,7 @@ export class EmailClient extends PuterClient {
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
html: compiled.html(values),
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,7 +291,14 @@ export class EmailClient extends PuterClient {
|
||||
private compileTemplates(): void {
|
||||
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
|
||||
this.compiledTemplates[name as EmailTemplateName] = {
|
||||
subject: handlebars.compile(template.subject),
|
||||
// Subjects are plain-text headers: HTML-escaping would put
|
||||
// literal entities in front of the recipient (& etc.).
|
||||
// Header safety is handled elsewhere — the transport encodes
|
||||
// newlines, and free-form values (e.g. app_title) collapse
|
||||
// whitespace upstream.
|
||||
subject: handlebars.compile(template.subject, {
|
||||
noEscape: true,
|
||||
}),
|
||||
html: handlebars.compile(dedent(template.html)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,25 @@ Please update <a href="https://puter.com/app/{{app_name}}">{{app_title}}</a>.
|
||||
<blockquote>{{nl2br message}}</blockquote>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
'app-user-feedback': {
|
||||
subject: 'New user feedback for {{app_title}}',
|
||||
html: `
|
||||
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
|
||||
<p>
|
||||
<strong>{{sender_username}}</strong>{{#if sender_email}} (<a href="mailto:{{sender_email}}">{{sender_email}}</a>){{/if}} sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
|
||||
</p>
|
||||
<blockquote>{{{nl2br message}}}</blockquote>
|
||||
{{#if sender_email}}<p>Just reply to this email to respond to them directly.</p>{{/if}}
|
||||
<p>
|
||||
You're receiving this because feedback is enabled for
|
||||
<a href="{{app_link}}">{{app_title}}</a> — manage it in the
|
||||
<a href="{{dev_center_link}}">Dev Center</a> under your app's settings.
|
||||
</p>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -540,11 +540,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { AppFeedbackService } from '../../services/feedback/AppFeedbackService.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
// Boots one real PuterServer (in-memory sqlite + mocked externals) and
|
||||
// registers AppFeedbackController's decorated routes onto a fresh
|
||||
// PuterRouter. Tests drive the captured handlers with stub req/res; the
|
||||
// stores/services underneath are the live wired ones, so rows land in the
|
||||
// real `app_feedback` table.
|
||||
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
router = new PuterRouter();
|
||||
server.controllers.appFeedback.registerRoutes(router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const makeApp = async (
|
||||
ownerUserId: number,
|
||||
opts: { feedbackEnabled?: boolean; indexUrl?: string; name?: string } = {},
|
||||
) => {
|
||||
const name =
|
||||
opts.name ?? `fdbk-app-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: `Feedback Test ${name}`,
|
||||
index_url: opts.indexUrl ?? `https://${name}.example.com`,
|
||||
...(opts.feedbackEnabled ? { feedback_enabled: 1 } : {}),
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
};
|
||||
|
||||
// Feedback is only offered when the deployment can deliver it (email
|
||||
// transport configured); most tests want that baseline without asserting
|
||||
// anything about the mail itself.
|
||||
const mockEmailConfigured = () =>
|
||||
vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
const confirmOwnerEmail = async (userId: number) => {
|
||||
await server.clients.db.write(
|
||||
'UPDATE `user` SET `email_confirmed` = ? WHERE `id` = ?',
|
||||
[server.clients.db.booleanValue(true), userId],
|
||||
);
|
||||
const user = await server.stores.user.getById(userId);
|
||||
if (user) await server.stores.user.invalidate(user);
|
||||
};
|
||||
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
query?: Record<string, unknown>;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: init.query ?? {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: { statusCode: number; body: unknown } = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
};
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
const findRoute = (method: string, path: string) => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
||||
return route;
|
||||
};
|
||||
|
||||
const callRoute = async (
|
||||
method: string,
|
||||
path: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const handler: RequestHandler = findRoute(method, path).handler;
|
||||
await handler(req, res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
};
|
||||
|
||||
const submit = (actor: Actor, body: unknown) => {
|
||||
const { res, captured } = makeRes();
|
||||
return callRoute('post', '/', makeReq({ body, actor }), res).then(
|
||||
() => captured,
|
||||
);
|
||||
};
|
||||
|
||||
// ── Route gates ─────────────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController route options', () => {
|
||||
it('rejects app actors and cross-origin pages on submit', () => {
|
||||
const { options } = findRoute('post', '/');
|
||||
// requireUserActor is what makes feedback impossible to submit
|
||||
// programmatically with an app token; guiOriginOnly keeps
|
||||
// cross-origin browser pages out even with a leaked user token.
|
||||
expect(options.requireUserActor).toBe(true);
|
||||
expect(options.guiOriginOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('stacks a per-user budget with a per-IP backstop', () => {
|
||||
const { options } = findRoute('post', '/');
|
||||
const limits = options.rateLimit;
|
||||
expect(Array.isArray(limits)).toBe(true);
|
||||
const keys = (limits as Array<{ key?: unknown }>).map((l) => l.key);
|
||||
expect(keys).toContain('user');
|
||||
expect(keys).toContain('ip');
|
||||
});
|
||||
|
||||
it('requires a user actor on the target pre-flight too', () => {
|
||||
const { options } = findRoute('get', '/target');
|
||||
expect(options.requireUserActor).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /app-feedback/target ────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController GET /target', () => {
|
||||
it('throws 400 when neither or both of app/origin are given', async () => {
|
||||
const { actor } = await makeUser();
|
||||
for (const query of [
|
||||
{},
|
||||
{ app: 'x', origin: 'https://x.example.com' },
|
||||
]) {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute('get', '/target', makeReq({ query, actor }), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports enabled:false for an unknown app', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: 'no-such-app-xyz' }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ enabled: false, app: null });
|
||||
});
|
||||
|
||||
it('reports enabled:false for an app that has not opted in', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId);
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('reports enabled:true with canonical title/name for an opted-in app', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.uid }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an opted-in app whose name starts with "app-"', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const name = `app-fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true, name });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an origin to the app whose index_url it matches', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const origin = new URL(app.index_url).origin;
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { origin }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports enabled:false when the email transport is unconfigured', async () => {
|
||||
// No mockEmailConfigured(): this is the self-hosted no-SMTP default.
|
||||
// Feedback that can never be delivered must not be solicited.
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('reports enabled:false for an origin with no registered app', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({
|
||||
query: { origin: 'https://nobody-registered.example.com' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ enabled: false, app: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /app-feedback ──────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController POST /', () => {
|
||||
it('throws 400 when message is missing or not a string', async () => {
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
for (const message of [undefined, 12345, '']) {
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws 400 when both app and origin are given', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, {
|
||||
app: 'x',
|
||||
origin: 'https://x.example.com',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when the origin exceeds the stored column size', async () => {
|
||||
// source_origin is VARCHAR(2048) on MySQL/Postgres; a longer origin
|
||||
// must be rejected up front, not fail (or silently truncate) at the
|
||||
// INSERT after passing every other validation.
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const origin = `${new URL(app.index_url).origin}/${'x'.repeat(2500)}`;
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { origin, message: 'hi' }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 403 feedback_not_enabled when the app has not opted in', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId);
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message: 'hi there' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'feedback_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 403 for an unknown app and an unknown origin alike', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: 'no-such-app-xyz', message: 'hi' }),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(
|
||||
submit(actor, {
|
||||
origin: 'https://nobody-registered.example.com',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 feedback_not_enabled when the email transport is unconfigured', async () => {
|
||||
// No mockEmailConfigured(): the opted-in app must still refuse — a
|
||||
// stored row nothing can read, sold to the sender as delivered, is
|
||||
// worse than an honest refusal.
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message: 'into the void' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'feedback_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 400 when the message exceeds the length limit', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, {
|
||||
app: app.name,
|
||||
message: 'x'.repeat(
|
||||
AppFeedbackService.MESSAGE_MAX_LENGTH + 1,
|
||||
),
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('stores a normalized row and responds with an empty object', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor, userId } = await makeUser();
|
||||
const captured = await submit(actor, {
|
||||
app: app.uid,
|
||||
message: ' Great\r\napp! | ||||