Compare commits

...

5 Commits

Author SHA1 Message Date
Tom Moor ec3a8ca4e0 Test draft documents 2025-03-22 09:49:07 -04:00
Tom Moor bc00fd6f45 Delete both subscriptions 2025-03-22 09:47:01 -04:00
Tom Moor 75c7819bc5 Separate redirect for pass through 2025-03-21 19:34:37 -04:00
Tom Moor 0410e099d8 tests 2025-03-21 19:31:32 -04:00
Tom Moor d85f4456d1 fix: Cannot unsubscribe from collection subscriptions via email token 2025-03-21 19:10:39 -04:00
7 changed files with 290 additions and 122 deletions
+8
View File
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
);
break;
}
case QueryNotices.UnsubscribeCollection: {
toast.success(
t("Unsubscribed from collection", {
type: "success",
})
);
break;
}
default:
}
}, [t, notice]);
@@ -0,0 +1,20 @@
import { v4 as uuidv4 } from "uuid";
import env from "@server/env";
import SubscriptionHelper from "./SubscriptionHelper";
describe("SubscriptionHelper", () => {
describe("unsubscribeUrl", () => {
it("should return a valid unsubscribe URL", () => {
const userId = uuidv4();
const documentId = uuidv4();
const unsubscribeUrl = SubscriptionHelper.unsubscribeUrl(
userId,
documentId
);
expect(unsubscribeUrl).toContain(`${env.URL}/api/subscriptions.delete`);
expect(unsubscribeUrl).toContain(`userId=${userId}`);
expect(unsubscribeUrl).toContain(`documentId=${documentId}`);
});
});
});
+14 -3
View File
@@ -1,4 +1,5 @@
import crypto from "crypto";
import queryString from "query-string";
import env from "@server/env";
/**
@@ -15,12 +16,22 @@ export default class SubscriptionHelper {
* @returns The unsubscribe URL
*/
public static unsubscribeUrl(userId: string, documentId: string) {
return `${env.URL}/api/subscriptions.delete?token=${this.unsubscribeToken(
const token = this.unsubscribeToken(userId, documentId);
return `${env.URL}/api/subscriptions.delete?${queryString.stringify({
token,
userId,
documentId
)}&userId=${userId}&documentId=${documentId}`;
documentId,
})}`;
}
/**
* Generate a token for unsubscribing a user from a document or collection.
*
* @param userId The user ID to unsubscribe
* @param documentId The document ID to unsubscribe from
* @returns The unsubscribe token
*/
public static unsubscribeToken(userId: string, documentId: string) {
const hash = crypto.createHash("sha256");
hash.update(`${userId}-${env.SECRET_KEY}-${documentId}`);
@@ -1,10 +1,13 @@
import { SubscriptionType } from "@shared/types";
import { Event } from "@server/models";
import queryString from "query-string";
import { QueryNotices, SubscriptionType } from "@shared/types";
import { Event, Subscription } from "@server/models";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import {
buildUser,
buildSubscription,
buildDocument,
buildCollection,
buildDraftDocument,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
@@ -704,126 +707,220 @@ describe("#subscriptions.list", () => {
});
describe("#subscriptions.delete", () => {
it("should delete user's subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
});
const res = await server.post("/api/subscriptions.delete", {
body: {
describe("GET", () => {
it("should delete user's document subscription", async () => {
const user = await buildUser();
const document = await buildDraftDocument({
userId: user.id,
id: subscription.id,
token: user.getJwtToken(),
},
teamId: user.teamId,
});
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
});
const res = await server.get(
`/api/subscriptions.delete?${queryString.stringify({
userId: user.id,
documentId: document.id,
follow: true,
token: SubscriptionHelper.unsubscribeToken(user.id, document.id),
})}`,
{
redirect: "manual",
}
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain(
`/home?notice=${QueryNotices.UnsubscribeDocument}`
);
expect(await Subscription.findByPk(subscription.id)).toBeNull();
});
const body = await res.json();
it("should delete user's collection subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const subscription = await buildSubscription({
userId: user.id,
collectionId: document.collectionId,
});
expect(res.status).toEqual(200);
expect(body.ok).toEqual(true);
expect(body.success).toEqual(true);
const res = await server.get(
`/api/subscriptions.delete?${queryString.stringify({
userId: user.id,
documentId: document.id,
follow: true,
token: SubscriptionHelper.unsubscribeToken(user.id, document.id),
})}`,
{
redirect: "manual",
}
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain(
`/home?notice=${QueryNotices.UnsubscribeCollection}`
);
expect(await Subscription.findByPk(subscription.id)).toBeNull();
});
it("should not fail if subscription is already deleted", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
});
await subscription.destroy();
const res = await server.get(
`/api/subscriptions.delete?${queryString.stringify({
userId: user.id,
documentId: document.id,
follow: true,
token: SubscriptionHelper.unsubscribeToken(user.id, document.id),
})}`,
{
redirect: "manual",
}
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain(`/home`);
expect(await Subscription.findByPk(subscription.id)).toBeNull();
});
});
it("should emit event", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
});
const res = await server.post("/api/subscriptions.delete", {
body: {
describe("POST", () => {
it("should delete user's document subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const subscription = await buildSubscription({
userId: user.id,
id: subscription.id,
token: user.getJwtToken(),
},
});
const events = await Event.findAll({
where: {
teamId: document.teamId,
},
});
expect(events.length).toEqual(1);
expect(events[0].name).toEqual("subscriptions.delete");
expect(events[0].modelId).toEqual(subscription.id);
expect(events[0].actorId).toEqual(user.id);
expect(events[0].documentId).toEqual(document.id);
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toEqual(true);
expect(body.success).toEqual(true);
});
it("users should not be able to delete other's subscriptions on document", async () => {
const subscriber0 = await buildUser();
// `subscriber1` belongs to `subscriber0`'s team.
const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
// `subscriber0` created a document.
const document = await buildDocument({
userId: subscriber0.id,
teamId: subscriber0.teamId,
});
// `subscriber0` wants to be notified about
// changes on this document.
await server.post("/api/subscriptions.create", {
body: {
token: subscriber0.getJwtToken(),
documentId: document.id,
event: "documents.update",
},
});
const res = await server.post("/api/subscriptions.delete", {
body: {
id: subscription.id,
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toEqual(true);
expect(body.success).toEqual(true);
const events = await Event.findAll({
where: {
teamId: document.teamId,
},
});
expect(events.length).toEqual(1);
expect(events[0].name).toEqual("subscriptions.delete");
expect(events[0].modelId).toEqual(subscription.id);
expect(events[0].actorId).toEqual(user.id);
expect(events[0].documentId).toEqual(document.id);
});
// `subscriber1` wants to be notified about
// changes on this document.
const resp = await server.post("/api/subscriptions.create", {
body: {
token: subscriber1.getJwtToken(),
documentId: document.id,
event: "documents.update",
},
it("should delete user's collection subscription", async () => {
const user = await buildUser();
const collection = await buildCollection({ userId: user.id });
const subscription = await buildSubscription({
userId: user.id,
collectionId: collection.id,
});
const res = await server.post("/api/subscriptions.delete", {
body: {
id: subscription.id,
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toEqual(true);
expect(body.success).toEqual(true);
const events = await Event.findAll({
where: {
teamId: user.teamId,
},
});
expect(events.length).toEqual(1);
expect(events[0].name).toEqual("subscriptions.delete");
expect(events[0].modelId).toEqual(subscription.id);
expect(events[0].actorId).toEqual(user.id);
expect(events[0].collectionId).toEqual(collection.id);
});
const subscription1 = await resp.json();
const subscription1Id = subscription1.data.id;
it("users should not be able to delete other's subscriptions on document", async () => {
const subscriber0 = await buildUser();
// `subscriber1` belongs to `subscriber0`'s team.
const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
// `subscriber0` wants to change `subscriber1`'s
// subscription for this document.
const res = await server.post("/api/subscriptions.delete", {
body: {
// `subscriber0`
// `subscriber0` created a document.
const document = await buildDocument({
userId: subscriber0.id,
// subscription id of `subscriber1`
id: subscription1Id,
token: subscriber0.getJwtToken(),
},
teamId: subscriber0.teamId,
});
// `subscriber0` wants to be notified about
// changes on this document.
await server.post("/api/subscriptions.create", {
body: {
token: subscriber0.getJwtToken(),
documentId: document.id,
event: "documents.update",
},
});
// `subscriber1` wants to be notified about
// changes on this document.
const resp = await server.post("/api/subscriptions.create", {
body: {
token: subscriber1.getJwtToken(),
documentId: document.id,
event: "documents.update",
},
});
const subscription1 = await resp.json();
const subscription1Id = subscription1.data.id;
// `subscriber0` wants to change `subscriber1`'s
// subscription for this document.
const res = await server.post("/api/subscriptions.delete", {
body: {
id: subscription1Id,
token: subscriber0.getJwtToken(),
},
});
const body = await res.json();
// `subscriber0` should be unauthorized.
expect(res.status).toEqual(403);
expect(body.ok).toEqual(false);
expect(body.error).toEqual("authorization_error");
expect(body.message).toEqual("Authorization error");
});
const body = await res.json();
// `subscriber0` should be unauthorized.
expect(res.status).toEqual(403);
expect(body.ok).toEqual(false);
expect(body.error).toEqual("authorization_error");
expect(body.message).toEqual("Authorization error");
});
});
@@ -168,14 +168,21 @@ router.get(
return;
}
const [subscription, user] = await Promise.all([
const [documentSubscription, document, user] = await Promise.all([
Subscription.findOne({
where: {
userId,
documentId,
},
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
transaction,
}),
Document.unscoped().findOne({
attributes: ["collectionId"],
where: {
id: documentId,
},
paranoid: false,
transaction,
}),
User.scope("withTeam").findByPk(userId, {
@@ -184,18 +191,41 @@ router.get(
}),
]);
authorize(user, "delete", subscription);
const context = createContext({
user,
ip: ctx.request.ip,
transaction,
});
await subscription.destroyWithCtx(
createContext({
user,
ip: ctx.request.ip,
transaction,
})
);
const collectionSubscription = document?.collectionId
? await Subscription.findOne({
where: {
userId,
collectionId: document.collectionId,
},
lock: Transaction.LOCK.UPDATE,
transaction,
})
: undefined;
if (collectionSubscription) {
authorize(user, "delete", collectionSubscription);
await collectionSubscription.destroyWithCtx(context);
}
if (documentSubscription) {
authorize(user, "delete", documentSubscription);
await documentSubscription.destroyWithCtx(context);
}
ctx.redirect(
`${user.team.url}/home?notice=${QueryNotices.UnsubscribeDocument}`
`${user.team.url}/home?notice=${
collectionSubscription
? QueryNotices.UnsubscribeCollection
: documentSubscription
? QueryNotices.UnsubscribeDocument
: ""
}`
);
}
);
@@ -507,6 +507,7 @@
"None": "None",
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
"Account": "Account",
"API Keys": "API Keys",
"Details": "Details",
+1
View File
@@ -415,6 +415,7 @@ export type UnfurlResponse = {
export enum QueryNotices {
UnsubscribeDocument = "unsubscribe-document",
UnsubscribeCollection = "unsubscribe-collection",
}
export type JSONValue =