import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { Database } from "bun:sqlite";
import { sanitizeCommunityContent } from "~/server/lib/sanitize";
import {
requireClubMembership,
resolveClubIdFromPost,
type NessaConn
} from "./nessa-community-authz";
/**
* Tests for p8-012: sanitize community post/comment content on write.
*
* Content model: plain text — no HTML is stored. The iOS client renders
* with SwiftUI `Text()` (not a WebView), so there is no render-time XSS
* surface. Sanitization on write is defense-in-depth against a future HTML
* render path.
*
* The router's `social.createPost` and `social.addComment` run
* `sanitizeCommunityContent(input.content)` before the INSERT. These tests
* verify the sanitizer directly and then exercise the full write path
* against an in-memory SQLite DB wrapped to match the libsql contract.
*/
// ---------------------------------------------------------------------------
// Sanitizer unit tests
// ---------------------------------------------------------------------------
describe("sanitizeCommunityContent", () => {
it("strips hello")
).toBe("alert(1)hello");
});
it("strips safe"
)
).toBe("alert('xss') safe");
});
it("strips event handlers", () => {
expect(
sanitizeCommunityContent(
'check this out'
)
).toBe("check this out");
});
it("strips ", () => {
expect(
sanitizeCommunityContent('text ')
).toBe("text");
});
it("strips
end'
)
).toBe("alert(1)boldend");
});
it("handles unclosed tags", () => {
expect(sanitizeCommunityContent("text")).toBe("text");
expect(sanitizeCommunityContent("text
")).toBe("text");
});
it("strips , , ", () => {
expect(
sanitizeCommunityContent(
' text'
)
).toBe("text");
expect(
sanitizeCommunityContent('text')
).toBe("text");
expect(
sanitizeCommunityContent('text ')
).toBe("text");
});
it("strips data: URI tags — remaining text is harmless as plain text", () => {
// The tag and its attribute are stripped; the inner text remains.
// As plain text, the leftover characters are not executable.
const result = sanitizeCommunityContent(
' link '
);
expect(result).not.toContain("'
)
).toBe("alert(1)");
});
it("strips / / ", () => {
expect(
sanitizeCommunityContent(
'click '
)
).toBe("clickalert(1)");
expect(
sanitizeCommunityContent(
' text'
)
).toBe("alert(1)text");
});
});
// ---------------------------------------------------------------------------
// In-memory SQLite write-path tests (libsql-shaped)
// ---------------------------------------------------------------------------
let db: Database;
let conn: NessaConn;
function makeConn(): NessaConn {
return {
execute: async ({
sql,
args
}: {
sql: string;
args?: (string | number | null)[];
}) => {
const stmt = db.prepare(sql);
const upper = sql.trim().toUpperCase();
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
if (isRead) {
const rows = stmt.all(...(args ?? []));
return { rows: rows as unknown[] };
}
stmt.run(...(args ?? []));
return { rows: [] as unknown[] };
}
};
}
const USER_A = "user-a";
const CLUB_C = "club-c";
function initSchema() {
db = new Database(":memory:");
db.run("PRAGMA foreign_keys = ON");
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)");
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)");
db.run("CREATE TABLE clubPostComments (id TEXT PRIMARY KEY, postId TEXT, userId TEXT, content TEXT, createdAt TEXT, updatedAt TEXT)");
}
function seed() {
db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["mem-a", CLUB_C, USER_A, "owner"]
);
}
// ---------------------------------------------------------------------------
beforeAll(() => {
initSchema();
seed();
conn = makeConn();
});
beforeEach(() => {
db.run("DELETE FROM clubPostComments");
db.run("DELETE FROM clubPosts");
db.run("DELETE FROM clubMemberships");
db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["mem-a", CLUB_C, USER_A, "owner"]
);
});
describe("p8-012: createPost sanitizes content on write", () => {
it("stores without the script tag", async () => {
const postId = "post-1";
const raw = "hello";
const sanitized = sanitizeCommunityContent(raw);
await conn.execute({
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
args: [postId, CLUB_C, USER_A, sanitized, "text"]
});
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
expect(row.content).not.toContain(" without the script tag", async () => {
const postId = "post-1";
const commentId = "comment-1";
const raw = "hello";
const sanitized = sanitizeCommunityContent(raw);
// Seed the post first
db.run(
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, 'normal post', 'text', NULL, datetime('now'), datetime('now'))",
[postId, CLUB_C, USER_A]
);
await conn.execute({
sql: "INSERT INTO clubPostComments (id, postId, userId, content, createdAt, updatedAt) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))",
args: [commentId, postId, USER_A, sanitized]
});
const row = db.prepare("SELECT content FROM clubPostComments WHERE id = ?").get(commentId);
expect(row.content).not.toContain("