fix(issue-301): gate client actions from canonical phase flags
This commit is contained in:
@@ -50,11 +50,11 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
|
|||||||
host: {
|
host: {
|
||||||
can_start_round: status === 'lobby',
|
can_start_round: status === 'lobby',
|
||||||
can_show_question: status === 'lie',
|
can_show_question: status === 'lie',
|
||||||
can_mix_answers: status === 'lie',
|
can_mix_answers: status === 'lie' || status === 'guess',
|
||||||
can_calculate_scores: status === 'guess',
|
can_calculate_scores: status === 'guess',
|
||||||
can_reveal_scoreboard: status === 'reveal',
|
can_reveal_scoreboard: status === 'reveal',
|
||||||
can_start_next_round: status === 'scoreboard',
|
can_start_next_round: status === 'reveal',
|
||||||
can_finish_game: status === 'scoreboard',
|
can_finish_game: status === 'reveal',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
can_join: status === 'lobby',
|
can_join: status === 'lobby',
|
||||||
@@ -116,7 +116,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
expect(component.loading).toBe(false);
|
expect(component.loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
|
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
|
||||||
const fetchMock: FetchMock = vi
|
const fetchMock: FetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@@ -156,12 +156,16 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
component.sessionCode = ' abcd12 ';
|
component.sessionCode = ' abcd12 ';
|
||||||
component.roundQuestionId = ' 77 ';
|
component.roundQuestionId = ' 77 ';
|
||||||
|
|
||||||
|
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
|
||||||
await component.showQuestion();
|
await component.showQuestion();
|
||||||
|
|
||||||
|
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
|
||||||
await component.mixAnswers();
|
await component.mixAnswers();
|
||||||
await component.calculateScores();
|
await component.calculateScores();
|
||||||
|
|
||||||
expect(component.error).toBe('');
|
expect(component.error).toBe('');
|
||||||
expect(component.loading).toBe(false);
|
expect(component.loading).toBe(false);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs next-round transition without reload and clears scoreboard payload', async () => {
|
it('runs next-round transition without reload and clears scoreboard payload', async () => {
|
||||||
@@ -245,27 +249,44 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
expect(component.finishError).toContain('Session code is required');
|
expect(component.finishError).toContain('Session code is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks illegal host actions outside canonical reveal/scoreboard boundaries', async () => {
|
it('blocks illegal host actions outside canonical phase permissions', async () => {
|
||||||
const fetchMock: FetchMock = vi.fn();
|
const fetchMock: FetchMock = vi.fn();
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const component = new HostShellComponent();
|
const component = new HostShellComponent();
|
||||||
component.sessionCode = 'ABCD12';
|
component.sessionCode = 'ABCD12';
|
||||||
|
component.roundQuestionId = '77';
|
||||||
|
|
||||||
for (const status of ['lie', 'guess', 'reveal'] as const) {
|
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
|
||||||
component.session = sessionDetailPayload(status) as any;
|
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||||
|
await component.showQuestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
|
||||||
|
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||||
|
await component.calculateScores();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
|
||||||
|
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||||
|
await component.loadScoreboard();
|
||||||
await component.startNextRound();
|
await component.startNextRound();
|
||||||
await component.finishGame();
|
await component.finishGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
component.session = sessionDetailPayload('reveal') as any;
|
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
|
||||||
|
expect(component.canShowQuestion).toBe(false);
|
||||||
|
|
||||||
|
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
||||||
|
expect(component.canCalculateScores).toBe(false);
|
||||||
|
expect(component.canLoadScoreboard).toBe(true);
|
||||||
|
expect(component.canStartNextRound).toBe(true);
|
||||||
|
expect(component.canFinishGame).toBe(true);
|
||||||
|
|
||||||
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
expect(component.canLoadScoreboard).toBe(false);
|
||||||
expect(component.canStartNextRound).toBe(false);
|
expect(component.canStartNextRound).toBe(false);
|
||||||
expect(component.canFinishGame).toBe(false);
|
expect(component.canFinishGame).toBe(false);
|
||||||
|
|
||||||
component.session = sessionDetailPayload('scoreboard') as any;
|
|
||||||
await component.loadScoreboard();
|
|
||||||
|
|
||||||
expect(component.canLoadScoreboard).toBe(false);
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ type LeaderboardResponse = FinishGameResponse;
|
|||||||
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
|
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
|
||||||
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
|
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
|
||||||
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
|
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
|
||||||
<button (click)="showQuestion()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.show_question') }}</button>
|
<button (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
|
||||||
<button (click)="mixAnswers()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.mix_answers') }}</button>
|
<button (click)="mixAnswers()" [disabled]="loading || !canMixAnswers">{{ copy('host.mix_answers') }}</button>
|
||||||
<button (click)="calculateScores()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.calculate_scores') }}</button>
|
<button (click)="calculateScores()" [disabled]="loading || !canCalculateScores">{{ copy('host.calculate_scores') }}</button>
|
||||||
<button (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.load_scoreboard') }}</button>
|
<button (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.load_scoreboard') }}</button>
|
||||||
<button (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.start_next_round') }}</button>
|
<button (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.start_next_round') }}</button>
|
||||||
<button (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.finish_game') }}</button>
|
<button (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.finish_game') }}</button>
|
||||||
@@ -123,6 +123,22 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
return isHostGameplayActionAllowed(this.session as any, 'startRound');
|
return isHostGameplayActionAllowed(this.session as any, 'startRound');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canShowQuestion(): boolean {
|
||||||
|
return isHostGameplayActionAllowed(this.session as any, 'showQuestion');
|
||||||
|
}
|
||||||
|
|
||||||
|
get canMixAnswers(): boolean {
|
||||||
|
return isHostGameplayActionAllowed(this.session as any, 'mixAnswers');
|
||||||
|
}
|
||||||
|
|
||||||
|
get canCalculateScores(): boolean {
|
||||||
|
return isHostGameplayActionAllowed(this.session as any, 'calculateScores');
|
||||||
|
}
|
||||||
|
|
||||||
|
get canLoadScoreboard(): boolean {
|
||||||
|
return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard');
|
||||||
|
}
|
||||||
|
|
||||||
get canStartNextRound(): boolean {
|
get canStartNextRound(): boolean {
|
||||||
return isHostGameplayActionAllowed(this.session as any, 'startNextRound');
|
return isHostGameplayActionAllowed(this.session as any, 'startNextRound');
|
||||||
}
|
}
|
||||||
@@ -131,14 +147,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
|
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
|
||||||
}
|
}
|
||||||
|
|
||||||
get canUseLegacyMidRoundHostAction(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get canLoadScoreboard(): boolean {
|
|
||||||
return !this.session || this.gameplayPhase === 'reveal';
|
|
||||||
}
|
|
||||||
|
|
||||||
copy(key: string): string {
|
copy(key: string): string {
|
||||||
return t(key, this.locale);
|
return t(key, this.locale);
|
||||||
}
|
}
|
||||||
@@ -219,7 +227,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async showQuestion(): Promise<void> {
|
async showQuestion(): Promise<void> {
|
||||||
if (!this.canUseLegacyMidRoundHostAction) {
|
if (!this.canShowQuestion) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +239,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mixAnswers(): Promise<void> {
|
async mixAnswers(): Promise<void> {
|
||||||
if (!this.canUseLegacyMidRoundHostAction) {
|
if (!this.canMixAnswers) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +252,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async calculateScores(): Promise<void> {
|
async calculateScores(): Promise<void> {
|
||||||
if (!this.canUseLegacyMidRoundHostAction) {
|
if (!this.canCalculateScores) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,9 +109,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
component.sessionToken = 'token-1';
|
component.sessionToken = 'token-1';
|
||||||
component.lieText = 'my lie';
|
component.lieText = 'my lie';
|
||||||
component.session = {
|
component.session = {
|
||||||
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
|
||||||
round_question: { id: 11, prompt: 'Q?', answers: [] },
|
round_question: { id: 11, prompt: 'Q?', answers: [] },
|
||||||
players: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await component.submitLie();
|
await component.submitLie();
|
||||||
@@ -173,9 +172,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
component.sessionToken = 'token-1';
|
component.sessionToken = 'token-1';
|
||||||
component.selectedGuess = 'B';
|
component.selectedGuess = 'B';
|
||||||
component.session = {
|
component.session = {
|
||||||
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
|
...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
|
||||||
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
||||||
players: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await component.submitGuess();
|
await component.submitGuess();
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
|
import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
|
||||||
|
|
||||||
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
|
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
|
||||||
export type HostGameplayAction = 'startRound' | 'startNextRound' | 'finishGame';
|
export type HostGameplayAction =
|
||||||
|
| 'startRound'
|
||||||
|
| 'showQuestion'
|
||||||
|
| 'mixAnswers'
|
||||||
|
| 'calculateScores'
|
||||||
|
| 'loadScoreboard'
|
||||||
|
| 'startNextRound'
|
||||||
|
| 'finishGame';
|
||||||
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
|
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
|
||||||
|
|
||||||
export type GameplayPhaseEvent =
|
export type GameplayPhaseEvent =
|
||||||
@@ -77,15 +84,22 @@ export function isHostGameplayActionAllowed(session: SessionDetailResponse | nul
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = deriveGameplayPhase(session);
|
|
||||||
const host = session.phase_view_model?.host;
|
const host = session.phase_view_model?.host;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'startRound':
|
case 'startRound':
|
||||||
return phase === null || Boolean(host?.can_start_round ?? phase === null);
|
return Boolean(host?.can_start_round ?? false);
|
||||||
|
case 'showQuestion':
|
||||||
|
return Boolean(host?.can_show_question ?? false);
|
||||||
|
case 'mixAnswers':
|
||||||
|
return Boolean(host?.can_mix_answers ?? false);
|
||||||
|
case 'calculateScores':
|
||||||
|
return Boolean(host?.can_calculate_scores ?? false);
|
||||||
|
case 'loadScoreboard':
|
||||||
|
return Boolean(host?.can_reveal_scoreboard ?? false);
|
||||||
case 'startNextRound':
|
case 'startNextRound':
|
||||||
return phase === 'scoreboard' && Boolean(host?.can_start_next_round ?? true);
|
return Boolean(host?.can_start_next_round ?? false);
|
||||||
case 'finishGame':
|
case 'finishGame':
|
||||||
return phase === 'scoreboard' && Boolean(host?.can_finish_game ?? true);
|
return Boolean(host?.can_finish_game ?? false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +108,15 @@ export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | n
|
|||||||
return action === 'join';
|
return action === 'join';
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = deriveGameplayPhase(session);
|
|
||||||
const player = session.phase_view_model?.player;
|
const player = session.phase_view_model?.player;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'join':
|
case 'join':
|
||||||
return Boolean(player?.can_join ?? true);
|
return Boolean(player?.can_join ?? false);
|
||||||
case 'submitLie':
|
case 'submitLie':
|
||||||
return phase === 'lie' && Boolean(player?.can_submit_lie ?? true);
|
return Boolean(player?.can_submit_lie ?? false);
|
||||||
case 'submitGuess':
|
case 'submitGuess':
|
||||||
return phase === 'guess' && Boolean(player?.can_submit_guess ?? true);
|
return Boolean(player?.can_submit_guess ?? false);
|
||||||
case 'viewFinalResult':
|
case 'viewFinalResult':
|
||||||
return session.session.status === 'finished' && Boolean(player?.can_view_final_result ?? true);
|
return Boolean(player?.can_view_final_result ?? false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
allowedGameplayEvents,
|
allowedGameplayEvents,
|
||||||
deriveGameplayPhase,
|
deriveGameplayPhase,
|
||||||
|
isHostGameplayActionAllowed,
|
||||||
|
isPlayerGameplayActionAllowed,
|
||||||
transitionGameplayPhase,
|
transitionGameplayPhase,
|
||||||
type GameplayPhase
|
type GameplayPhase
|
||||||
} from '../src/spa/gameplay-phase-machine';
|
} from '../src/spa/gameplay-phase-machine';
|
||||||
@@ -103,4 +105,44 @@ describe('gameplay phase machine skeleton', () => {
|
|||||||
})
|
})
|
||||||
).toBe('scoreboard');
|
).toBe('scoreboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gates host and player actions from canonical phase_view_model permissions', () => {
|
||||||
|
const session = {
|
||||||
|
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 },
|
||||||
|
players: [],
|
||||||
|
round_question: { id: 77, prompt: 'Q?', answers: [] },
|
||||||
|
phase_view_model: {
|
||||||
|
status: 'reveal',
|
||||||
|
round_number: 1,
|
||||||
|
players_count: 3,
|
||||||
|
constraints: {
|
||||||
|
min_players_to_start: 3,
|
||||||
|
max_players_mvp: 5,
|
||||||
|
min_players_reached: true,
|
||||||
|
max_players_allowed: true
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
can_start_round: false,
|
||||||
|
can_show_question: false,
|
||||||
|
can_mix_answers: false,
|
||||||
|
can_calculate_scores: false,
|
||||||
|
can_reveal_scoreboard: true,
|
||||||
|
can_start_next_round: true,
|
||||||
|
can_finish_game: true
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
can_join: false,
|
||||||
|
can_submit_lie: false,
|
||||||
|
can_submit_guess: false,
|
||||||
|
can_view_final_result: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
expect(deriveGameplayPhase(session as any)).toBe('reveal');
|
||||||
|
expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true);
|
||||||
|
expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true);
|
||||||
|
expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true);
|
||||||
|
expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user