diff --git a/src/backend/controllers/static/StaticPagesController.test.ts b/src/backend/controllers/static/StaticPagesController.test.ts new file mode 100644 index 000000000..6fbfb75c4 --- /dev/null +++ b/src/backend/controllers/static/StaticPagesController.test.ts @@ -0,0 +1,420 @@ +/** + * 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 . + */ + +import type { Request, RequestHandler, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock +// redis) and re-registers StaticPagesController's inline lambda +// routes onto a fresh PuterRouter. Tests run against the live wired +// stores (user, group), DB client, and EventClient — no method spies +// or stub services. Each test seeds the data it needs (user rows, +// listed apps) directly through the real stores. + +let server: PuterServer; +let router: PuterRouter; + +beforeAll(async () => { + server = await setupTestServer(); + router = new PuterRouter(); + server.controllers.staticPages.registerRoutes(router); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +interface CapturedResponse { + statusCode: number; + body: unknown; + contentType?: string; +} + +const makeReq = (init: { + query?: Record; + hostname?: string; + protocol?: string; +}): Request => { + return { + body: {}, + query: init.query ?? {}, + params: {}, + headers: {}, + hostname: init.hostname ?? 'test.local', + protocol: init.protocol ?? 'https', + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { statusCode: 200, body: undefined }; + const res = { + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + send: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + type: vi.fn((value: string) => { + captured.contentType = value; + return res; + }), + setHeader: vi.fn(() => res), + }; + return { res: res as unknown as Response, captured }; +}; + +const findHandler = (method: string, path: string): RequestHandler => { + const route = router.routes.find( + (r) => r.method === method && r.path === path, + ); + if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`); + return route.handler; +}; + +const callRoute = async ( + method: string, + path: string, + req: Request, + res: Response, +) => { + const handler = findHandler(method, path); + await handler(req, res, () => { + throw new Error('handler called next() unexpectedly'); + }); +}; + +interface TestUser { + id: number; + uuid: string; + username: string; + email: string; + email_confirm_token: string; +} + +const makeUser = async ( + overrides: { + email_confirmed?: 0 | 1; + unsubscribed?: 0 | 1; + } = {}, +): Promise => { + const username = `spc-${Math.random().toString(36).slice(2, 10)}`; + const uuid = uuidv4(); + const email = `${username}@test.local`; + const token = `tok-${Math.random().toString(36).slice(2, 10)}`; + const created = await server.stores.user.create({ + username, + uuid, + password: null, + email, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + // Layer the test-specific shape on top of the row (clean_email is + // populated by `create`; we just stamp the confirmation token here). + await server.stores.user.update(created.id, { + email_confirm_token: token, + email_confirmed: overrides.email_confirmed ?? 0, + unsubscribed: overrides.unsubscribed ?? 0, + }); + return { id: created.id, uuid, username, email, email_confirm_token: token }; +}; + +// ── /robots.txt ───────────────────────────────────────────────────── + +describe('StaticPagesController GET /robots.txt', () => { + it('disallows known SEO bots and points to the sitemap', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/robots.txt', + makeReq({ protocol: 'https' }), + res, + ); + expect(captured.contentType).toBe('text/plain'); + const body = String(captured.body); + expect(body).toContain('User-agent: AhrefsBot'); + expect(body).toContain('User-agent: SemrushBot'); + expect(body).toContain('Disallow: /'); + // Sitemap URL is built off the request protocol + configured domain + // (or req.hostname if domain is unset). + expect(body).toMatch(/Sitemap: https:\/\/[^/]+\/sitemap\.xml/); + }); +}); + +// ── /sitemap.xml ──────────────────────────────────────────────────── + +describe('StaticPagesController GET /sitemap.xml', () => { + it('lists docs + each approved-for-listing app', async () => { + const { id: userId } = await makeUser(); + // Seed an approved app via the real AppStore. `approved_for_listing` + // is in the store's READ_ONLY_COLUMNS (admin-controlled), so we + // flip it via a direct DB write after the row is created. + const name = `app-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.app.create( + { + name, + title: name, + description: '', + index_url: `https://example.com/${name}/`, + }, + { ownerUserId: userId }, + ); + await server.clients.db.write( + 'UPDATE `apps` SET `approved_for_listing` = 1 WHERE `name` = ?', + [name], + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/sitemap.xml', + makeReq({ protocol: 'https' }), + res, + ); + expect(captured.contentType).toBe('application/xml'); + const body = String(captured.body); + expect(body).toContain( + '', + ); + // Docs subdomain entry is always present. + expect(body).toMatch(/https:\/\/docs\.[^<]+<\/loc>/); + // The seeded approved app appears. + expect(body).toContain(`/app/${name}`); + }); + + it('omits non-approved apps', async () => { + const { id: userId } = await makeUser(); + const hidden = `hidden-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.app.create( + { + name: hidden, + title: hidden, + description: '', + index_url: `https://example.com/${hidden}/`, + approved_for_listing: 0, + }, + { ownerUserId: userId }, + ); + + const { res, captured } = makeRes(); + await callRoute('get', '/sitemap.xml', makeReq({}), res); + expect(String(captured.body)).not.toContain(`/app/${hidden}`); + }); +}); + +// ── /unsubscribe ──────────────────────────────────────────────────── + +describe('StaticPagesController GET /unsubscribe', () => { + it('renders an error when user_uuid is missing', async () => { + const { res, captured } = makeRes(); + await callRoute('get', '/unsubscribe', makeReq({}), res); + expect(String(captured.body)).toContain('user_uuid is required'); + }); + + it('renders an error when the user does not exist', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/unsubscribe', + makeReq({ + query: { user_uuid: '00000000-0000-0000-0000-000000000000' }, + }), + res, + ); + expect(String(captured.body)).toContain('User not found'); + }); + + it('flips unsubscribed=1 on the real user row', async () => { + const user = await makeUser({ unsubscribed: 0 }); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/unsubscribe', + makeReq({ query: { user_uuid: user.uuid } }), + res, + ); + expect(String(captured.body)).toContain( + 'You have successfully unsubscribed', + ); + const refreshed = await server.stores.user.getById(user.id); + // User store stores booleans as 1/0 in sqlite; both forms are accepted. + expect(Boolean(refreshed?.unsubscribed)).toBe(true); + }); + + it('reports already-unsubscribed without re-writing', async () => { + const user = await makeUser({ unsubscribed: 1 }); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/unsubscribe', + makeReq({ query: { user_uuid: user.uuid } }), + res, + ); + expect(String(captured.body)).toContain('already unsubscribed'); + }); +}); + +// ── /confirm-email-by-token ───────────────────────────────────────── + +describe('StaticPagesController GET /confirm-email-by-token', () => { + it('renders an error when user_uuid is missing', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ query: { token: 'whatever' } }), + res, + ); + expect(String(captured.body)).toContain('user_uuid is required'); + }); + + it('renders an error when token is missing', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ query: { user_uuid: 'u-uuid' } }), + res, + ); + expect(String(captured.body)).toContain('token is required'); + }); + + it('renders an error when the user does not exist', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ + query: { + user_uuid: '00000000-0000-0000-0000-000000000000', + token: 'x', + }, + }), + res, + ); + expect(String(captured.body)).toContain('user not found'); + }); + + it('rejects an invalid token without modifying the user', async () => { + const user = await makeUser({ email_confirmed: 0 }); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ + query: { user_uuid: user.uuid, token: 'wrong' }, + }), + res, + ); + expect(String(captured.body)).toContain('invalid token'); + const refreshed = await server.stores.user.getById(user.id); + expect(Boolean(refreshed?.email_confirmed)).toBe(false); + }); + + it('reports already-confirmed without re-writing', async () => { + const user = await makeUser({ email_confirmed: 1 }); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ + query: { + user_uuid: user.uuid, + token: user.email_confirm_token, + }, + }), + res, + ); + expect(String(captured.body)).toContain('Email already confirmed'); + }); + + it('confirms the email and clears the token on the real user row', async () => { + const user = await makeUser({ email_confirmed: 0 }); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ + query: { + user_uuid: user.uuid, + token: user.email_confirm_token, + }, + }), + res, + ); + expect(String(captured.body)).toContain('successfully confirmed'); + + const refreshed = await server.stores.user.getById(user.id); + expect(Boolean(refreshed?.email_confirmed)).toBe(true); + expect(refreshed?.email_confirm_token).toBeNull(); + // requires_email_confirmation is also cleared by the controller. + expect(Boolean(refreshed?.requires_email_confirmation)).toBe(false); + }); + + it('rejects when the email is already confirmed on a different account', async () => { + // Duplicate-email gate fires only when the existing row is + // (a) email_confirmed=1 and (b) has a non-null password — the + // controller's EXISTS check requires both. `makeUser` writes + // password=null so we have to pin one in directly. + const ownerUser = await makeUser({ email_confirmed: 1 }); + await server.clients.db.write( + 'UPDATE `user` SET `password` = ? WHERE `id` = ?', + ['hashed-pw', ownerUser.id], + ); + + // Second user with the same email; controller will refuse to + // confirm them because the original already owns the address. + const duplicateUuid = uuidv4(); + const duplicateUsername = `dup-${Math.random().toString(36).slice(2, 8)}`; + await server.clients.db.write( + 'INSERT INTO `user` (`uuid`, `username`, `email`, `clean_email`, `email_confirmed`, `password`, `email_confirm_token`) VALUES (?, ?, ?, ?, 0, NULL, ?)', + [ + duplicateUuid, + duplicateUsername, + ownerUser.email, + ownerUser.email.toLowerCase(), + 'tok-dup', + ], + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/confirm-email-by-token', + makeReq({ + query: { + user_uuid: duplicateUuid, + token: 'tok-dup', + }, + }), + res, + ); + expect(String(captured.body)).toContain( + 'confirmed on a different account', + ); + }); +});