Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
160 changes: 160 additions & 0 deletions server/routes/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import { getSettings } from '@server/lib/settings';
import { checkUser } from '@server/middleware/auth';
Expand Down Expand Up @@ -213,6 +214,165 @@ describe('PUT /request/:requestId (movie)', () => {
});
});

describe('PUT /request/:requestId (tv, season quota enforcement)', () => {
async function seedTvRequestAtQuota(tmdbId: number) {
const userRepo = getRepository(User);
const mediaRepo = getRepository(Media);
const requestRepo = getRepository(MediaRequest);

const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
});
friend.tvQuotaLimit = 2;
friend.tvQuotaDays = undefined;
await userRepo.save(friend);

const media = await mediaRepo.save(
new Media({
mediaType: MediaType.TV,
tmdbId,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
})
);

const created = await requestRepo.save(
new MediaRequest({
type: MediaType.TV,
status: MediaRequestStatus.PENDING,
media,
requestedBy: friend,
is4k: false,
seasons: [1, 2].map(
(seasonNumber) =>
new SeasonRequest({
seasonNumber,
status: MediaRequestStatus.PENDING,
})
),
})
);

return requestRepo.findOneOrFail({
where: { id: created.id },
relations: { requestedBy: true, seasons: true },
});
}

it('rejects adding a season beyond the requester quota', async () => {
const requestRepo = getRepository(MediaRequest);
const mediaRequest = await seedTvRequestAtQuota(90001);

const agent = await loginAs('friend@seerr.dev', 'test1234');
const res = await agent.put(`/request/${mediaRequest.id}`).send({
mediaType: MediaType.TV,
seasons: [1, 2, 3],
});

assert.strictEqual(res.status, 403);

const saved = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
relations: { seasons: true },
});
assert.deepStrictEqual(
saved.seasons.map((s) => s.seasonNumber).sort(),
[1, 2]
);
});

it('rejects an admin edit beyond quota without the explicit ignoreQuota flag', async () => {
const requestRepo = getRepository(MediaRequest);
const mediaRequest = await seedTvRequestAtQuota(90002);

const agent = await loginAs('admin@seerr.dev', 'test1234');
const res = await agent.put(`/request/${mediaRequest.id}`).send({
mediaType: MediaType.TV,
seasons: [1, 2, 3],
});

assert.strictEqual(res.status, 403);

const saved = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
relations: { seasons: true },
});
assert.deepStrictEqual(
saved.seasons.map((s) => s.seasonNumber).sort(),
[1, 2]
);
});

it('rejects a non-admin attempting to set ignoreQuota themselves', async () => {
const requestRepo = getRepository(MediaRequest);
const mediaRequest = await seedTvRequestAtQuota(90003);

const agent = await loginAs('friend@seerr.dev', 'test1234');
const res = await agent.put(`/request/${mediaRequest.id}`).send({
mediaType: MediaType.TV,
seasons: [1, 2, 3],
ignoreQuota: true,
});

assert.strictEqual(res.status, 403);

const saved = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
relations: { seasons: true },
});
assert.deepStrictEqual(
saved.seasons.map((s) => s.seasonNumber).sort(),
[1, 2]
);
});

it('allows an admin to bypass quota via the explicit ignoreQuota flag', async () => {
const requestRepo = getRepository(MediaRequest);
const mediaRequest = await seedTvRequestAtQuota(90004);

const agent = await loginAs('admin@seerr.dev', 'test1234');
const res = await agent.put(`/request/${mediaRequest.id}`).send({
mediaType: MediaType.TV,
seasons: [1, 2, 3],
ignoreQuota: true,
});

assert.strictEqual(res.status, 200);

const saved = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
relations: { seasons: true },
});
assert.deepStrictEqual(
saved.seasons.map((s) => s.seasonNumber).sort(),
[1, 2, 3]
);
assert.strictEqual(saved.ignoreQuota, true);
});

it('allows swapping seasons at full quota when the count stays the same', async () => {
const requestRepo = getRepository(MediaRequest);
const mediaRequest = await seedTvRequestAtQuota(90005);

const agent = await loginAs('friend@seerr.dev', 'test1234');
const res = await agent.put(`/request/${mediaRequest.id}`).send({
mediaType: MediaType.TV,
seasons: [3, 4],
});

assert.strictEqual(res.status, 200);

const saved = await requestRepo.findOneOrFail({
where: { id: mediaRequest.id },
relations: { seasons: true },
});
assert.deepStrictEqual(
saved.seasons.map((s) => s.seasonNumber).sort(),
[3, 4]
);
});
});

describe('POST /request/:requestId/:status', () => {
const cases = [
{ action: 'approve', expected: MediaRequestStatus.APPROVED },
Expand Down
41 changes: 41 additions & 0 deletions server/routes/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,40 @@ requestRoutes.put<{ requestId: string }>(
(sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
);

// Seasons dropped by this same edit are still counted in quota.tv.used
// (getQuota() reads the persisted rows before we save below), so they
// must be credited back before comparing against newSeasons.
const removedSeasonsCount = request.seasons.filter(
(rs) => !requestedSeasons.includes(rs.seasonNumber)
).length;

if (newSeasons.length > 0) {
const quota = await requestUser.getQuota();
const canBypassQuota = !!req.user?.hasPermission(
Permission.MANAGE_REQUESTS
);
const ignoreQuota =
req.body.ignoreQuota === true &&
canBypassQuota &&
(quota.tv.limit ?? 0) > 0;

if (!ignoreQuota) {
if (req.body.ignoreQuota && !canBypassQuota) {
throw new RequestPermissionError(
'You do not have permission to bypass user quota limits.'
);
} else if (
quota.tv.limit &&
newSeasons.length >
(quota.tv.remaining ?? 0) + removedSeasonsCount
) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
} else {
request.ignoreQuota = true;
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
request.seasons = request.seasons.filter((rs) =>
filteredSeasons.includes(rs.seasonNumber)
);
Expand All @@ -593,6 +627,13 @@ requestRoutes.put<{ requestId: string }>(

return res.status(200).json(request);
} catch (e) {
if (
e instanceof QuotaRestrictedError ||
e instanceof RequestPermissionError
) {
return next({ status: 403, message: e.message });
}

next({ status: 500, message: e.message });
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/components/RequestModal/CollectionRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,7 @@ const CollectionRequestModal = ({
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user &&
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
: null
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);

const currentlyRemaining =
Expand Down
5 changes: 1 addition & 4 deletions src/components/RequestModal/MovieRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,7 @@ const MovieRequestModal = ({
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user &&
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
: null
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);

useEffect(() => {
Expand Down
5 changes: 1 addition & 4 deletions src/components/RequestModal/TvRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,7 @@ const TvRequestModal = ({
});
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
const { data: quota } = useSWR<QuotaResponse>(
user &&
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
: null
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);

const currentlyRemaining =
Expand Down