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
6 changes: 6 additions & 0 deletions generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -13616,6 +13616,9 @@ enum e_notification_types_constraint {
}

enum e_notification_types_enum {
"""An admin is calling you"""
AdminCall

"""You received an award"""
AwardGranted

Expand Down Expand Up @@ -13673,6 +13676,9 @@ enum e_notification_types_enum {
"""A new message in a match's chat"""
MatchChatMessage

"""A match was found and is waiting for you to ready up"""
MatchFound

"""A Valve match you played was imported to 5stack"""
MatchImported

Expand Down
4 changes: 3 additions & 1 deletion generated/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5662,7 +5662,7 @@ export interface e_notification_types_aggregate_fields {
/** unique or primary key constraints on table "e_notification_types" */
export type e_notification_types_constraint = 'e_notification_types_pkey'

export type e_notification_types_enum = 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady'
export type e_notification_types_enum = 'AdminCall' | 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchFound' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady'


/** aggregate max on columns */
Expand Down Expand Up @@ -152183,6 +152183,7 @@ export const enumENotificationTypesConstraint = {
}

export const enumENotificationTypesEnum = {
AdminCall: 'AdminCall' as const,
AwardGranted: 'AwardGranted' as const,
ChatMessage: 'ChatMessage' as const,
ClipReady: 'ClipReady' as const,
Expand All @@ -152202,6 +152203,7 @@ export const enumENotificationTypesEnum = {
LeagueRosterUndersized: 'LeagueRosterUndersized' as const,
MatchAbandoned: 'MatchAbandoned' as const,
MatchChatMessage: 'MatchChatMessage' as const,
MatchFound: 'MatchFound' as const,
MatchImported: 'MatchImported' as const,
MatchStatsReady: 'MatchStatsReady' as const,
MatchStatusChange: 'MatchStatusChange' as const,
Expand Down
4 changes: 3 additions & 1 deletion hasura/enums/notification-types.sql
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ INSERT INTO e_notification_types ("value", "description") VALUES
('UtilityPracticeInvite', 'You were invited to a utility practice session'),
('UtilityPracticeReady', 'Your utility practice server is ready'),
('UtilityDriftScanFinished', 'A utility drift scan finished'),
('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party')
('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party'),
('MatchFound', 'A match was found and is waiting for you to ready up'),
('AdminCall', 'An admin is calling you')
ON CONFLICT("value") DO UPDATE
SET "description" = EXCLUDED."description";
200 changes: 200 additions & 0 deletions src/matches/camera/camera.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ describe("CameraService authorization", () => {
let mediaMtx: { proxySdp: jest.Mock; isPathReady: jest.Mock; kickSessions: jest.Mock };
let matchAssistant: { isOrganizer: jest.Mock };
let gameStreamer: { validateStatusOriginAuth: jest.Mock };
let notifications: {
notifyAdminCall: jest.Mock;
retractAdminCall: jest.Mock;
};
let redisKeys: Map<string, string>;
let redis: { set: jest.Mock; del: jest.Mock };
let service: CameraService;

const scopeRow = (myLineupId: string | null, allowTeammates: boolean) => [
Expand All @@ -35,6 +41,21 @@ describe("CameraService authorization", () => {
gameStreamer = {
validateStatusOriginAuth: jest.fn().mockResolvedValue(true),
};
notifications = {
notifyAdminCall: jest.fn().mockResolvedValue(1),
retractAdminCall: jest.fn().mockResolvedValue(undefined),
};
redisKeys = new Map();
redis = {
set: jest.fn(async (key: string, value: unknown, ...args: unknown[]) => {
if (args.includes("NX") && redisKeys.has(key)) {
return null;
}
redisKeys.set(key, String(value));
return "OK";
}),
del: jest.fn(async (key: string) => (redisKeys.delete(key) ? 1 : 0)),
};

service = new CameraService(
new Logger("CameraAuthTest"),
Expand All @@ -43,6 +64,8 @@ describe("CameraService authorization", () => {
mediaMtx as any,
matchAssistant as any,
gameStreamer as any,
notifications as any,
{ getConnection: () => redis } as any,
);
});

Expand Down Expand Up @@ -291,6 +314,183 @@ describe("CameraService authorization", () => {
);
});

describe("admin call ring", () => {
const settle = () => new Promise((resolve) => setImmediate(resolve));

// Answers each query by what it asks: the caller's scope, whether the
// target is someone playing this match, and a teammate check.
let caller: { lineupId: string | null; allowTeammates: boolean };
let targetStatus: string | null;

beforeEach(() => {
caller = { lineupId: null, allowTeammates: false };
targetStatus = "Live";

postgres.query.mockImplementation(async (sql: string) => {
if (sql.includes("allow_teammates")) {
return scopeRow(caller.lineupId, caller.allowTeammates);
}
if (sql.includes("SELECT m.status")) {
return targetStatus ? [{ status: targetStatus }] : [];
}
return [{ exists: true }];
});
});

const calls = async (by: User = admin, steamId = player.steam_id) => {
const answer = await service.proxyAdminTalk(
MATCH_ID,
steamId,
by,
"offer",
);
await settle();
return answer;
};

const playerAnswers = async () => {
await service.proxyPlayerTalk(MATCH_ID, player, "offer");
await settle();
};

const hangsUp = async () => {
await service.hangupAdminTalk(MATCH_ID, player.steam_id, admin);
await settle();
};

it("rings the player when an admin starts talking to them", async () => {
await calls();

expect(notifications.notifyAdminCall).toHaveBeenCalledWith(
MATCH_ID,
player.steam_id,
);
expect(redis.set).toHaveBeenCalledWith(
CameraService.adminCallRingKey(MATCH_ID, player.steam_id),
1,
"EX",
60,
"NX",
);
});

it("rings an organizer's call the same way", async () => {
matchAssistant.isOrganizer.mockResolvedValue(true);

await calls(organizer);

expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(1);
});

it("rings once, not once per publish", async () => {
await calls();
await calls();

expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(1);
});

it("keeps each player's ring separate", async () => {
await calls(admin, player.steam_id);
await calls(admin, TEAMMATE);

expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(2);
});

// An organizer's scope is "all" whoever they name, so the organizer of
// one match could otherwise push to any player on the platform.
it("never rings someone who is not playing the match", async () => {
matchAssistant.isOrganizer.mockResolvedValue(true);
targetStatus = null;

await expect(calls(organizer)).resolves.toBe("answer");

expect(notifications.notifyAdminCall).not.toHaveBeenCalled();
expect(redis.set).not.toHaveBeenCalled();
});

it("never rings about a match that is over", async () => {
targetStatus = "Finished";

await calls();

expect(notifications.notifyAdminCall).not.toHaveBeenCalled();
});

it("does not ring when a teammate is the one talking", async () => {
caller = { lineupId: MY_LINEUP, allowTeammates: true };

await calls(player, TEAMMATE);

expect(mediaMtx.proxySdp).toHaveBeenCalled();
expect(notifications.notifyAdminCall).not.toHaveBeenCalled();
});

it("does not ring for a publish MediaMTX refused", async () => {
mediaMtx.proxySdp.mockRejectedValueOnce(new Error("mediamtx is down"));

await expect(calls()).rejects.toThrow(/mediamtx/);

expect(notifications.notifyAdminCall).not.toHaveBeenCalled();
});

it("answers the publish without waiting on the push", async () => {
notifications.notifyAdminCall.mockReturnValue(new Promise(() => {}));

await expect(calls()).resolves.toBe("answer");
});

it("answers the publish when the push fails", async () => {
notifications.notifyAdminCall.mockRejectedValue(new Error("no hasura"));

await expect(calls()).resolves.toBe("answer");
});

it("retracts the ring on hangup", async () => {
await calls();
await hangsUp();

expect(notifications.retractAdminCall).toHaveBeenCalledWith(
MATCH_ID,
player.steam_id,
);
});

// Otherwise publish-then-hang-up in a loop rings as fast as it can be
// scripted.
it("does not let a hangup re-arm the ring", async () => {
await calls();
await hangsUp();
await calls();

expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(1);
});

it("rings again once the guard has expired", async () => {
await calls();
redisKeys.clear();
await calls();

expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(2);
});

it("retracts the ring when the player picks up", async () => {
await calls();
await playerAnswers();

expect(notifications.retractAdminCall).toHaveBeenCalledWith(
MATCH_ID,
player.steam_id,
);
});

it("still hangs up when the retract fails", async () => {
notifications.retractAdminCall.mockRejectedValue(new Error("no db"));

await expect(hangsUp()).resolves.toBeUndefined();
expect(mediaMtx.kickSessions).toHaveBeenCalled();
});
});

// The broadcast pod has no session; it authenticates as the match itself.
describe("broadcast overlay", () => {
const AUTH = "match-1:secret";
Expand Down
Loading
Loading