Compare commits
2
Commits
b1b3e8b958
...
083d29323f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
083d29323f | ||
|
|
bd10c1c5d2 |
@@ -4,6 +4,12 @@ Electric Cherry requires a user session for operator pages and management APIs.
|
|||||||
HTTPS protects the connection; server-side role checks protect each operation.
|
HTTPS protects the connection; server-side role checks protect each operation.
|
||||||
There is no anonymous management mode or public registration.
|
There is no anonymous management mode or public registration.
|
||||||
|
|
||||||
|
Leaderboard API reads (`GET` and `HEAD /api/leaderboard`) are public and need no
|
||||||
|
session or credentials. This exception covers only that exact API route;
|
||||||
|
leaderboard/profile pages, match-history routes and management operations retain
|
||||||
|
their access checks. HTTPS requirements still apply. See the
|
||||||
|
[leaderboard API reference](leaderboard.md#read-api) for requests and response fields.
|
||||||
|
|
||||||
## First administrator and upgrades
|
## First administrator and upgrades
|
||||||
|
|
||||||
1. Generate a setup secret: `openssl rand -hex 32`.
|
1. Generate a setup secret: `openssl rand -hex 32`.
|
||||||
|
|||||||
+196
-23
@@ -148,33 +148,205 @@ would require additional reporter identity/ordering information.
|
|||||||
|
|
||||||
## Read API
|
## Read API
|
||||||
|
|
||||||
`GET /api/leaderboard` accepts optional `game` (`t5` or `t6`), `map` (the opaque
|
This reference describes the current repository implementation. A deployed
|
||||||
key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID search),
|
instance must run the corresponding code to provide the same behavior. The
|
||||||
`limit` (1–100, default 50), and `offset` (default 0). It returns ranked `entries`,
|
sources are the [HTTP handler](../src/routes/api/leaderboard/+server.ts),
|
||||||
available `maps`, the matching player `total`, pagination values, and `summary`.
|
[response types](../src/lib/leaderboard.ts),
|
||||||
Each entry represents one GUID and combines its qualifying matches within the
|
[query implementation](../src/lib/server/fleet/leaderboard.ts), and
|
||||||
game/map filters. Entries have no game field; map options retain their game
|
[authentication guard](../src/lib/server/auth/guard.ts).
|
||||||
identity. Both `total` and `summary.players` count unique GUIDs; `total` applies
|
|
||||||
the selected metric's qualification rules and player search, while the summary
|
### Authentication and requests
|
||||||
covers all completed qualifying participation in the game/map scope.
|
|
||||||
|
`GET /api/leaderboard` is a public JSON endpoint; `HEAD` is public too. A tool
|
||||||
|
does not need an account, session cookie, API key or authorization header to
|
||||||
|
read it. Reads also work before administrator setup and with an expired session
|
||||||
|
or a session awaiting a password change. This exception applies only to reads
|
||||||
|
of this exact API route. Leaderboard/profile HTML pages, match-history APIs and
|
||||||
|
management routes still require a session; see [Users and access](authentication.md).
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
EC_BASE_URL='https://ec.example.com'
|
||||||
|
curl --fail-with-body --silent --show-error \
|
||||||
|
--get "$EC_BASE_URL/api/leaderboard" \
|
||||||
|
--data-urlencode 'game=t6' \
|
||||||
|
--data-urlencode 'metric=kills' \
|
||||||
|
--data-urlencode 'limit=100' \
|
||||||
|
--data-urlencode 'offset=0'
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTPS is required. Local HTTP development requires `EC_AUTH_ALLOW_HTTP=true`
|
||||||
|
and a loopback hostname. The read request does not require an `Origin` header.
|
||||||
|
This endpoint does not add cross-origin browser access (CORS) headers; the
|
||||||
|
example above works for command-line or server-side clients. Successful responses use
|
||||||
|
`Content-Type: application/json`, `Cache-Control: no-store` and `Vary: Cookie`.
|
||||||
|
|
||||||
|
### Query parameters
|
||||||
|
|
||||||
|
All parameters are optional. Names and enumerated values are case-sensitive.
|
||||||
|
Use URL encoding for map keys and search text.
|
||||||
|
|
||||||
|
| Parameter | Default | Accepted values and behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. |
|
||||||
|
| `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. |
|
||||||
|
| `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. |
|
||||||
|
| `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. |
|
||||||
|
| `limit` | `50` | Integer from 1 to 100. |
|
||||||
|
| `offset` | `0` | Integer from 0 to 1,000,000; counts matching entries to skip, not ranks or pages. |
|
||||||
|
|
||||||
|
`limit` and `offset` accept decimal digits only. Empty strings, signs, spaces,
|
||||||
|
fractions and exponent notation are rejected rather than clamped. String
|
||||||
|
length limits use JavaScript string length (UTF-16 code units). Unrecognized
|
||||||
|
query parameters are ignored; if a parameter is repeated, its first value wins.
|
||||||
|
|
||||||
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
|
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
|
||||||
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`,
|
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`,
|
||||||
`killsPerDown`, `averageKills`, `averageRounds`, and `completionRate`.
|
`killsPerDown`, `averageKills`, `averageRounds`, and `completionRate`.
|
||||||
Efficiency values in entries are unrounded ratios (for example, `0.5` means 50%)
|
|
||||||
or `null` when their denominator is zero. UI percentages multiply by 100.
|
### Success response
|
||||||
Entries also include `records`, keyed by `highestRound`, `bestKills`, and
|
|
||||||
`bestScore`, with the completed match ID, value, map/game/server, timing and
|
HTTP `200` returns the following top-level fields directly, with no `ok` or
|
||||||
observed participation behind each personal best.
|
`data` wrapper:
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `entries` | Entry array | Requested page, ordered by the selected metric descending. |
|
||||||
|
| `maps` | Map array | Available completed-match map options, filtered only by `game`, independent of `map`, `q`, metric qualification and pagination. |
|
||||||
|
| `total` | number | Number of matching GUIDs after metric qualification and search, before pagination. |
|
||||||
|
| `limit`, `offset` | number | Accepted pagination values. |
|
||||||
|
| `metric` | string | Selected metric key, including the default when omitted. |
|
||||||
|
| `summary` | object | Totals for all qualifying completed participation in the game/map scope, independent of `q`, metric qualification and pagination. |
|
||||||
|
|
||||||
|
Each entry combines one GUID's qualifying participation across the selected
|
||||||
|
games/maps. It contains all of these fields regardless of the selected metric:
|
||||||
|
|
||||||
|
| Entry field | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `guid` | string | Player identity. Preserve it as a string, including leading zeros. |
|
||||||
|
| `name` | string | Latest reported name across both games, even if that report is outside the selected scope or from an unfinished match. |
|
||||||
|
| `rank` | number | Rank within the scope after metric qualification, before search/pagination. Ties share rank and leave gaps: 1, 1, 3. |
|
||||||
|
| `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, `downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore` | number | The ten counters defined under [Recorded statistics](#recorded-statistics). |
|
||||||
|
| `headshotRate`, `killsPerDown`, `averageKills`, `averageRounds`, `completionRate` | number or null | Ratios defined below; `null` if the denominator is zero. |
|
||||||
|
| `lastSeen` | string | Latest player observation among the qualifying participation in this scope, not the latest global identity update. |
|
||||||
|
| `records` | object | Personal-best evidence keyed by `highestRound`, `bestKills` and `bestScore`. |
|
||||||
|
|
||||||
|
Entries have no `game` field. Equal metric values are displayed in name order
|
||||||
|
(ASCII case-insensitive, then exact name), then GUID order. `q` is a substring
|
||||||
|
search, not an exact-GUID lookup; a tool looking up one GUID must check equality
|
||||||
|
on returned `guid` values and account for pagination.
|
||||||
|
|
||||||
|
| Ratio | Calculation | Requirement to rank by this metric |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `headshotRate` | `headshots / kills` | `gamesPlayed >= 5` and `kills >= 100` |
|
||||||
|
| `killsPerDown` | `kills / downs` | `gamesPlayed >= 5` and `downs >= 5` |
|
||||||
|
| `averageKills` | `kills / gamesPlayed` | `gamesPlayed >= 5` |
|
||||||
|
| `averageRounds` | `roundsSurvived / gamesPlayed` | `gamesPlayed >= 5` |
|
||||||
|
| `completionRate` | `gamesCompleted / gamesPlayed` | `gamesPlayed >= 5` |
|
||||||
|
|
||||||
|
These are unrounded ratios, not formatted strings. For `headshotRate` and
|
||||||
|
`completionRate`, `0.5` means 50%. Requirements use the selected game/map scope
|
||||||
|
and apply only to the metric being ranked. Entries on a counter leaderboard
|
||||||
|
still expose ratio values even when they would not qualify for a ratio ranking.
|
||||||
|
`gamesPlayed` counts ended matches with qualifying participation, including
|
||||||
|
matches the player left early; it does not require completion credit.
|
||||||
|
|
||||||
|
Each map option has string fields `key`, `game`, `map` and `name`. `map` is the
|
||||||
|
engine map identifier; `name` is the display name. Pass `key` as the request's
|
||||||
|
`map` parameter, preferably together with the option's `game`. Options come from
|
||||||
|
completed matches reaching round 2, so an option can exist without any qualifying
|
||||||
|
players. Removed servers' maps remain available.
|
||||||
|
|
||||||
|
| Summary field | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `players` | number | Unique qualifying GUIDs, combined across games. |
|
||||||
|
| `matches` | number | Distinct completed matches with qualifying player participation; not the sum of `gamesPlayed`. |
|
||||||
|
| `kills`, `roundsSurvived` | number | Sums across qualifying players; rounds are player-rounds. |
|
||||||
|
| `highestRound` | number | Highest qualifying player's recorded round. |
|
||||||
|
| `firstTrackedAt` | string or null | Earliest match `startedAt` among those matches. |
|
||||||
|
| `updatedAt` | string or null | Latest match report receipt time among those matches; not a response-generation time or a complete data-change token. |
|
||||||
|
|
||||||
|
For an empty scope, numeric summary fields are zero and both timestamps are
|
||||||
|
`null`. A search with no results or an offset beyond `total` returns
|
||||||
|
`entries: []` while preserving the scope's summary and available maps. An
|
||||||
|
efficiency board can likewise have no entries while `summary.players > 0`.
|
||||||
|
|
||||||
|
Each `records` value has the following fields. Evidence uses the same game/map
|
||||||
|
scope as the entry, and ties choose the earliest completion, then lowest match
|
||||||
|
ID. The three records can refer to different matches.
|
||||||
|
|
||||||
|
| Evidence field | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `matchId`, `value` | number | Completed match ID and that personal-best value. Link to `/matches/{matchId}` for its results page. |
|
||||||
|
| `game`, `mapKey`, `mapName`, `server` | string | Recorded game, opaque map key, map display name and server name. |
|
||||||
|
| `startedAt` | string | First recorded match observation; only the game start when `startObserved` is true. |
|
||||||
|
| `endedAt` | string or null | Recorded completion time; can be unknown in older stored records. |
|
||||||
|
| `startObserved` | boolean | Whether a `game_start` was received for the match. |
|
||||||
|
| `presentAtStart` | boolean or null | Whether the player was first observed at the recorded start; `null` for unknown older participation. |
|
||||||
|
| `firstObservedRound` | number or null | Round of the player's first observation, or unknown. This does not prove continuous survival. |
|
||||||
|
|
||||||
|
Timestamps reflect controller receipt/observation times, normally ISO 8601 UTC
|
||||||
|
strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times.
|
||||||
|
|
||||||
|
### Errors, pagination and freshness
|
||||||
|
|
||||||
|
Invalid query values return HTTP `400` with
|
||||||
|
`{ "ok": false, "kind": "bad-request", "error": "..." }`. Public reads do not
|
||||||
|
return `401` for missing/expired sessions or `403` for a required password change.
|
||||||
|
Sending host/enrollment bearer credentials still returns `403` because those
|
||||||
|
credentials are restricted to the host-agent API; omit them. Disallowed HTTP transport
|
||||||
|
returns `426` with an `error` string. Report-service failures can produce `5xx`;
|
||||||
|
check status and content type before assuming the success schema or a uniform
|
||||||
|
error body.
|
||||||
|
|
||||||
|
For successive pages, retain the same filters and increase `offset` by `limit`
|
||||||
|
until `offset + entries.length >= total` or the page is empty. There is no cursor,
|
||||||
|
snapshot token, date-range filter or incremental `since` parameter. Each report
|
||||||
|
reads a consistent database snapshot, but separate requests may see newly ended
|
||||||
|
matches or name changes. A multi-page crawl can therefore miss or repeat players
|
||||||
|
as their ordering changes; deduplicate by GUID and refresh as needed. The offset
|
||||||
|
cap also limits how far a tool can page through a very large selection.
|
||||||
|
|
||||||
|
Reports run in a worker and use caches keyed by database revision, invalidated
|
||||||
|
by writes to match, participation and player-identity tables. There is no fixed
|
||||||
|
TTL wait before newly committed leaderboard data becomes eligible for a fresh
|
||||||
|
report. Identical requests already in flight may share a response. Neither
|
||||||
|
`summary.updatedAt` nor an entry's `lastSeen` should be used as a cache revision:
|
||||||
|
an identity rename can change results without changing those timestamps.
|
||||||
|
|
||||||
|
### Related routes and current limits
|
||||||
|
|
||||||
|
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
||||||
|
summaries as `{ "matches": [...], "total": number }`. These routes still require
|
||||||
|
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
|
||||||
|
no offset pagination. Results are newest first by first observation, then match
|
||||||
|
ID, and include active/interrupted matches that reached round 2. Their
|
||||||
|
[ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries
|
||||||
|
and do not include player counters or a roster. Unknown server names return an
|
||||||
|
empty list. URL-encode server names in paths.
|
||||||
|
|
||||||
|
`/leaderboard/players/{guid}` and `/matches/{id}` are HTML pages backed by server
|
||||||
|
loaders. There is currently no dedicated JSON endpoint at
|
||||||
|
`/api/leaderboard/players/{guid}` or `/api/matches/{id}` for complete profiles,
|
||||||
|
player match history or match rosters. A tool needing those records will need
|
||||||
|
an API extension; the leaderboard endpoint supplies aggregate stats and
|
||||||
|
personal-best evidence only.
|
||||||
|
|
||||||
## Query performance
|
## Query performance
|
||||||
|
|
||||||
Leaderboard reads materialize the selected completed participation once, reuse
|
On a cache miss, leaderboard reads aggregate the selected completed participation
|
||||||
one player aggregate for ranking and summary totals, and reuse the ranked/search
|
for rankings and summary totals. The ranked scope and map options are reused
|
||||||
result for pagination and its matching count. Empty and out-of-range pages still
|
across search/pagination requests at the same database revision; filtering and
|
||||||
return accurate totals. Record evidence is fetched only for players on the page.
|
pagination operate on that ranked list. Empty and out-of-range pages still
|
||||||
Exact player-rank reads share the ranking rules but skip unused fleet summaries
|
return accurate totals. Record evidence is fetched only for requested entries.
|
||||||
and map lists. No response cache delays visibility of a newly completed game.
|
Exact player-rank reads use the same ranked scope, including its summaries and
|
||||||
|
map list. Report work runs in a dedicated worker with revision-keyed response
|
||||||
|
caching as described above.
|
||||||
|
|
||||||
|
The benchmark and measurements below describe the earlier SQL optimization,
|
||||||
|
before the worker and revision caches were added. They are historical results,
|
||||||
|
not measurements of the current HTTP implementation.
|
||||||
|
|
||||||
Run the deterministic, isolated benchmark with:
|
Run the deterministic, isolated benchmark with:
|
||||||
|
|
||||||
@@ -208,10 +380,11 @@ small-fixture parity cases and 22 timed small/large request cases matched the
|
|||||||
baseline's complete responses, including ratios and record evidence. Timings
|
baseline's complete responses, including ratios and record evidence. Timings
|
||||||
include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and
|
include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and
|
||||||
use a warm filesystem cache with alternating implementation order. These are
|
use a warm filesystem cache with alternating implementation order. These are
|
||||||
local synthetic measurements, not production latency guarantees. Queries still
|
local synthetic measurements, not production latency guarantees. SQL queries
|
||||||
run synchronously, so larger histories remain a capacity consideration.
|
run synchronously within the report worker; larger histories still affect
|
||||||
|
report latency and worker capacity.
|
||||||
|
|
||||||
Validation for this change passed 1,019 unit tests, Svelte checking with no errors
|
Validation for that optimization passed 1,019 unit tests, Svelte checking with no errors
|
||||||
or warnings, and a production build. Isolated Firefox walkthroughs at 1440px and
|
or warnings, and a production build. Isolated Firefox walkthroughs at 1440px and
|
||||||
390px checked completed-only totals, record links, efficiency qualification,
|
390px checked completed-only totals, record links, efficiency qualification,
|
||||||
scoped ranks/history, preserved navigation, empty scopes and mobile expansion.
|
scoped ranks/history, preserved navigation, empty scopes and mobile expansion.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
|
|
||||||
if (within(routeId, '/api/agent')) return 'machine';
|
if (within(routeId, '/api/agent')) return 'machine';
|
||||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||||
|
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||||
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
|
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
|
||||||
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
|
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const allows = (role: UserRole, route: string | null, method = 'GET') => roleAll
|
|||||||
describe('management route permissions', () => {
|
describe('management route permissions', () => {
|
||||||
it.each([
|
it.each([
|
||||||
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
|
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
|
||||||
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]', '/api/leaderboard',
|
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]',
|
||||||
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
||||||
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
||||||
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
||||||
@@ -72,6 +72,19 @@ describe('management route permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('authentication and machine boundaries', () => {
|
describe('authentication and machine boundaries', () => {
|
||||||
|
it('makes only leaderboard API reads public', () => {
|
||||||
|
for (const method of ['GET', 'HEAD']) {
|
||||||
|
expect(routePermission('/api/leaderboard', method)).toBe('public');
|
||||||
|
for (const route of ['/leaderboard', '/leaderboard/players/[guid]',
|
||||||
|
'/leaderboard/players/[game]/[guid]', '/api/leaderboard/players/[guid]',
|
||||||
|
'/api/leaderboard-extra', '/api/matches', '/api/servers/[name]/matches', '/matches/[id]']) {
|
||||||
|
expect(routePermission(route, method)).toBe('viewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
||||||
|
expect(routePermission('/api/leaderboard', method)).toBe('operator');
|
||||||
|
}
|
||||||
|
});
|
||||||
it('makes only the intended login/bootstrap methods public', () => {
|
it('makes only the intended login/bootstrap methods public', () => {
|
||||||
for (const route of ['/login', '/setup']) {
|
for (const route of ['/login', '/setup']) {
|
||||||
expect(routePermission(route, 'GET')).toBe('public');
|
expect(routePermission(route, 'GET')).toBe('public');
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session
|
|||||||
}
|
}
|
||||||
type Resolve = Parameters<Handle>[0]['resolve'];
|
type Resolve = Parameters<Handle>[0]['resolve'];
|
||||||
function requestFor(routeId: string | null, options: {
|
function requestFor(routeId: string | null, options: {
|
||||||
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit;
|
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit; cookie?: string | null;
|
||||||
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
|
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
|
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
|
||||||
@@ -32,7 +32,7 @@ function requestFor(routeId: string | null, options: {
|
|||||||
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
|
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
|
||||||
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||||
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
|
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
|
||||||
const cookies = { get: vi.fn(() => token) };
|
const cookies = { get: vi.fn(() => options.cookie === null ? undefined : options.cookie ?? token) };
|
||||||
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
|
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
|
||||||
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
|
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
|
||||||
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
|
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
|
||||||
@@ -47,6 +47,49 @@ beforeEach(() => {
|
|||||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
||||||
|
|
||||||
describe('browser authentication guard', () => {
|
describe('browser authentication guard', () => {
|
||||||
|
it.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
|
||||||
|
for (const setup of [false, true]) {
|
||||||
|
auth.setup = setup;
|
||||||
|
const call = requestFor('/api/leaderboard', { method, path: '/api/leaderboard?game=t6&metric=kills', origin: null, cookie: null });
|
||||||
|
const response = await call.invoke();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
expect(call.event.locals.user).toBeNull();
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect(response.headers.get('vary')).toContain('Cookie');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('allows leaderboard reads when a supplied session has expired', async () => {
|
||||||
|
const call = requestFor('/api/leaderboard', { cookie: token });
|
||||||
|
expect((await call.invoke()).status).toBe(200);
|
||||||
|
expect(auth.readSession).toHaveBeenCalledWith(token);
|
||||||
|
expect(call.event.locals.user).toBeNull();
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it('allows public leaderboard reads with a session that requires a password change', async () => {
|
||||||
|
signIn('viewer', true);
|
||||||
|
const call = requestFor('/api/leaderboard');
|
||||||
|
expect((await call.invoke()).status).toBe(200);
|
||||||
|
expect(call.resolve).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it('keeps anonymous leaderboard writes and related API reads protected', async () => {
|
||||||
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
||||||
|
const call = requestFor('/api/leaderboard', { method });
|
||||||
|
expect((await call.invoke()).status).toBe(401);
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
for (const route of ['/api/leaderboard/players/[guid]', '/api/leaderboard-extra',
|
||||||
|
'/api/matches', '/api/servers/[name]/matches', '/api/fleet']) {
|
||||||
|
const call = requestFor(route);
|
||||||
|
expect((await call.invoke()).status).toBe(401);
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
for (const route of ['/leaderboard', '/leaderboard/players/[guid]', '/matches/[id]']) {
|
||||||
|
const call = requestFor(route);
|
||||||
|
await expect(call.invoke()).rejects.toMatchObject({ status: 303 });
|
||||||
|
expect(call.resolve).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
|
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
|
||||||
const page = requestFor('/metrics', { path: '/metrics?days=7' });
|
const page = requestFor('/metrics', { path: '/metrics?days=7' });
|
||||||
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
|
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
|
||||||
|
|||||||
Reference in New Issue
Block a user