0.2.45 - Advanced analytics, logout old versions

This commit is contained in:
ponzischeme89
2026-08-10 20:24:22 +12:00
parent 63f0768507
commit 56c1167382
32 changed files with 1125 additions and 452 deletions
+23 -1
View File
@@ -720,7 +720,29 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
users, err := s.store.AnalyticsUsers(r.Context(), since)
if err != nil {
s.loggerFor(r.Context()).Error("user analytics failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
payload := map[string]any{"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "rows": stats, "users": users}
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
if userID != "" {
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
if featureErr != nil || pathErr != nil || eventErr != nil {
s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID)
writeError(w, http.StatusInternalServerError, "could not read user journey")
return
}
payload["userId"] = userID
payload["features"] = features
payload["paths"] = paths
payload["events"] = events
}
writeJSON(w, http.StatusOK, payload)
}
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
@@ -8,8 +8,9 @@
<label class="field narrow"><span>Window</span>
<select id="engagement-days">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select></label>
</div>
<div class="table-wrap">
@@ -24,3 +25,53 @@
</table>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">User journeys</h2>
<p class="card-note">Choose an Emby profile to review feature use, common paths and
significant actions in time order. Search text, content titles and setting values
are not stored in journey analytics.</p>
</div>
<label class="field narrow"><span>User</span>
<select id="engagement-user"><option value="">Choose a user</option></select></label>
</div>
<div class="tiles" id="engagement-user-tiles"></div>
</section>
<section class="card" id="engagement-feature-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
<p class="card-note">Rare and unused features are shown explicitly against Memby's
major feature catalogue.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
<tbody id="engagement-features"></tbody>
</table></div>
</section>
<section class="card" id="engagement-path-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
<p class="card-note">Repeated transitions reveal routes into playback and places a
viewer commonly leaves a flow.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
<tbody id="engagement-paths"></tbody>
</table></div>
</section>
<section class="card" id="engagement-journey-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journey</h2>
<p class="card-note">Newest journeys first; actions within each journey run from start
to finish. A journey without an end event indicates an interruption or abandonment.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
<tbody id="engagement-events"></tbody>
</table></div>
</section>
+81 -3
View File
@@ -1,11 +1,81 @@
const { fmt, ui, $ } = Admin;
const featureCatalogue = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
];
const label = (value) => {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
};
function renderUser(payload) {
const selected = $('engagement-user').value;
const users = payload.users || [];
const current = users.find((user) => user.userId === selected);
$('engagement-user-tiles').innerHTML = current ? ui.tiles([
['events', fmt.number(current.events), { icon: 'pulse', tone: 'info' }],
['journeys', fmt.number(current.journeys), { icon: 'list', tone: 'data' }],
['last active', fmt.when(current.lastActiveAt), { icon: 'clock', tone: 'note', small: true }],
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
]) : '';
['feature', 'path', 'journey'].forEach((name) => {
$('engagement-' + name + '-card').hidden = !current;
});
if (!current) return;
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
const features = [...new Set([...featureCatalogue, ...used.keys()])];
$('engagement-features').innerHTML = features.map((name) => {
const stat = used.get(name);
const uses = stat?.uses || 0;
const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok');
return '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
}).join('');
const paths = payload.paths || [];
$('engagement-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
: ui.emptyRow(3, 'No repeated paths in this window.');
const grouped = new Map();
(payload.events || []).forEach((event) => {
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
grouped.get(event.journeyId).push(event);
});
let journeyNumber = grouped.size;
const rows = [];
grouped.forEach((events) => {
events.sort((a, b) => a.sequence - b.sequence);
const number = journeyNumber--;
events.forEach((event) => {
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
: label(event.target || event.screen);
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
});
});
$('engagement-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
}
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
const selected = $('engagement-user').value;
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value +
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
const rows = payload.rows || [];
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
'<tr><td>' + fmt.escape(row.rowId) + '</td>' +
'<td class="muted">' + fmt.escape(row.rowKind || '—') + '</td>' +
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
'<td class="muted">' + fmt.escape(label(row.rowKind)) + '</td>' +
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
'<td class="num">' + fmt.number(row.focuses) + '</td>' +
@@ -13,6 +83,14 @@ Admin.onRefresh(async () => {
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
: ui.emptyRow(8, 'No events in this window.');
const users = payload.users || [];
const existing = $('engagement-user').value;
$('engagement-user').innerHTML = '<option value="">Choose a user</option>' + users.map((user) =>
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').join('');
if (users.some((user) => user.userId === existing)) $('engagement-user').value = existing;
renderUser(payload);
});
Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh));
Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh));
+104 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -30,6 +31,108 @@ type analyticsRequest struct {
Events []rowEventPayload `json:"events"`
}
type journeyEventPayload struct {
UserID string `json:"userId"`
JourneyID string `json:"journeyId"`
Sequence int `json:"sequence"`
Category string `json:"category"`
Action string `json:"action"`
Screen string `json:"screen"`
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId"`
ItemType string `json:"itemType"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
}
type journeyAnalyticsRequest struct {
Events []journeyEventPayload `json:"events"`
}
var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
var journeyActions = allowedAnalyticsValues(
"journey_start", "journey_end", "screen_view", "open", "close", "select",
"submit", "request", "start", "stop", "complete", "abandon", "change",
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
"mark_unplayed", "retry", "dismiss", "switch",
)
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
func allowedAnalyticsValues(values ...string) map[string]bool {
out := make(map[string]bool, len(values))
for _, value := range values {
out[value] = true
}
return out
}
// handleJourneyAnalytics accepts privacy-bounded journey steps. The payload's user id is
// only a profile-switch guard: authority always comes from the bearer session.
func (s *Server) handleJourneyAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req journeyAnalyticsRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if len(req.Events) > maxAnalyticsBatch {
req.Events = req.Events[:maxAnalyticsBatch]
}
now := time.Now().UTC()
events := make([]store.JourneyEvent, 0, len(req.Events))
for _, payload := range req.Events {
if event, ok := toJourneyEvent(payload, sess.EmbyUserID, now); ok {
events = append(events, event)
}
}
if err := s.store.InsertJourneyEvents(r.Context(), events); err != nil {
s.loggerFor(r.Context()).Warn("journey analytics write failed", "error", err)
}
w.WriteHeader(http.StatusNoContent)
}
func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (store.JourneyEvent, bool) {
if payload.UserID != userID || !safeAnalyticsValue(payload.JourneyID, 80) ||
payload.Sequence < 0 || !journeyCategories[payload.Category] || !journeyActions[payload.Action] ||
!journeyOutcomes[payload.Outcome] {
return store.JourneyEvent{}, false
}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
for _, field := range fields {
if !safeAnalyticsValue(field, 100) {
return store.JourneyEvent{}, false
}
}
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
Outcome: payload.Outcome}, true
}
func safeAnalyticsValue(value string, max int) bool {
if len(value) > max {
return false
}
for _, char := range value {
if !(char == '-' || char == '_' || char == '.' || char == ':' ||
char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return false
}
}
return true
}
func analyticsOccurredAt(value string, now time.Time) time.Time {
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil &&
parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
return parsed.UTC()
}
return now
}
// handleRowAnalytics accepts a batch of row engagement events from a TV.
//
// Fire-and-forget by design: the client does not retry, and a rejected event is never
@@ -81,16 +184,7 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
return store.RowEvent{}, false
}
occurredAt := now
if payload.OccurredAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil {
// Trust the device's clock only within a sane window; TVs are notorious for
// waking up in 1970.
if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
occurredAt = parsed.UTC()
}
}
}
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
dwell := payload.DwellMs
if dwell < 0 {
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"testing"
"time"
)
func TestJourneyEventUsesAuthenticatedUser(t *testing.T) {
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
payload := journeyEventPayload{UserID: "user-1", JourneyID: "journey-1", Sequence: 3,
Category: "navigation", Action: "open", Screen: "home", Feature: "search", Target: "search"}
event, ok := toJourneyEvent(payload, "user-1", now)
if !ok {
t.Fatal("valid event was rejected")
}
if event.UserID != "user-1" || event.Sequence != 3 {
t.Fatalf("unexpected event: %+v", event)
}
if _, ok := toJourneyEvent(payload, "user-2", now); ok {
t.Fatal("an event buffered under another profile was accepted")
}
}
func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
now := time.Now().UTC()
base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", Action: "open"}
withTitle := base
withTitle.Feature = "A Film Title"
if _, ok := toJourneyEvent(withTitle, "u1", now); ok {
t.Fatal("free text feature was accepted")
}
unknown := base
unknown.Action = "typed_query"
if _, ok := toJourneyEvent(unknown, "u1", now); ok {
t.Fatal("unknown action was accepted")
}
}
+39 -3
View File
@@ -126,7 +126,7 @@ func (s *Server) Routes() http.Handler {
// once, without the gate ever touching health checks or the admin page.
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.HandleFunc("POST /v1/auth/login", s.requireSupportedClient(s.handleLogin))
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
@@ -196,6 +196,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
v1.Handle("POST /v1/analytics/events", s.authed(s.handleJourneyAnalytics))
v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage))
@@ -204,8 +205,8 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /readyz", s.handleReady)
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update without touching a viewer session.
mux.HandleFunc("GET /v1/update", s.handleUpdate)
// whether the server requires an update. A valid session enriches only its log context.
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
@@ -230,6 +231,21 @@ func (s *Server) Routes() http.Handler {
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
// identifyOptionalSession gives public routes the viewer and television attached to a
// valid bearer token without turning authentication into a condition of access. The
// update check must remain reachable before sign-in, but an offer made to a signed-in
// client should still say whose session is affected in the logs.
func (s *Server) identifyOptionalSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
if sess, err := s.sessionFor(r.Context(), token); err == nil {
identify(r.Context(), sess)
}
}
next.ServeHTTP(w, r)
})
}
// authed resolves the bearer token to a session before running h.
//
// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the
@@ -253,6 +269,26 @@ func (s *Server) authed(h authedFunc) http.Handler {
}
sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess)
decision := s.updateDecision(r)
if mustRetireForUpdate(decision, clientVersion(r)) {
// Mirror an ordinary sign-out closely enough that this token cannot be restored
// from either database or Redis. The 401 is intentional: every supported client
// treats it as authoritative and removes the rejected local profile.
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.loggerFor(r.Context()).Error("required-update session delete failed", "error", err)
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out for required update",
"device_id", sess.DeviceID,
"from", clientLogValue(clientVersion(r)),
"minimum", forcedUpdateFloor,
"to", decision.Version,
)
w.Header().Set("X-Memby-Update-Required", decision.Version)
writeError(w, http.StatusUnauthorized, "Memby must be updated before signing in again")
return
}
h(w, r, sess)
})
}
+50 -3
View File
@@ -17,6 +17,12 @@ import (
// speaks, next to the build's own version.
const ProtocolVersion = 1
// forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough
// to leave optional. The floor only takes effect once an enabled policy points at this
// version (or a newer one) and carries a download URL, so deploying the gateway before
// publishing the APK cannot lock televisions out.
const forcedUpdateFloor = "0.2.44"
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct {
@@ -90,13 +96,54 @@ func compatibilityFor(r *http.Request) (bool, string) {
return true, ""
}
// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a
// higher minimum the operator has already selected.
func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" ||
appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 {
return policy
}
if strings.TrimSpace(policy.MinimumVersion) == "" ||
appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 {
policy.MinimumVersion = forcedUpdateFloor
}
return policy
}
func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r))
}
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
// newer release without wanting every otherwise supported session destroyed. Only builds
// below the permanent compatibility floor are signed out.
func mustRetireForUpdate(decision appupdate.Decision, version string) bool {
return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
appupdate.CompareVersions(version, forcedUpdateFloor) < 0
}
// requireSupportedClient prevents a retired build from signing straight back in after
// the authenticated gate has removed its old session. Its public update check remains
// available and will keep returning the actionable mandatory verdict.
func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
decision := s.updateDecision(r)
if mustRetireForUpdate(decision, clientVersion(r)) {
w.Header().Set("X-Memby-Update-Required", decision.Version)
writeJSON(w, http.StatusUpgradeRequired, decision)
return
}
next(w, r)
}
}
// handleUpdate answers the client's version check.
//
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
// app build, not a viewer or login. The only client input is its build-version header and
// the answer comes from memory, so checking it never reads or mutates a user session.
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
// present the route resolves it only to attribute an offered update to the affected viewer.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
decision := s.updateDecision(r)
// Only a verdict that asks a television to do something is worth a line. Every TV
// checks on every launch, and "nothing to say" logged each time would bury the
// launch where an update was actually offered — or forced.
+105
View File
@@ -2,12 +2,15 @@ package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/config"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -38,3 +41,105 @@ func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) {
t.Fatalf("status = %q, want mandatory", decision.Status)
}
}
func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) {
logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole)
server := New(config.Config{}, Deps{Log: logger, Events: events})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk",
})
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
req.Header.Set("X-Memby-Version", "0.2.38")
req, _ = withRequestIdentity(req)
identify(req.Context(), store.Session{Username: "matt", DeviceName: "Living room"})
server.handleUpdate(httptest.NewRecorder(), req)
page := events.Events(0, 10)
if len(page.Events) != 1 {
t.Fatalf("events = %d, want 1", len(page.Events))
}
event := page.Events[0]
if event.Message != "update offered" || event.Attributes["user"] != "matt" {
t.Fatalf("update event was not attributed to the viewer: %+v", event)
}
}
func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
})
for version, want := range map[string]string{
"0.2.43": appupdate.StatusMandatory,
"0.2.44": appupdate.StatusNone,
"0.2.45": appupdate.StatusNone,
} {
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
req.Header.Set("X-Memby-Version", version)
rec := httptest.NewRecorder()
server.handleUpdate(rec, req)
var decision appupdate.Decision
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil {
t.Fatalf("%s: decode decision: %v", version, err)
}
if decision.Status != want {
t.Errorf("%s: status = %q, want %q", version, decision.Status, want)
}
}
}
func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) {
for name, policy := range map[string]appupdate.Policy{
"disabled": {
LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk",
},
"missing download": {
Enabled: true, LatestVersion: "0.2.44",
},
"release too old": {
Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk",
},
} {
t.Run(name, func(t *testing.T) {
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43")
if mustRetireForUpdate(decision, "0.2.43") {
t.Fatal("client would be retired without an actionable 0.2.44-or-newer release")
}
})
}
}
func TestRetiredClientCannotSignBackIn(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
})
reached := false
handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) {
reached = true
})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.Header.Set("X-Memby-Version", "0.2.43")
rec := httptest.NewRecorder()
handler(rec, req)
if reached {
t.Fatal("retired client reached the login handler")
}
if rec.Code != http.StatusUpgradeRequired {
t.Fatalf("status = %d, want 426", rec.Code)
}
if rec.Header().Get("X-Memby-Update-Required") != "0.2.44" {
t.Fatalf("required update header = %q", rec.Header().Get("X-Memby-Update-Required"))
}
}