package main import ( "honeyos/internal/auth" "honeyos/internal/config" "honeyos/internal/store" "io/fs" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // Ein Anbieter zum Anfassen: signiert echte RS256-Token und prüft PKCE. Damit // lässt sich der ganze Anmeldeweg hier durchspielen, statt ihn erst in der // Produktion gegen PocketID zu debuggen. type fakeIdP struct { *httptest.Server key *rsa.PrivateKey clientID string lastChallenge string // was der Client als code_challenge geschickt hat verifierSeen string // was er beim Einlösen als code_verifier nachreicht } func newFakeIdP(t *testing.T, clientID string) *fakeIdP { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } f := &fakeIdP{key: key, clientID: clientID} mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{ "issuer": f.URL, "authorization_endpoint": f.URL + "/authorize", "token_endpoint": f.URL + "/token", "jwks_uri": f.URL + "/jwks", "id_token_signing_alg_values_supported": []string{"RS256"}, }) }) mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { n := base64.RawURLEncoding.EncodeToString(key.N.Bytes()) e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 writeJSON(w, 200, map[string]any{"keys": []map[string]string{ {"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test", "n": n, "e": e}, }}) }) mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() f.verifierSeen = r.Form.Get("code_verifier") writeJSON(w, 200, map[string]any{ "access_token": "at", "token_type": "Bearer", "id_token": f.idToken(t), }) }) f.Server = httptest.NewServer(mux) t.Cleanup(f.Close) return f } func (f *fakeIdP) idToken(t *testing.T) string { t.Helper() header := map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test"} claims := map[string]any{ "iss": f.URL, "sub": "sub-123", "aud": f.clientID, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "name": "Marc Testfall", "preferred_username": "marc", "picture": "https://example.invalid/avatar.png", } seg := func(v any) string { b, _ := json.Marshal(v) return base64.RawURLEncoding.EncodeToString(b) } signing := seg(header) + "." + seg(claims) sum := sha256.Sum256([]byte(signing)) sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:]) if err != nil { t.Fatal(err) } return signing + "." + base64.RawURLEncoding.EncodeToString(sig) } func newTestServer(t *testing.T, issuer string) *Server { t.Helper() st, err := store.Open(store.DataPath(t.TempDir())) if err != nil { t.Fatal(err) } cfg := config.Config{ PublicURL: "https://os.example.test", SessionSecret: strings.Repeat("k", 40), OIDCIssuer: issuer, OIDCClientID: "honeyos", OIDCClientSecret: "geheim", } return &Server{cfg: cfg, auth: auth.New(cfg), store: st} } // TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter, // Rückruf mit Code, Sitzungscookie, geschützter Endpunkt. func TestAnmeldeablauf(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) h := s.routes() // 1. Umleitung zum Anbieter — mit State und PKCE rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil)) if rec.Code != http.StatusFound { t.Fatalf("Umleitung erwartet, bekam %d: %s", rec.Code, rec.Body) } loc, err := url.Parse(rec.Header().Get("Location")) if err != nil { t.Fatal(err) } q := loc.Query() if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { t.Errorf("PKCE fehlt: %v", q) } if q.Get("redirect_uri") != "https://os.example.test/api/auth/callback" { t.Errorf("falsche Rückrufadresse: %q", q.Get("redirect_uri")) } state := q.Get("state") var stateCk *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == auth.StateCookie { stateCk = c } } if stateCk == nil { t.Fatal("kein State-Cookie gesetzt") } // 2. Rückruf mit Code req := httptest.NewRequest("GET", "/api/auth/callback?code=abc&state="+state, nil) req.AddCookie(stateCk) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" { t.Fatalf("Rückruf scheiterte: %d → %s", rec.Code, rec.Header().Get("Location")) } if idp.verifierSeen == "" { t.Error("der Client hat kein code_verifier nachgereicht") } var sess, csrf *http.Cookie for _, c := range rec.Result().Cookies() { switch c.Name { case auth.SessionCookie: sess = c case auth.CSRFCookie: csrf = c } } if sess == nil || csrf == nil { t.Fatal("Sitzungs- oder CSRF-Cookie fehlt") } if !sess.HttpOnly { t.Error("Sitzungscookie muss HttpOnly sein") } if csrf.HttpOnly { t.Error("CSRF-Cookie muss lesbar sein, sonst kann die Seite es nicht mitschicken") } // 3. Geschützter Endpunkt kennt den Nutzer req = httptest.NewRequest("GET", "/api/me", nil) req.AddCookie(sess) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("/api/me: %d", rec.Code) } var me struct{ Sub, Name, Picture string } _ = json.Unmarshal(rec.Body.Bytes(), &me) if me.Sub != "sub-123" || me.Name != "Marc Testfall" { t.Errorf("falscher Nutzer: %+v", me) } if me.Picture == "" { t.Error("Bild aus dem Token wurde nicht übernommen") } } func TestOhneAnmeldungKeineDaten(t *testing.T) { idp := newFakeIdP(t, "honeyos") h := newTestServer(t, idp.URL).routes() for _, path := range []string{"/api/me", "/api/apps"} { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if rec.Code != http.StatusUnauthorized { t.Errorf("%s ohne Anmeldung: %d, erwartet 401", path, rec.Code) } } } func TestGefälschtesCookieZähltNicht(t *testing.T) { idp := newFakeIdP(t, "honeyos") h := newTestServer(t, idp.URL).routes() req := httptest.NewRequest("GET", "/api/me", nil) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("selbstgebautes Cookie wurde akzeptiert: %d", rec.Code) } } // TestSchreibenBrauchtCSRF: das Sitzungscookie allein darf nicht reichen, // sonst könnte eine fremde Seite im Namen des Angemeldeten schreiben. func TestSchreibenBrauchtCSRF(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) h := s.routes() sess, csrf := signIn(t, h) body := `{"mode":"desktop"}` req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body)) req.AddCookie(sess) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("ohne CSRF-Kopfzeile: %d, erwartet 403", rec.Code) } req = httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body)) req.AddCookie(sess) req.AddCookie(csrf) req.Header.Set("X-CSRF-Token", csrf.Value) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("mit CSRF-Kopfzeile: %d — %s", rec.Code, rec.Body) } // und die Einstellung überlebt if got := s.store.User("sub-123").Settings["mode"]; got != "desktop" { t.Errorf("Einstellung nicht gespeichert: %q", got) } } func TestAppsAnlegenUndPrüfen(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) h := s.routes() sess, csrf := signIn(t, h) put := func(body string) *httptest.ResponseRecorder { req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(body)) req.AddCookie(sess) req.AddCookie(csrf) req.Header.Set("X-CSRF-Token", csrf.Value) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } if rec := put(`[{"id":"aether","name":"AETHER","url":"https://cloud.example.test"}]`); rec.Code != 200 { t.Fatalf("gültige App abgelehnt: %d — %s", rec.Code, rec.Body) } if apps := s.store.Apps(); len(apps) != 1 || apps[0].ID != "aether" { t.Fatalf("nicht gespeichert: %+v", apps) } schlecht := []struct{ name, body string }{ {"leere Adresse", `[{"id":"x","url":""}]`}, {"Adresse ohne Schema", `[{"id":"x","url":"cloud.example.test"}]`}, {"javascript:", `[{"id":"x","url":"javascript:alert(1)"}]`}, {"ID mit Leerzeichen", `[{"id":"mein app","url":"https://a.test"}]`}, {"ID doppelt", `[{"id":"a","url":"https://a.test"},{"id":"a","url":"https://b.test"}]`}, } for _, c := range schlecht { if rec := put(c.body); rec.Code != http.StatusBadRequest { t.Errorf("%s wurde angenommen (%d)", c.name, rec.Code) } } // nach allen Fehlversuchen steht die gültige Liste unverändert da if apps := s.store.Apps(); len(apps) != 1 { t.Errorf("Liste beschädigt: %+v", apps) } } // TestOffenerBetrieb: ohne OIDC_ISSUER läuft alles ohne Anmeldung — das ist // gewollt, muss aber auch wirklich funktionieren. func TestOffenerBetrieb(t *testing.T) { s := newTestServer(t, "") h := s.routes() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil)) if rec.Code != 200 { t.Fatalf("offener Betrieb verweigert: %d", rec.Code) } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("PUT", "/api/settings", strings.NewReader(`{"phosphor":"green"}`))) if rec.Code != http.StatusNoContent { t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code) } if s.store.User(store.LocalSub).Settings["phosphor"] != "green" { t.Error("Einstellung im offenen Betrieb nicht gespeichert") } } // TestLeisteNurFürEingetrageneApps: die App-Liste ist zugleich die // CORS-Freigabe. Wer nicht drinsteht, bekommt keine Kopfzeile. func TestLeisteNurFürEingetrageneApps(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) h := s.routes() if err := s.store.SetApps([]store.App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil { t.Fatal(err) } for origin, want := range map[string]string{ "https://cloud.example.test": "https://cloud.example.test", "https://boese.example.test": "", } { req := httptest.NewRequest("GET", "/api/apps", nil) req.Header.Set("Origin", origin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != want { t.Errorf("Origin %s: Freigabe %q, erwartet %q", origin, got, want) } } } func TestStatischeOberflächeWirdAusgeliefert(t *testing.T) { h := newTestServer(t, "").routes() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "HoneyOS") { t.Fatalf("Startseite fehlt: %d", rec.Code) } } // signIn spielt eine Anmeldung durch und gibt die beiden Cookies zurück. func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil)) loc, _ := url.Parse(rec.Header().Get("Location")) var stateCk *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == auth.StateCookie { stateCk = c } } if stateCk == nil { t.Fatal("kein State-Cookie") } req := httptest.NewRequest("GET", fmt.Sprintf("/api/auth/callback?code=abc&state=%s", loc.Query().Get("state")), nil) req.AddCookie(stateCk) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) for _, c := range rec.Result().Cookies() { switch c.Name { case auth.SessionCookie: sess = c case auth.CSRFCookie: csrf = c } } if sess == nil || csrf == nil { t.Fatalf("Anmeldung fehlgeschlagen: %d", rec.Code) } return sess, csrf } // ── Betrieb hinter einem Anmelde-Proxy ───────────────────────────────────── // Kein eigener Anmeldeschirm: Pangolin o. ä. hat die Tür schon zugemacht. func proxyServer(t *testing.T, cfg config.Config) *Server { t.Helper() st, err := store.Open(store.DataPath(t.TempDir())) if err != nil { t.Fatal(err) } cfg.SessionSecret = strings.Repeat("k", 40) return &Server{cfg: cfg, auth: auth.New(cfg), store: st} } func TestProxyBetriebLässtDurch(t *testing.T) { h := proxyServer(t, config.Config{ProxyAuth: true}).routes() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil)) if rec.Code != 200 { t.Fatalf("/api/me hinter dem Proxy: %d", rec.Code) } // Kein Anmeldeschirm: /api/public darf nicht behaupten, es gäbe OIDC. rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/public", nil)) var pub struct{ OIDC, SignedIn bool } _ = json.Unmarshal(rec.Body.Bytes(), &pub) if pub.OIDC || !pub.SignedIn { t.Errorf("die Oberfläche würde einen Anmeldeschirm zeigen: %+v", pub) } } // Nennt der Proxy einen Namen, bekommt jeder Name seine eigenen Einstellungen. func TestProxyKopfzeileTrenntEinstellungen(t *testing.T) { s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User"}) h := s.routes() put := func(user, body string) { req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body)) if user != "" { req.Header.Set("Remote-User", user) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("PUT für %q: %d", user, rec.Code) } } put("marc", `{"phosphor":"green"}`) put("gast", `{"phosphor":"ice"}`) if got := s.store.User("proxy:marc").Settings["phosphor"]; got != "green" { t.Errorf("marc: %q", got) } if got := s.store.User("proxy:gast").Settings["phosphor"]; got != "ice" { t.Errorf("gast: %q", got) } } // ── Schlüssel für die App-Liste ──────────────────────────────────────────── func TestAppListeBrauchtDenSchlüssel(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) s.cfg.AppsKey = "geheim123" s.auth = auth.New(s.cfg) h := s.routes() get := func(mod func(*http.Request)) int { req := httptest.NewRequest("GET", "/api/apps", nil) if mod != nil { mod(req) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec.Code } if code := get(nil); code != http.StatusUnauthorized { t.Errorf("ohne Schlüssel: %d, erwartet 401", code) } if code := get(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "falsch") }); code != http.StatusUnauthorized { t.Errorf("falscher Schlüssel: %d, erwartet 401", code) } if code := get(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "geheim123") }); code != 200 { t.Errorf("richtiger Schlüssel: %d, erwartet 200", code) } if code := get(func(r *http.Request) { r.URL.RawQuery = "key=geheim123" }); code != 200 { t.Errorf("Schlüssel als Parameter: %d, erwartet 200", code) } } // Liegt die App unter derselben Site, kommt die Sitzung mit — dann muss die // HoneyBar die Einstellungen des Angemeldeten bekommen und nicht die des // Standardnutzers. Der Schlüssel darf dabei ruhig mitgeschickt werden: Er ist // die Krücke für den fremden Fall und darf die Sitzung nicht überstimmen. func TestSitzungSchlaegtSchluessel(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) s.cfg.AppsKey = "geheim123" s.auth = auth.New(s.cfg) h := s.routes() sess, csrf := signIn(t, h) // Der Angemeldete stellt seine Röhre auf grün. req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(`{"phosphor":"green"}`)) req.Header.Set("X-CSRF-Token", csrf.Value) req.AddCookie(sess) req.AddCookie(csrf) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("Einstellungen speichern: %d", rec.Code) } // Ein zweiter Nutzer mit anderer Röhre — sonst könnte der Rückfall auf den // „einzigen Nutzer" das Ergebnis zufällig richtig aussehen lassen. if err := s.store.SetSettings("jemand-anders", map[string]string{"phosphor": "ice"}); err != nil { t.Fatal(err) } hole := func(mod func(*http.Request)) map[string]any { req := httptest.NewRequest("GET", "/api/apps", nil) mod(req) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("/api/apps: %d", rec.Code) } var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } return out } mitBeidem := hole(func(r *http.Request) { r.AddCookie(sess) r.Header.Set("X-HoneyOS-Key", "geheim123") }) if got := mitBeidem["settings"].(map[string]any)["phosphor"]; got != "green" { t.Errorf("mit Sitzung und Schlüssel: %v, erwartet green (die des Angemeldeten)", got) } // Ohne Sitzung bleibt der Schlüssel der Weg — und landet beim // Standardnutzer. Bei zwei Nutzern heißt das: kein Rateweg, leere Menge. nurSchluessel := hole(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "geheim123") }) if got := nurSchluessel["settings"].(map[string]any)["phosphor"]; got == "green" { t.Errorf("nur mit Schlüssel: %v — der Schlüssel kennt keinen Nutzer und darf keinen raten", got) } } // Der Schlüssel öffnet nur das Lesen der Liste, nicht das Schreiben. func TestSchlüsselDarfNichtSchreiben(t *testing.T) { idp := newFakeIdP(t, "honeyos") s := newTestServer(t, idp.URL) s.cfg.AppsKey = "geheim123" s.auth = auth.New(s.cfg) h := s.routes() req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(`[{"id":"x","url":"https://a.test"}]`)) req.Header.Set("X-HoneyOS-Key", "geheim123") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("Schreiben mit Schlüssel: %d, erwartet 401", rec.Code) } } // Die Oberfläche muss den Schlüssel mitbekommen, sonst fragt sie sich selbst aus. func TestStartseiteTrägtDenSchlüssel(t *testing.T) { s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"}) rec := httptest.NewRecorder() s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) if !strings.Contains(rec.Body.String(), `name="honeyos-key" content="geheim123"`) { t.Fatal("Schlüssel steht nicht in der Startseite") } // Ohne Schlüssel darf auch nichts drinstehen. s2 := proxyServer(t, config.Config{ProxyAuth: true}) rec = httptest.NewRecorder() s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) if strings.Contains(rec.Body.String(), "honeyos-key") { t.Error("ohne Schlüssel steht trotzdem eine Meta-Angabe in der Seite") } } // Der Fall, der beim Durchspielen aufgefallen ist: Ohne eigene Anmeldung gilt // jede Anfrage als angemeldet. Der Schlüssel darf dann NICHT auf diese Prüfung // zurückfallen, sonst gibt der freigegebene Pfad die Liste an jeden heraus. func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) { h := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"}).routes() get := func(key string) int { req := httptest.NewRequest("GET", "/api/apps", nil) if key != "" { req.Header.Set("X-HoneyOS-Key", key) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec.Code } if code := get(""); code != http.StatusUnauthorized { t.Errorf("ohne Schlüssel hinter dem Proxy: %d, erwartet 401", code) } if code := get("falsch"); code != http.StatusUnauthorized { t.Errorf("falscher Schlüssel hinter dem Proxy: %d, erwartet 401", code) } if code := get("geheim123"); code != 200 { t.Errorf("richtiger Schlüssel: %d, erwartet 200", code) } } // Die Leiste stellt genau eine Anfrage — sie muss Liste und Aussehen bringen. func TestAppListeBringtDasAussehenMit(t *testing.T) { s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"}) if err := s.store.SetSettings(store.LocalSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil { t.Fatal(err) } if err := s.store.SetApps([]store.App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test"}}); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/apps", nil) req.Header.Set("X-HoneyOS-Key", "geheim123") rec := httptest.NewRecorder() s.routes().ServeHTTP(rec, req) var got struct { Apps []store.App Settings map[string]string } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("Antwort nicht lesbar: %v — %s", err, rec.Body) } if len(got.Apps) != 1 || got.Apps[0].ID != "aether" { t.Errorf("Liste fehlt: %+v", got.Apps) } if got.Settings["phosphor"] != "green" || got.Settings["glow"] != "off" { t.Errorf("Aussehen fehlt: %+v", got.Settings) } } // Die angezeigte Version muss vom Server kommen. Vorher stand sie zusätzlich in // os.js und blieb bei 0.2 stehen, während die Images schon 0.4 hiessen. func TestVersionKommtVomServer(t *testing.T) { h := proxyServer(t, config.Config{ProxyAuth: true}).routes() for _, pfad := range []string{"/api/public", "/api/me"} { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", pfad, nil)) var got struct{ Version string } _ = json.Unmarshal(rec.Body.Bytes(), &got) if got.Version != Version { t.Errorf("%s nennt Version %q, erwartet %q", pfad, got.Version, Version) } } // Und in der Oberfläche darf keine zweite fest eingetragen sein — in keiner // Datei, egal wie die Oberfläche später aufgeteilt wird. err := fs.WalkDir(webFS, ".", func(pfad string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(pfad, ".js") { return err } raw, err := webFS.ReadFile(pfad) if err != nil { return err } if strings.Contains(string(raw), `const VERSION = "`) { t.Errorf("%s trägt wieder eine eigene Version — die läuft mit den Image-Tags auseinander", pfad) } return nil }) if err != nil { t.Fatal(err) } } // Der Fall, nach dem Marc gefragt hat: Einstellungen am Rechner setzen, und die // Leiste am Handy trägt dieselbe Farbe. Sie fragt über die Site-Grenze, landet // also beim Standardnutzer — die Einstellungen können aber unter einem Namen // aus der Proxy-Kopfzeile liegen. func TestLeisteFindetDasAussehenAuchÜberDieSiteGrenze(t *testing.T) { s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"}) h := s.routes() // Am Rechner, durch den Proxy: grün. req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(`{"phosphor":"green"}`)) req.Header.Set("Remote-User", "marc") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("speichern: %d", rec.Code) } // Am Handy fragt die Leiste nur mit dem Schlüssel — ohne Namen. req = httptest.NewRequest("GET", "/api/apps", nil) req.Header.Set("X-HoneyOS-Key", "geheim123") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var got struct{ Settings map[string]string } _ = json.Unmarshal(rec.Body.Bytes(), &got) if got.Settings["phosphor"] != "green" { t.Fatalf("die Leiste bekäme %q statt grün — auf dem Handy bliebe sie bernstein", got.Settings["phosphor"]) } } // Bei mehreren Nutzern darf nicht geraten werden, wessen Farbe gilt. func TestBeiMehrerenNutzernWirdNichtGeraten(t *testing.T) { s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"}) for name, farbe := range map[string]string{"marc": "green", "gast": "ice"} { if err := s.store.SetSettings("proxy:"+name, map[string]string{"phosphor": farbe}); err != nil { t.Fatal(err) } } req := httptest.NewRequest("GET", "/api/apps", nil) req.Header.Set("X-HoneyOS-Key", "geheim123") rec := httptest.NewRecorder() s.routes().ServeHTTP(rec, req) var got struct{ Settings map[string]string } _ = json.Unmarshal(rec.Body.Bytes(), &got) if got.Settings["phosphor"] != "" { t.Errorf("es wurde geraten: %q", got.Settings["phosphor"]) } }