Files
outline/server/models/ShareSubscription.test.ts
Tom Moor 12c71f267e Improve scoping of public share subscriptions (#11932)
* Improve scoping of public share subscriptions

* fix: Add missing transaction, includeChildDocuments check, and test documentId

- Pass { transaction } to ShareSubscription.create in the subscribe handler
  so the insert runs atomically with the duplicate-check findOne/lock
- Skip ancestor-scoped subscription notifications when the share has
  includeChildDocuments=false, preventing notifications for inaccessible docs
- Add required documentId field to all share subscription tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Resolve type error for nullable share.documentId in tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* JSDoc

* Hide subscription option for logged-in users

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 08:08:05 -04:00

312 lines
10 KiB
TypeScript

import { randomString } from "@shared/random";
import { buildDocument, buildShare } from "@server/test/factories";
import ShareSubscription from "./ShareSubscription";
describe("ShareSubscription", () => {
describe("generateConfirmToken / generateUnsubscribeToken", () => {
it("should produce deterministic tokens", async () => {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const token1 = ShareSubscription.generateConfirmToken(subscription);
const token2 = ShareSubscription.generateConfirmToken(subscription);
expect(token1).toBe(token2);
});
it("should produce different tokens for confirm vs unsubscribe", async () => {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const confirmToken = ShareSubscription.generateConfirmToken(subscription);
const unsubscribeToken =
ShareSubscription.generateUnsubscribeToken(subscription);
expect(confirmToken).not.toBe(unsubscribeToken);
});
it("should produce different tokens for different secrets", async () => {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const sub1 = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const document2 = await buildDocument({ teamId: document.teamId });
const sub2 = await ShareSubscription.create({
shareId: share.id,
documentId: document2.id,
email: "test2@example.com",
emailFingerprint: "test2@example.com",
secret: randomString(32),
});
expect(ShareSubscription.generateConfirmToken(sub1)).not.toBe(
ShareSubscription.generateConfirmToken(sub2)
);
});
});
describe("normalizeEmailFingerprint", () => {
it("should return a hex hash string", () => {
const fp =
ShareSubscription.normalizeEmailFingerprint("user@example.com");
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
it("should treat different cases as equivalent", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("User@Example.COM")
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
});
it("should remove dots from Gmail local part", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("first.last@gmail.com")
).toBe(
ShareSubscription.normalizeEmailFingerprint("firstlast@gmail.com")
);
});
it("should preserve dots for non-Gmail domains", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("first.last@example.com")
).not.toBe(
ShareSubscription.normalizeEmailFingerprint("firstlast@example.com")
);
});
it("should strip +alias from local part", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("user+tag@example.com")
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
});
it("should handle dots and +alias together for Gmail", () => {
expect(
ShareSubscription.normalizeEmailFingerprint(
"first.last+newsletter@gmail.com"
)
).toBe(
ShareSubscription.normalizeEmailFingerprint("firstlast@gmail.com")
);
});
it("should not remove dots from domain", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("user@sub.example.com")
).not.toBe(
ShareSubscription.normalizeEmailFingerprint("user@subexample.com")
);
});
it("should trim whitespace", () => {
expect(
ShareSubscription.normalizeEmailFingerprint(" user@example.com ")
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
});
it("should treat gmail.com and googlemail.com as equivalent", () => {
const gmail = ShareSubscription.normalizeEmailFingerprint(
"first.last+tag@gmail.com"
);
const googlemail = ShareSubscription.normalizeEmailFingerprint(
"first.last+tag@googlemail.com"
);
expect(gmail).toBe(googlemail);
});
it("should not alter other domains ending in googlemail.com", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("user@notgooglemail.com")
).not.toBe(ShareSubscription.normalizeEmailFingerprint("user@gmail.com"));
});
it("should handle email without @ gracefully", () => {
const fp = ShareSubscription.normalizeEmailFingerprint("invalid");
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
it("should strip null bytes to prevent injection bypasses", () => {
const normal =
ShareSubscription.normalizeEmailFingerprint("user@example.com");
const withNull =
ShareSubscription.normalizeEmailFingerprint("user\0@example.com");
expect(withNull).toBe(normal);
});
});
describe("checkIPLimit", () => {
beforeEach(async () => {
await ShareSubscription.destroy({ where: {}, force: true });
});
it("should allow up to 3 unique emails from the same IP", async () => {
const ip = "192.168.1.1";
for (let i = 0; i < 3; i++) {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
ipAddress: ip,
});
}
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await expect(
ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "user3@example.com",
emailFingerprint: "user3@example.com",
secret: randomString(32),
ipAddress: ip,
})
).rejects.toThrow("limit");
});
it("should not count subscriptions from different IPs", async () => {
for (let i = 0; i < 3; i++) {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
ipAddress: `10.0.0.${i}`,
});
}
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await expect(
ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "user3@example.com",
emailFingerprint: "user3@example.com",
secret: randomString(32),
ipAddress: "10.0.0.3",
})
).resolves.toBeDefined();
});
it("should not count duplicate fingerprints from the same IP", async () => {
const document1 = await buildDocument();
const share1 = await buildShare({
documentId: document1.id,
teamId: document1.teamId,
});
const document2 = await buildDocument();
const share2 = await buildShare({
documentId: document2.id,
teamId: document2.teamId,
});
const ip = "192.168.2.1";
// Same fingerprint across different shares — should count as 1
for (let i = 0; i < 3; i++) {
const doc = await buildDocument();
const share = await buildShare({
documentId: doc.id,
teamId: doc.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: doc.id,
email: "same@example.com",
emailFingerprint: "same@example.com",
secret: randomString(32),
ipAddress: ip,
});
}
// 2 more unique fingerprints — total distinct = 3
for (let i = 0; i < 2; i++) {
const doc = await buildDocument();
await ShareSubscription.create({
shareId: share1.id,
documentId: doc.id,
email: `other${i}@example.com`,
emailFingerprint: `other${i}@example.com`,
secret: randomString(32),
ipAddress: ip,
});
}
// 4th unique fingerprint should be blocked
await expect(
ShareSubscription.create({
shareId: share2.id,
documentId: document2.id,
email: "blocked@example.com",
emailFingerprint: "blocked@example.com",
secret: randomString(32),
ipAddress: ip,
})
).rejects.toThrow("limit");
});
it("should skip the check if ipAddress is null", async () => {
for (let i = 0; i < 6; i++) {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
});
}
});
});
});