Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/api/v1/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,35 @@ describe.sequential("/api/v1/notifications", () => {
});
});
});

describe("Notification types", () => {
it("can handle unknown notification types", async () => {
expect.assertions(2);
const accessToken = await getAccessToken(client, account, [
"read:notifications",
]);

await createNotification(
account.id as Uuid,
"follow",
remoteAccount.id,
new Date(),
);

const response = await app.request(
"/api/v1/notifications?types[]=SurelyInvalidType",
{
method: "GET",
headers: {
authorization: bearerAuthorization(accessToken),
},
},
);

expect(response.status).toBe(200);

const body = await response.json();
expect(body).toHaveLength(0);
});
});
});
31 changes: 16 additions & 15 deletions src/api/v1/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import {
tokenRequired,
type Variables,
} from "../../oauth/middleware";
import { notifications, polls, pollVotes, posts } from "../../schema";
import {
type NotificationType,
notifications,
notificationTypeEnum,
polls,
pollVotes,
posts,
} from "../../schema";
import type { Uuid } from "../../uuid";

const logger = getLogger(["hollo", "notifications"]);
Expand Down Expand Up @@ -59,20 +66,11 @@ function parseNotificationId(compositeId: string): ParsedNotificationId {

const app = new Hono<{ Variables: Variables }>();

export type NotificationType =
| "mention"
| "status"
| "reblog"
| "follow"
| "follow_request"
| "favourite"
| "emoji_reaction"
| "poll"
| "update"
| "admin.sign_up"
| "admin.report"
| "quote"
| "quoted_update";
// set for O(1) access to all possible types
const notificationTypeSet = new Set(notificationTypeEnum.enumValues);
function isNotificationType(value: string) {
return notificationTypeSet.has(value as NotificationType);
}

app.get(
"/",
Expand Down Expand Up @@ -121,6 +119,9 @@ app.get(
"quoted_update",
];
}
// types contains client-supplied values, which are not necessarily valid NotificationType. Filter everything we don't know and prevent problems later
// excludeTypes doesn't need filtering because we won't pass it along
types = types.filter(isNotificationType);
types = types.filter((t) => !excludeTypes?.includes(t));

const startTime = performance.now();
Expand Down
124 changes: 124 additions & 0 deletions src/api/v2/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it } from "vitest";

import { cleanDatabase } from "../../../tests/helpers";
import {
bearerAuthorization,
createAccount,
createOAuthApplication,
getAccessToken,
} from "../../../tests/helpers/oauth";

import db from "../../db";
import app from "../../index";
import * as Schema from "../../schema";
import type { Uuid } from "../../uuid";

// Helper to create a remote account for use as notification actor
async function createRemoteAccount(username: string): Promise<Schema.Account> {
const accountId = crypto.randomUUID() as Uuid;
const accountIri = `https://remote.test/@${username}`;

await db
.insert(Schema.instances)
.values({
host: "remote.test",
software: "mastodon",
softwareVersion: null,
})
.onConflictDoNothing();

const [account] = await db
.insert(Schema.accounts)
.values({
id: accountId,
iri: accountIri,
instanceHost: "remote.test",
type: "Person",
name: `Remote: ${username}`,
emojis: {},
handle: `@${username}@remote.test`,
bioHtml: "",
url: accountIri,
protected: false,
inboxUrl: `${accountIri}/inbox`,
followersUrl: `${accountIri}/followers`,
sharedInboxUrl: "https://remote.test/inbox",
featuredUrl: `${accountIri}/pinned`,
published: new Date(),
})
.returning();

return account;
}

// Helper to create a notification
async function createNotification(
accountOwnerId: Uuid,
type: Schema.NotificationType,
actorAccountId: Uuid,
createdAt?: Date,
): Promise<Schema.Notification> {
const id = crypto.randomUUID() as Uuid;
const created = createdAt ?? new Date();

const [notification] = await db
.insert(Schema.notifications)
.values({
id,
accountOwnerId,
type,
actorAccountId,
groupKey: `ungrouped-${id}`,
created,
})
.returning();

return notification;
}

describe.sequential("/api/v2/notifications", () => {
let client: Awaited<ReturnType<typeof createOAuthApplication>>;
let account: Awaited<ReturnType<typeof createAccount>>;
let remoteAccount: Schema.Account;

beforeEach(async () => {
await cleanDatabase();

account = await createAccount();
remoteAccount = await createRemoteAccount("remote_user");
client = await createOAuthApplication({
scopes: ["read:notifications"],
});
});

describe("Notification types", () => {
it("can handle unknown notification types", async () => {
expect.assertions(2);
const accessToken = await getAccessToken(client, account, [
"read:notifications",
]);

await createNotification(
account.id as Uuid,
"follow",
remoteAccount.id,
new Date(),
);

const response = await app.request(
"/api/v2/notifications?types[]=SurelyInvalidType",
{
method: "GET",
headers: {
authorization: bearerAuthorization(accessToken),
},
},
);

expect(response.status).toBe(200);

const body = await response.json();
expect(body.notification_groups).toHaveLength(0);
});
});
});
10 changes: 10 additions & 0 deletions src/api/v2/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type NotificationType,
notificationGroups,
notifications,
notificationTypeEnum,
posts,
} from "../../schema";
import type { Uuid } from "../../uuid";
Expand All @@ -31,6 +32,12 @@ function formatNotificationId(created: Date, type: string, id: string): string {
return `${created.toISOString()}/${type}/${id}`;
}

// set for O(1) access to all possible types
const notificationTypeSet = new Set(notificationTypeEnum.enumValues);
function isNotificationType(value: string) {
return notificationTypeSet.has(value as NotificationType);
}

// GET /api/v2/notifications - Get grouped notifications
app.get(
"/",
Expand Down Expand Up @@ -76,6 +83,9 @@ app.get(
"quoted_update",
];
}
// types contains client-supplied values, which are not necessarily valid NotificationType. Filter everything we don't know and prevent problems later
// excludeTypes doesn't need filtering because we won't pass it along
types = types.filter(isNotificationType);
types = types.filter((t) => !excludeTypes?.includes(t));

const startTime = performance.now();
Expand Down