Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher lagen fuenf Dateien in einem Topf, und jede kannte jede. Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server, Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je Datei, os.js nur noch die Ansichten. Stylesheet nach css/. Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden Seiten mit einer Zeile eingebunden, da darf nichts nachladen. Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert. Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
664 lines
21 KiB
Go
664 lines
21 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)
|
|
}
|
|
}
|
|
|
|
// 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"])
|
|
}
|
|
}
|