From 6cec7b2ce6f669ec9a1798afc94e04348c7d4cc0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 6 Oct 2026 12:04:28 +0000 Subject: [PATCH] feat: a Room can put the browser and platform a participant joined with in their groups spec.browserGroups is optional: a demo: prefix. Everyone who joins gets browser- and platform-, such as demo:browser-safari and demo:platform-ios, kept on the Participant beside the answer to the Room's question and sent to Dex the same way. The names come from fixed lists: safari, chrome, firefox, edge, samsung, opera, other; ios, android, macos, windows, chromeos, linux, other. The User-Agent only selects one, so a forged header gains nothing but another name from the list. The join page says what will be shared before anyone presses Continue. There is no phone brand: Chrome on Android reports every phone as model "K" since the User-Agent was frozen. A Room without browserGroups reads nothing from the User-Agent, and its page and groups are unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + api/v1alpha1/types.go | 27 ++++ api/v1alpha1/zz_generated.deepcopy.go | 20 +++ .../bases/room-pass.koudijs.dev_rooms.yaml | 19 +++ deploy/example/room.yaml | 5 + docs/install.md | 29 ++++ internal/server/metrics_test.go | 4 +- internal/server/server.go | 94 +++++++++++- internal/server/server_test.go | 142 ++++++++++++++++-- requirements.md | 3 +- test/browser/README.md | 3 +- test/browser/room-auth.spec.js | 41 +++++ test/integration/api_test.go | 12 +- 13 files changed, 384 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7690505..f3fe428 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ served from the application's own host With `spec.question` it asks one question at the door, and the `demo:` group of the answer joins `audienceGroup` in the participant's token ([install guide](docs/install.md#ask-one-question-at-the-door-optional)). +`spec.browserGroups` adds groups naming the browser and platform a participant +joined with, such as `demo:browser-safari` +([install guide](docs/install.md#put-the-browser-in-the-groups-optional)). Required cookie Secret: ```sh diff --git a/api/v1alpha1/types.go b/api/v1alpha1/types.go index c3d5ad9..7d7b92c 100644 --- a/api/v1alpha1/types.go +++ b/api/v1alpha1/types.go @@ -77,6 +77,14 @@ type RoomSpec struct { // event. // +optional Question *RoomQuestion `json:"question,omitempty"` + // BrowserGroups adds two groups to everyone who joins, named after the + // browser and the platform they joined with, such as demo:browser-safari + // and demo:platform-ios. Empty, nothing is added. + // + // Like an answer, they are kept on the Participant (spec.groups) at + // enrollment and are only what the browser says it is. + // +optional + BrowserGroups *BrowserGroups `json:"browserGroups,omitempty"` // EndsAt can be extended without changing participant identities. EndsAt metav1.Time `json:"endsAt"` // +kubebuilder:default=Closed @@ -185,6 +193,25 @@ type RoomAnswer struct { Group string `json:"group"` } +// BrowserGroups names the two groups that say how a participant joined: +// browser- and platform-. +// +// Both names come from fixed lists, whatever the browser sends. Browsers: +// safari, chrome, firefox, edge, samsung, opera, other. Platforms: ios, +// android, macos, windows, chromeos, linux, other. A forged User-Agent can +// only pick another name from them, and anyone can claim to be Safari, so +// bind these groups only to what anyone may have. +// +// There is no phone brand: Chrome on Android no longer sends one. ios is the +// closest there is to Apple. +type BrowserGroups struct { + // Prefix starts both names: demo: gives demo:browser-safari, and + // demo:my-talk: gives demo:my-talk:browser-safari. + // +kubebuilder:validation:MaxLength=100 + // +kubebuilder:validation:Pattern="^demo:([a-zA-Z0-9][a-zA-Z0-9:_-]*)?$" + Prefix string `json:"prefix"` +} + type Code struct { // +kubebuilder:validation:MaxLength=12 Code string `json:"code"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5bbc364..843a8c1 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -9,6 +9,21 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserGroups) DeepCopyInto(out *BrowserGroups) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserGroups. +func (in *BrowserGroups) DeepCopy() *BrowserGroups { + if in == nil { + return nil + } + out := new(BrowserGroups) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Code) DeepCopyInto(out *Code) { *out = *in @@ -257,6 +272,11 @@ func (in *RoomSpec) DeepCopyInto(out *RoomSpec) { *out = new(RoomQuestion) (*in).DeepCopyInto(*out) } + if in.BrowserGroups != nil { + in, out := &in.BrowserGroups, &out.BrowserGroups + *out = new(BrowserGroups) + **out = **in + } in.EndsAt.DeepCopyInto(&out.EndsAt) if in.AllowedReturnURLs != nil { in, out := &in.AllowedReturnURLs, &out.AllowedReturnURLs diff --git a/config/crd/bases/room-pass.koudijs.dev_rooms.yaml b/config/crd/bases/room-pass.koudijs.dev_rooms.yaml index 6bc6047..18a2b94 100644 --- a/config/crd/bases/room-pass.koudijs.dev_rooms.yaml +++ b/config/crd/bases/room-pass.koudijs.dev_rooms.yaml @@ -141,6 +141,25 @@ spec: x-kubernetes-validations: - message: audienceGroup is immutable rule: self == oldSelf + browserGroups: + description: |- + BrowserGroups adds two groups to everyone who joins, named after the + browser and the platform they joined with, such as demo:browser-safari + and demo:platform-ios. Empty, nothing is added. + + Like an answer, they are kept on the Participant (spec.groups) at + enrollment and are only what the browser says it is. + properties: + prefix: + description: |- + Prefix starts both names: demo: gives demo:browser-safari, and + demo:my-talk: gives demo:my-talk:browser-safari. + maxLength: 100 + pattern: ^demo:([a-zA-Z0-9][a-zA-Z0-9:_-]*)?$ + type: string + required: + - prefix + type: object endsAt: description: EndsAt can be extended without changing participant identities. format: date-time diff --git a/deploy/example/room.yaml b/deploy/example/room.yaml index 68f319b..5f96798 100644 --- a/deploy/example/room.yaml +++ b/deploy/example/room.yaml @@ -33,6 +33,11 @@ spec: # group: demo:framework-react # - label: Svelte # group: demo:framework-svelte + # Optional: groups naming the browser and platform each participant joined + # with, such as demo:browser-safari and demo:platform-ios. Only what the + # browser claims to be. + # browserGroups: + # prefix: "demo:" # Nobody can join after this. Move it before every event. endsAt: "2026-12-31T18:00:00Z" enrollment: Open diff --git a/docs/install.md b/docs/install.md index c2db8bc..38caac9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -234,6 +234,35 @@ Somebody who picks Svelte signs in with the groups `demo:my-talk` and - **Set it before you go on stage.** Editing it starts a fresh code epoch, and anyone who joined before the question existed is not asked it. +### Put the browser in the groups (optional) + +`spec.browserGroups` adds two groups to everyone who joins, named after the +browser and the platform they joined with. Nobody is asked anything; the join +page says what it will share ("and your browser, Safari on iOS"). + +```yaml +spec: + browserGroups: + prefix: "demo:" # demo:browser-safari, demo:platform-ios +``` + +| Group | Names | +|---|---| +| `browser-…` | `safari`, `chrome`, `firefox`, `edge`, `samsung`, `opera`, `other` | +| `platform-…` | `ios`, `android`, `macos`, `windows`, `chromeos`, `linux`, `other` | + +- **It is what the browser says it is.** Room Pass reads the User-Agent and + only ever picks a name from these lists, so a forged one gains nothing but + another name from them. Anyone can claim to be Safari, so bind these groups + only to what anyone may have. +- **There is no phone brand.** Chrome on Android stopped sending one when the + User-Agent was frozen; `platform-ios` is the closest there is to "Apple". +- **The prefix keeps them apart.** `demo:my-talk:` gives + `demo:my-talk:browser-safari`. It must start with `demo:`, like every group + here. +- **They are kept on the Participant** in `spec.groups` beside the answer, from + the browser that enrolled. Like the question, set it before anyone joins. + ## 6. Connect your application Your application is an OIDC client of the issuer: diff --git a/internal/server/metrics_test.go b/internal/server/metrics_test.go index 0670a4c..240ed48 100644 --- a/internal/server/metrics_test.go +++ b/internal/server/metrics_test.go @@ -19,10 +19,10 @@ func TestMetricsReflectOutcomesWithoutIdentityLabels(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := s.enrollParticipant(context.Background(), "WRONG", "private-name", ""); err == nil { + if _, err := s.enrollParticipant(context.Background(), "WRONG", "private-name", "", ""); err == nil { t.Fatal("invalid code accepted") } - if _, err := s.enrollParticipant(context.Background(), "BCDFGH", "private-name", ""); err != nil { + if _, err := s.enrollParticipant(context.Background(), "BCDFGH", "private-name", "", ""); err != nil { t.Fatal(err) } for _, path := range []string{"/healthz", "/unknown-private-name?code=SECRET", "/another-random-path"} { diff --git a/internal/server/server.go b/internal/server/server.go index 72ae5d1..845882e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -677,6 +677,85 @@ func answerLabel(room *api.Room, p *api.Participant) string { return "" } +// device is a browser and a platform, each a key of the lists below. The +// User-Agent is the participant's to write, so it only ever picks a name off +// these lists; no part of it ever becomes part of one. +type device struct{ Browser, Platform string } + +var ( + browserNames = map[string]string{"safari": "Safari", "chrome": "Chrome", "firefox": "Firefox", "edge": "Edge", "samsung": "Samsung Internet", "opera": "Opera", "other": "another browser"} + platformNames = map[string]string{"ios": "iOS", "android": "Android", "macos": "macOS", "windows": "Windows", "chromeos": "ChromeOS", "linux": "Linux", "other": "another system"} +) + +// deviceOf reads a User-Agent. Order matters: Chromium browsers also say +// Chrome, nearly everything says Safari, and an iPhone says it is "like Mac OS +// X" while Android says Linux. +// +// No phone brand: Chrome on Android reports every phone as model "K" since the +// User-Agent was frozen, and only Client Hints plus a table of model numbers +// could recover it. +func deviceOf(ua string) device { + has := func(xs ...string) bool { + for _, x := range xs { + if strings.Contains(ua, x) { + return true + } + } + return false + } + d := device{Browser: "other", Platform: "other"} + switch { + case has("SamsungBrowser/"): + d.Browser = "samsung" + case has("Edg/", "EdgA/", "EdgiOS/"): + d.Browser = "edge" + case has("OPR/", "OPT/", "OPiOS/"): + d.Browser = "opera" + case has("Firefox/", "FxiOS/"): + d.Browser = "firefox" + case has("Chrome/", "CriOS/"): + d.Browser = "chrome" + case has("Version/") && has("Safari/"): + d.Browser = "safari" + } + switch { + case has("iPhone", "iPad", "iPod"): + d.Platform = "ios" + case has("Android"): + d.Platform = "android" + case has("CrOS"): + d.Platform = "chromeos" + case has("Windows"): + d.Platform = "windows" + case has("Macintosh"): + d.Platform = "macos" + case has("Linux"): + d.Platform = "linux" + } + return d +} + +// String is how the join page tells a participant what they share. +func (d device) String() string { return browserNames[d.Browser] + " on " + platformNames[d.Platform] } + +// browserGroups is what enrollment stores for the browser a join came from, or +// nothing when the Room does not ask for it. A prefix that escaped the schema +// gives names that fail the group pattern, and those are not stored. +func browserGroups(room *api.Room, ua string) []string { + bg := room.Spec.BrowserGroups + if bg == nil { + return nil + } + d := deviceOf(ua) + var groups []string + for _, g := range []string{bg.Prefix + "browser-" + d.Browser, bg.Prefix + "platform-" + d.Platform} { + if validGroup(g) { + groups = append(groups, g) + } + } + return groups +} + // groupHeader is the X-Remote-Group value: the Room's audience group, then the // Participant's own. Dex's authproxy connector splits it on commas (its // groupHeaderSeparator default), and no group the pattern admits holds one, so @@ -740,7 +819,7 @@ var previewScriptSource = func() string { return "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" }() -var pageSource = `Join the room{{with .Look}}{{with .BackgroundImage}}{{end}}{{end}}
{{with .Look}}{{if .Picture}}{{.PictureAlt}}{{end}}{{end}}

{{.Title}}

{{with .Look}}{{with .Tagline}}

{{.}}

{{end}}{{end}}

{{.Message}}

{{if .Error}}{{end}}{{if .Form}}
{{if .Enrolled}}

You’re already enrolled as {{.EnrolledName}}{{with .EnrolledAnswer}}, and you picked {{.}}{{end}}. Continue with the same identity.

You are joining as {{.EnrolledEmail}}Room Pass built that address from your name, which is why there was nothing to fill in: it is never a real mailbox.{{with .AttributionNote}} {{.}}{{end}}

{{else}}{{if .Scanned}}

Room code {{.Scanned}}, from the code you scanned.

{{else}}{{end}}

You will appear as {{.Display}}You will join as {{.Email}}Room Pass builds both from your name, so there is nothing to fill in: spaces and accents are folded, and the address is never a real mailbox.{{with .AttributionNote}} {{.}}{{end}}

{{with .Question}}
{{.Prompt}}{{range $i, $a := .Answers}}{{end}}
{{end}}{{end}}
{{end}}{{if .Enrolled}}
{{end}}{{if .Question}}Your name and your answer are unverified labels, not a verified identity. Both are shown to the application you are joining, together with the address above.{{else}}Your name is an unverified label, not a verified identity. It is shown to the application you are joining, together with the address above.{{end}}
` +var pageSource = `Join the room{{with .Look}}{{with .BackgroundImage}}{{end}}{{end}}
{{with .Look}}{{if .Picture}}{{.PictureAlt}}{{end}}{{end}}

{{.Title}}

{{with .Look}}{{with .Tagline}}

{{.}}

{{end}}{{end}}

{{.Message}}

{{if .Error}}{{end}}{{if .Form}}
{{if .Enrolled}}

You’re already enrolled as {{.EnrolledName}}{{with .EnrolledAnswer}}, and you picked {{.}}{{end}}. Continue with the same identity.

You are joining as {{.EnrolledEmail}}Room Pass built that address from your name, which is why there was nothing to fill in: it is never a real mailbox.{{with .AttributionNote}} {{.}}{{end}}

{{else}}{{if .Scanned}}

Room code {{.Scanned}}, from the code you scanned.

{{else}}{{end}}

You will appear as {{.Display}}You will join as {{.Email}}Room Pass builds both from your name, so there is nothing to fill in: spaces and accents are folded, and the address is never a real mailbox.{{with .AttributionNote}} {{.}}{{end}}

{{with .Question}}
{{.Prompt}}{{range $i, $a := .Answers}}{{end}}
{{end}}{{end}}
{{end}}{{if .Enrolled}}
{{end}}{{if .Question}}Your name and your answer are unverified labels, not a verified identity. Both are{{else}}Your name is an unverified label, not a verified identity. It is{{end}} shown to the application you are joining, together with the address above{{with .Device}} and your browser, {{.}}{{end}}.
` func (s *Server) join(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" && r.Method != "POST" { @@ -822,6 +901,12 @@ func (s *Server) join(w http.ResponseWriter, r *http.Request) { // only ever selects one of the Room's own answers on the page; nothing // renders it. answer := r.FormValue("answer") + // What the page says this browser will be shared as, when the Room puts it + // in the groups. Read from the same header enrollment reads. + shared := "" + if room.Spec.BrowserGroups != nil { + shared = deviceOf(r.UserAgent()).String() + } // A code carried here by a scanned QR code. Only ever a prefill: the POST // below re-reads it from the form and Room Pass checks it against the // Room's rotating status exactly as it checks a typed one. @@ -896,7 +981,7 @@ func (s *Server) join(w http.ResponseWriter, r *http.Request) { // Email is what the script would compute for the name already in the // box, so a browser with script disabled and a form that came back with // a typed name both still show the address that is actually on offer. - _ = page.Execute(w, map[string]any{"Title": room.Spec.Title, "Look": lookOf(room), "AttributionNote": room.Spec.AttributionNote, "Message": heading, "Form": form, "CSRF": csrf, "Handoff": handoff, "Return": dest, "Enrolled": enrolled, "EnrolledName": enrolledName, "EnrolledEmail": enrolledEmail, "EnrolledAnswer": enrolledAnswer, "Scanned": pill, "Code": code, "Error": failure, "Name": name, "Display": displayPreview(name), "Email": emailPreview(name), "Question": questionOf(room), "Answer": answer, "Focus": focus, "CodeInvalid": field == "code", "NameInvalid": field == "name", "AnswerInvalid": field == "answer"}) + _ = page.Execute(w, map[string]any{"Title": room.Spec.Title, "Look": lookOf(room), "AttributionNote": room.Spec.AttributionNote, "Message": heading, "Form": form, "CSRF": csrf, "Handoff": handoff, "Return": dest, "Enrolled": enrolled, "EnrolledName": enrolledName, "EnrolledEmail": enrolledEmail, "EnrolledAnswer": enrolledAnswer, "Scanned": pill, "Code": code, "Error": failure, "Name": name, "Display": displayPreview(name), "Email": emailPreview(name), "Question": questionOf(room), "Answer": answer, "Device": shared, "Focus": focus, "CodeInvalid": field == "code", "NameInvalid": field == "name", "AnswerInvalid": field == "answer"}) } if r.Method == "GET" { render(200, notice, "", "", "") @@ -936,7 +1021,7 @@ func (s *Server) join(w http.ResponseWriter, r *http.Request) { render(400, e.Error(), "name", code, typed) return } - ss, e = s.enrollParticipant(r.Context(), code, name, answer) + ss, e = s.enrollParticipant(r.Context(), code, name, answer, r.UserAgent()) if e != nil { field := "" switch { @@ -1020,7 +1105,7 @@ func typedCode(raw string) string { }, strings.ToValidUTF8(v, "")) } -func (s *Server) enrollParticipant(ctx context.Context, code, name, answer string) (session, error) { +func (s *Server) enrollParticipant(ctx context.Context, code, name, answer, userAgent string) (session, error) { result := "storage_error" defer func() { if s.metrics != nil { @@ -1045,6 +1130,7 @@ func (s *Server) enrollParticipant(ctx context.Context, code, name, answer strin result = "answer_rejected" return session{}, e } + groups = append(groups, browserGroups(room, userAgent)...) ps := &api.ParticipantList{} if e = s.db.List(ctx, ps, client.InNamespace(room.Namespace)); e != nil { return session{}, errors.New("Room temporarily unavailable") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 11a7d97..57b6fb1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -59,7 +59,7 @@ func TestEnrollmentConcurrencyAndLifecycle(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() - if _, e := s.enrollParticipant(context.Background(), "bcd-fgh", fmt.Sprintf("Ada %d", i), ""); e == nil { + if _, e := s.enrollParticipant(context.Background(), "bcd-fgh", fmt.Sprintf("Ada %d", i), "", ""); e == nil { ok.Add(1) } }(i) @@ -68,7 +68,7 @@ func TestEnrollmentConcurrencyAndLifecycle(t *testing.T) { if ok.Load() != 100 { t.Fatalf("shared-room burst: %d", ok.Load()) } - if _, e := s.enrollParticipant(context.Background(), "BCDFGH", "overflow", ""); e == nil { + if _, e := s.enrollParticipant(context.Background(), "BCDFGH", "overflow", "", ""); e == nil { t.Fatal("cap bypassed") } ps := &api.ParticipantList{} @@ -110,6 +110,7 @@ func TestEnrollmentConcurrencyAndLifecycle(t *testing.T) { type browser struct { cookies map[string]map[string]*http.Cookie + ua string } func (b *browser) request(s *Server, method, raw string, form url.Values) *httptest.ResponseRecorder { @@ -125,6 +126,9 @@ func (b *browser) request(s *Server, method, raw string, form url.Values) *httpt } r.Header.Set("X-Remote-User", "forged") r.Header.Set("X-Remote-Group", "system:masters") + if b.ua != "" { + r.Header.Set("User-Agent", b.ua) + } for _, c := range b.cookies[u.Host] { r.AddCookie(c) } @@ -155,7 +159,7 @@ func hidden(body, name string) string { // looking at the presenter's screen again. func TestARejectedJoinKeepsWhatWasTyped(t *testing.T) { s, _ := fixture(t, "http://dex.test") - if _, e := s.enrollParticipant(context.Background(), "BCDFGH", "Ada", ""); e != nil { + if _, e := s.enrollParticipant(context.Background(), "BCDFGH", "Ada", "", ""); e != nil { t.Fatal(e) } submit := func(b *browser, form url.Values) *httptest.ResponseRecorder { @@ -349,7 +353,7 @@ func TestStolenCrossHostLinkCannotEnroll(t *testing.T) { func TestCurrentRoomStateAndCookieTampering(t *testing.T) { s, db := fixture(t, "http://dex.test") ctx := context.Background() - ss, e := s.enrollParticipant(ctx, "BCDFGH", "Ada", "") + ss, e := s.enrollParticipant(ctx, "BCDFGH", "Ada", "", "") if e != nil { t.Fatal(e) } @@ -488,7 +492,7 @@ func TestIdentityRequiresCurrentEnrollment(t *testing.T) { t.Run(tc.name, func(t *testing.T) { s, db := fixture(t, "http://dex.test") ctx := context.Background() - ss, err := s.enrollParticipant(ctx, "BCDFGH", "Ada", "") + ss, err := s.enrollParticipant(ctx, "BCDFGH", "Ada", "", "") if err != nil { t.Fatal(err) } @@ -674,7 +678,7 @@ func TestScannedCodeIsAPrefillAndNothingMore(t *testing.T) { // That has to be worth nothing: the POST re-checks the value against // the Room's valid codes, so a made-up one fails exactly as a typed // made-up one fails. - if _, err := s.enrollParticipant(context.Background(), "ZZZZZZ", "Mallory", ""); err == nil { + if _, err := s.enrollParticipant(context.Background(), "ZZZZZZ", "Mallory", "", ""); err == nil { t.Fatal("a code that the Room never issued was accepted") } }) @@ -871,14 +875,14 @@ func TestNamesWithoutUsableCharactersAreRefused(t *testing.T) { // quietly seated at the first person's Participant with their own cookie. func TestASecondClaimOnANameIsRefused(t *testing.T) { s, db := fixture(t, "http://dex.test") - first, e := s.enrollParticipant(context.Background(), "BCDFGH", "Ada Demo", "") + first, e := s.enrollParticipant(context.Background(), "BCDFGH", "Ada Demo", "", "") if e != nil { t.Fatal(e) } if first.Name != "p-ada-demo" { t.Fatalf("participant object name: %q", first.Name) } - if _, e = s.enrollParticipant(context.Background(), "BCDFGH", "ada demo", ""); !errors.Is(e, errNameTaken) { + if _, e = s.enrollParticipant(context.Background(), "BCDFGH", "ada demo", "", ""); !errors.Is(e, errNameTaken) { t.Fatalf("a colliding name enrolled anyway: %v", e) } ps := &api.ParticipantList{} @@ -886,7 +890,7 @@ func TestASecondClaimOnANameIsRefused(t *testing.T) { if len(ps.Items) != 1 { t.Fatalf("participants after the refused claim: %d", len(ps.Items)) } - if _, e = s.enrollParticipant(context.Background(), "BCDFGH", "Ada Demo 2", ""); e != nil { + if _, e = s.enrollParticipant(context.Background(), "BCDFGH", "Ada Demo 2", "", ""); e != nil { t.Fatal(e) } } @@ -1416,3 +1420,123 @@ func TestGroupPatternIsTheCRDs(t *testing.T) { } } } + +const ( + iPhoneSafari = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1" + androidChrome = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36" +) + +// Real User-Agents from the phones and laptops an audience brings. Order is +// what this tests: almost everything claims Safari, every Chromium browser +// claims Chrome, iOS claims to be like Mac OS X and Android claims Linux. +func TestDeviceOfNamesBrowsersFromAFixedList(t *testing.T) { + for ua, want := range map[string]device{ + iPhoneSafari: {"safari", "ios"}, + androidChrome: {"chrome", "android"}, + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1": {"chrome", "ios"}, + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15": {"firefox", "ios"}, + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/130.0.2849.80 Mobile/15E148 Safari/605.1.15": {"edge", "ios"}, + "Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1": {"safari", "ios"}, + "Mozilla/5.0 (Linux; Android 14; SAMSUNG SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36": {"samsung", "android"}, + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 EdgA/130.0.0.0": {"edge", "android"}, + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 OPR/85.0.0.0": {"opera", "android"}, + "Mozilla/5.0 (Android 15; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0": {"firefox", "android"}, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15": {"safari", "macos"}, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36": {"chrome", "macos"}, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0": {"edge", "windows"}, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0": {"firefox", "windows"}, + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36": {"chrome", "chromeos"}, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/130.0.0.0 Safari/537.36": {"chrome", "linux"}, + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 350.0.0.0": {"other", "ios"}, + "curl/8.5.0": {"other", "other"}, + "": {"other", "other"}, + "demo:a,system:masters