Der Schluessel kennt keinen Nutzer und landete beim Standardnutzer — auch dann, wenn die App unter derselben Site liegt und der Browser das HoneyOS-Cookie laengst mitschickt (cloud.honeyos.… und honeyos.… sind dieselbe Site, duckdns.org steht auf der Public-Suffix-Liste). Damit trug die HoneyBar in Aether die Farben eines fremden Nutzers. Jetzt sieht /api/apps erst nach der Sitzung, und nur bei eigener Anmeldung: Im offenen Betrieb und hinter einem Anmelde-Proxy gilt jede Anfrage als angemeldet, dort bleibt der Schluessel das letzte Wort. Test dazu, mit zwei Nutzern, damit der Rueckfall auf den einzigen Nutzer das Ergebnis nicht zufaellig richtig aussehen laesst.
727 lines
24 KiB
Go
727 lines
24 KiB
Go
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"])
|
|
}
|
|
}
|