Die Oberfläche zeigte 0.2, während die Images schon 0.4 hiessen: os.js trug eine eigene Konstante, die ich beim Taggen nie mitgezogen habe. Jetzt steht sie nur noch in main.go. build.sh liest sie von dort und vergibt denselben Tag, die Oberfläche holt sie über /api/public und /api/me. Ein Test schlaegt an, falls je wieder eine zweite Version in os.js auftaucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
598 lines
19 KiB
Go
598 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"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()
|
|
store, err := OpenStore(dataPath(t.TempDir()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := Config{
|
|
PublicURL: "https://os.example.test",
|
|
SessionSecret: strings.Repeat("k", 40),
|
|
OIDCIssuer: issuer,
|
|
OIDCClientID: "honeyos",
|
|
OIDCClientSecret: "geheim",
|
|
}
|
|
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
|
|
}
|
|
|
|
// 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 == 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 sessionCookie:
|
|
sess = c
|
|
case 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: 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)"}]`},
|
|
{"Kennung mit Leerzeichen", `[{"id":"mein app","url":"https://a.test"}]`},
|
|
{"Kennung 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(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([]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 == 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 sessionCookie:
|
|
sess = c
|
|
case 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) *Server {
|
|
t.Helper()
|
|
store, err := OpenStore(dataPath(t.TempDir()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.SessionSecret = strings.Repeat("k", 40)
|
|
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
|
|
}
|
|
|
|
func TestProxyBetriebLässtDurch(t *testing.T) {
|
|
h := proxyServer(t, 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{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 = NewAuth(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 = NewAuth(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{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{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{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{ProxyAuth: true, AppsKey: "geheim123"})
|
|
if err := s.store.SetSettings(localSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.store.SetApps([]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 []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{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.
|
|
raw, err := webFS.ReadFile("web/os.js")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), `const VERSION = "`) {
|
|
t.Error("os.js trägt wieder eine eigene Version — die läuft mit den Image-Tags auseinander")
|
|
}
|
|
}
|