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
22 changes: 17 additions & 5 deletions backend/src/graphql/resolvers/userResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,20 @@ export function createUserResolvers(db: Db) {
//Cover Image
updateCoverImage: async (
_parent: unknown,
args: { image: string },
args: { image: string | null },
context: { userId: string | null },
) => {
if (!context.userId || !ObjectId.isValid(context.userId)) {
if (!context.userId) {
throw new GraphQLError("You must be logged in");
}

if (!ObjectId.isValid(context.userId)) {
throw new GraphQLError("Invalid user ID");
}

// Only accept JPEG, PNG and WebP
if (
args.image !== null &&
!/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(
args.image,
)
Expand All @@ -50,7 +55,10 @@ export function createUserResolvers(db: Db) {
}

// Limit image size to approximately 2 MB
if (Buffer.byteLength(args.image, "utf8") > 2_800_000) {
if (
args.image !== null &&
Buffer.byteLength(args.image, "utf8") > 2_800_000
) {
throw new GraphQLError("Image is too large");
}

Expand All @@ -77,7 +85,7 @@ export function createUserResolvers(db: Db) {
//Profile image
updateProfileImage: async (
_parent: unknown,
args: { image: string },
args: { image: string | null },
context: { userId: string | null },
) => {
if (!context.userId) {
Expand All @@ -90,6 +98,7 @@ export function createUserResolvers(db: Db) {

// Only accept JPEG, PNG and WebP images encoded as Base64
if (
args.image !== null &&
!/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(
args.image,
)
Expand All @@ -98,7 +107,10 @@ export function createUserResolvers(db: Db) {
}

// Limit the image to approximately 2 MB
if (Buffer.byteLength(args.image, "utf8") > 2_800_000) {
if (
args.image !== null &&
Buffer.byteLength(args.image, "utf8") > 2_800_000
) {
throw new GraphQLError("Image is too large");
}

Expand Down
4 changes: 2 additions & 2 deletions backend/src/graphql/typeDefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const typeDefs = `#graphql
removeArtworkFromGallery(galleryId: ID!, artworkId: Int!): Gallery!
renameGallery(galleryId: ID!, title: String!): Gallery!
deleteGallery(galleryId: ID!): ID!
updateProfileImage(image: String!): User!
updateCoverImage(image: String!): User!
updateProfileImage(image: String): User!
updateCoverImage(image: String): User!
}
`;
110 changes: 110 additions & 0 deletions backend/tests/profile-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { ApolloServer } from "@apollo/server";
import { ObjectId, type Db } from "mongodb";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { typeDefs } from "../src/graphql/typeDefs.js";
import { createResolvers } from "../src/graphql/resolvers/index.js";

describe("Profile image removal API", () => {
const userId = new ObjectId();
let user: {
_id: ObjectId;
username: string;
profileImage: string | null;
coverImage: string | null;
};
let server: ApolloServer<{ userId: string | null }>;
const update = vi.fn();

beforeEach(async () => {
user = {
_id: userId,
username: "alice",
profileImage: "profile.png",
coverImage: "cover.png",
};
update.mockReset().mockImplementation(async (filter, change) => {
expect(filter).toEqual({ _id: userId });
Object.assign(user, change.$set);
return { ...user };
});
const db = {
collection: () => ({
findOne: async () => ({ ...user }),
findOneAndUpdate: update,
}),
} as unknown as Db;
server = new ApolloServer({ typeDefs, resolvers: createResolvers(db) });
await server.start();
});

afterEach(async () => {
await server.stop();
});

it.each(["Profile", "Cover"])(
"persists removal of the %s image independently",
async (type) => {
const field = type === "Profile" ? "profileImage" : "coverImage";
const response = await server.executeOperation(
{
query: `mutation Remove($image: String) { update${type}Image(image: $image) { profileImage coverImage } }`,
variables: { image: null },
},
{ contextValue: { userId: userId.toString() } },
);
if (response.body.kind !== "single")
throw new Error("Expected single response");
expect(response.body.singleResult.errors).toBeUndefined();
expect(update).toHaveBeenCalledExactlyOnceWith(
{ _id: userId },
{ $set: { [field]: null } },
{ returnDocument: "after" },
);

// Read again through the API to verify saved state, independently of mutation data.
const reloaded = await server.executeOperation(
{ query: "{ me { profileImage coverImage } }" },
{ contextValue: { userId: userId.toString() } },
);
if (reloaded.body.kind !== "single")
throw new Error("Expected single response");
expect(reloaded.body.singleResult.errors).toBeUndefined();
expect(reloaded.body.singleResult.data?.me).toEqual({
profileImage: type === "Profile" ? null : "profile.png",
coverImage: type === "Cover" ? null : "cover.png",
});
},
);

it.each(["Profile", "Cover"])(
"requires authentication to remove the %s image",
async (type) => {
const response = await server.executeOperation(
{ query: `mutation { update${type}Image(image: null) { id } }` },
{ contextValue: { userId: null } },
);
if (response.body.kind !== "single")
throw new Error("Expected single response");
expect(response.body.singleResult.errors?.[0].message).toBe(
"You must be logged in",
);
expect(update).not.toHaveBeenCalled();
},
);

it.each(["Profile", "Cover"])(
"still rejects invalid %s image uploads",
async (type) => {
const response = await server.executeOperation(
{ query: `mutation { update${type}Image(image: "invalid") { id } }` },
{ contextValue: { userId: userId.toString() } },
);
if (response.body.kind !== "single")
throw new Error("Expected single response");
expect(response.body.singleResult.errors?.[0].message).toBe(
"Invalid image format",
);
expect(update).not.toHaveBeenCalled();
},
);
});
Loading