Basking: sign in with Authentik only, pages that nest and link
Sign-in is now OpenID Connect against Authentik (authorization code flow with PKCE, state and nonce). The password, API tokens and the add command are gone; each person who signs in gets a private notebook in a folder named from a hash of who they are. An optional allowed list is checked on every request. Notes become pages with titles, a tree of subpages, [[links]] with backlinks that follow renames, a Ctrl+K finder, slash commands, list continuation, and Write and Read modes. Autosave keeps the text through a dropped connection or an expired session. Export opens as an Obsidian vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3291
-1578
No files matched your search
+528
-253
@@ -3,337 +3,612 @@ package web
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/store"
|
||||
"git.veillax.com/Veillax/basking/internal/oidc"
|
||||
)
|
||||
|
||||
const password = "correct horse battery"
|
||||
|
||||
type rig struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
cfg *config.Holder
|
||||
cookie *http.Cookie
|
||||
// idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID
|
||||
// token carrying whatever claims the test set.
|
||||
type idp struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
nonce string
|
||||
challenge string
|
||||
claims map[string]any
|
||||
down bool
|
||||
}
|
||||
|
||||
func newRig(t *testing.T) *rig {
|
||||
func newIdP(t *testing.T) *idp {
|
||||
p := &idp{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/app/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"issuer": p.srv.URL + "/app/", "authorization_endpoint": p.srv.URL + "/authorize", "token_endpoint": p.srv.URL + "/token"})
|
||||
})
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
r.ParseForm()
|
||||
if p.down {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("code") != "good" || r.Form.Get("client_secret") != "shh" || r.Form.Get("grant_type") != "authorization_code" ||
|
||||
oidc.Challenge(r.Form.Get("code_verifier")) != p.challenge || r.Form.Get("redirect_uri") != "http://127.0.0.1:8490/auth/callback" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
|
||||
return
|
||||
}
|
||||
c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "basking-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce}
|
||||
for k, v := range p.claims {
|
||||
c[k] = v
|
||||
}
|
||||
raw, _ := json.Marshal(c)
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
json.NewEncoder(w).Encode(map[string]string{"id_token": enc([]byte(`{"alg":"none"}`)) + "." + enc(raw) + ".sig", "access_token": "x"})
|
||||
})
|
||||
p.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(p.srv.Close)
|
||||
return p
|
||||
}
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
s *Server
|
||||
h http.Handler
|
||||
idp *idp
|
||||
dir string
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func setup(t *testing.T, tweak func(map[string]any)) *env {
|
||||
t.Helper()
|
||||
p := newIdP(t)
|
||||
dir := t.TempDir()
|
||||
h, err := config.Load(filepath.Join(dir, "basking.json"))
|
||||
conf := map[string]any{
|
||||
"data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret",
|
||||
"oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "basking-client", "client_secret": "shh"},
|
||||
}
|
||||
if tweak != nil {
|
||||
tweak(conf)
|
||||
}
|
||||
b, _ := json.Marshal(conf)
|
||||
path := filepath.Join(dir, "basking.json")
|
||||
os.WriteFile(path, b, 0o600)
|
||||
h, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := config.HashPassword(password)
|
||||
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "data"))
|
||||
s, err := New(h, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(New(h, st, "test").Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return &rig{t: t, srv: srv, cfg: h}
|
||||
return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()}
|
||||
}
|
||||
|
||||
func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) {
|
||||
r.t.Helper()
|
||||
req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body))
|
||||
if ctype != "" {
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder {
|
||||
e.t.Helper()
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
r := httptest.NewRequest(method, path, rd)
|
||||
if body != nil {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if r.cookie != nil {
|
||||
req.AddCookie(r.cookie)
|
||||
if method != http.MethodGet {
|
||||
r.Header.Set("X-Basking", "1")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
|
||||
// js sends JSON as a browser would, with the CSRF header.
|
||||
func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) {
|
||||
r.t.Helper()
|
||||
var body string
|
||||
if v != nil {
|
||||
b, _ := json.Marshal(v)
|
||||
body = string(b)
|
||||
}
|
||||
resp, b := r.do(method, path, "application/json", body, "X-Basking", "1")
|
||||
var out map[string]any
|
||||
json.Unmarshal(b, &out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func (r *rig) signIn() {
|
||||
r.t.Helper()
|
||||
resp, _ := r.js("POST", "/api/login", map[string]string{"password": password})
|
||||
if resp.StatusCode != 200 {
|
||||
r.t.Fatalf("login: %d", resp.StatusCode)
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == cookieName {
|
||||
r.cookie = c
|
||||
for i := 0; i+1 < len(hdr); i += 2 {
|
||||
if hdr[i+1] == "" {
|
||||
r.Header.Del(hdr[i])
|
||||
} else {
|
||||
r.Header.Set(hdr[i], hdr[i+1])
|
||||
}
|
||||
}
|
||||
if r.cookie == nil {
|
||||
r.t.Fatal("no session cookie")
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRequiresSignIn(t *testing.T) {
|
||||
r := newRig(t)
|
||||
for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} {
|
||||
if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 {
|
||||
t.Errorf("GET %s without signing in: %d", p, resp.StatusCode)
|
||||
func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 {
|
||||
t.Error("healthz should be open")
|
||||
}
|
||||
if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 {
|
||||
t.Error("the page itself should load")
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("bad json %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndHeaders(t *testing.T) {
|
||||
r := newRig(t)
|
||||
if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 {
|
||||
t.Errorf("login without the CSRF header: %d", resp.StatusCode)
|
||||
// begin starts a sign-in and returns the redirect the browser would follow and the flow cookie.
|
||||
func (e *env) begin(next string) (*url.URL, *http.Cookie) {
|
||||
e.t.Helper()
|
||||
w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
e.t.Fatalf("login: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
|
||||
t.Errorf("wrong password: %d", resp.StatusCode)
|
||||
to, _ := url.Parse(w.Header().Get("Location"))
|
||||
return to, cookieNamed(w, flowCookie)
|
||||
}
|
||||
|
||||
// signIn runs the whole flow for a person and returns their session cookie.
|
||||
func (e *env) signIn(claims map[string]any) []*http.Cookie {
|
||||
e.t.Helper()
|
||||
to, flow := e.begin("")
|
||||
e.idp.mu.Lock()
|
||||
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims
|
||||
e.idp.mu.Unlock()
|
||||
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
|
||||
ck := cookieNamed(w, sessionCookie)
|
||||
if w.Code != http.StatusSeeOther || ck == nil {
|
||||
e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location"))
|
||||
}
|
||||
r.signIn()
|
||||
resp, _ := r.do("GET", "/api/me", "", "")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
return []*http.Cookie{ck}
|
||||
}
|
||||
|
||||
var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"}
|
||||
var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"}
|
||||
|
||||
func TestNothingWorksWithoutSigningIn(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} {
|
||||
if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET %s without a session: %d", path, w.Code)
|
||||
}
|
||||
}
|
||||
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
|
||||
t.Errorf("CSP %q", csp)
|
||||
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("POST without a session: %d", w.Code)
|
||||
}
|
||||
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("cookie flags %+v", r.cookie)
|
||||
if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") {
|
||||
t.Errorf("the sign-in page itself must load: %d", w.Code)
|
||||
}
|
||||
// a browser write needs the header
|
||||
if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 {
|
||||
t.Errorf("write without the header: %d", resp.StatusCode)
|
||||
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
|
||||
t.Errorf("healthz: %d", w.Code)
|
||||
}
|
||||
// a forged cookie does nothing
|
||||
r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"}
|
||||
if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 {
|
||||
t.Errorf("forged cookie: %d", resp.StatusCode)
|
||||
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("garbage cookie: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginIsRateLimited(t *testing.T) {
|
||||
r := newRig(t)
|
||||
var last int
|
||||
for i := 0; i < maxLoginFails+2; i++ {
|
||||
resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"})
|
||||
last = resp.StatusCode
|
||||
func TestSignInFlow(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
to, flow := e.begin("#/p/abc-123")
|
||||
q := to.Query()
|
||||
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
|
||||
t.Fatalf("goes to Authentik: %s", to)
|
||||
}
|
||||
if last != 429 {
|
||||
t.Fatalf("after many failures got %d", last)
|
||||
for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
|
||||
"scope": "openid profile email", "code_challenge_method": "S256"} {
|
||||
if q.Get(k) != want {
|
||||
t.Errorf("%s = %q, want %q", k, q.Get(k), want)
|
||||
}
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}); resp.StatusCode != 429 {
|
||||
t.Fatalf("even the right password waits: %d", resp.StatusCode)
|
||||
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
||||
if len(q.Get(k)) < 16 {
|
||||
t.Errorf("%s is too short: %q", k, q.Get(k))
|
||||
}
|
||||
}
|
||||
if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" {
|
||||
t.Errorf("flow cookie: %+v", flow)
|
||||
}
|
||||
|
||||
e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice
|
||||
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow})
|
||||
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/#/p/abc-123" {
|
||||
t.Fatalf("callback: %d -> %s", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
ses := cookieNamed(w, sessionCookie)
|
||||
if ses == nil || !ses.HttpOnly || ses.SameSite != http.SameSiteLaxMode || ses.Secure {
|
||||
t.Fatalf("session cookie: %+v", ses)
|
||||
}
|
||||
if c := cookieNamed(w, flowCookie); c == nil || c.MaxAge >= 0 {
|
||||
t.Error("the flow cookie should be cleared after use")
|
||||
}
|
||||
var me struct{ Name, Email string }
|
||||
jsonOf(t, e.do("GET", "/api/me", nil, []*http.Cookie{ses}), &me)
|
||||
if me.Name != "Alice" || me.Email != "alice@example.com" {
|
||||
t.Fatalf("me: %+v", me)
|
||||
}
|
||||
|
||||
// the same flow cookie cannot be used twice by a browser that kept it, and a stale state is refused
|
||||
if w := e.do("GET", "/auth/callback?code=good&state=wrong", nil, []*http.Cookie{flow}); w.Header().Get("Location") != "/?signin=failed" {
|
||||
t.Errorf("wrong state: %s", w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteLifecycle(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
|
||||
resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"})
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, n)
|
||||
func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) {
|
||||
t.Helper()
|
||||
to, flow := e.begin("")
|
||||
e.idp.mu.Lock()
|
||||
e.idp.nonce, e.idp.challenge, e.idp.claims, e.idp.down = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice, false
|
||||
e.idp.mu.Unlock()
|
||||
target := mutate(to, &flow)
|
||||
var cks []*http.Cookie
|
||||
if flow != nil {
|
||||
cks = append(cks, flow)
|
||||
}
|
||||
w := e.do("GET", target, nil, cks)
|
||||
if got := w.Header().Get("Location"); got != "/?signin="+want || cookieNamed(w, sessionCookie) != nil {
|
||||
t.Errorf("%s: went to %q (%d), session %v; want %s", name, got, w.Code, cookieNamed(w, sessionCookie), want)
|
||||
}
|
||||
}
|
||||
id, _ := n["id"].(string)
|
||||
if id == "" || n["body"] != "remember the milk #errands" {
|
||||
t.Fatalf("created %v", n)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 {
|
||||
t.Errorf("empty note: %d", resp.StatusCode)
|
||||
good := func(to *url.URL) string {
|
||||
return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state"))
|
||||
}
|
||||
|
||||
_, list := r.js("GET", "/api/notes?q=milk", nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Fatalf("search: %v", list)
|
||||
}
|
||||
_, list = r.js("GET", "/api/notes?tag=errands", nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Fatalf("tag: %v", list)
|
||||
}
|
||||
try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired")
|
||||
try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired")
|
||||
try("wrong state", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=good&state=nope" }, "failed")
|
||||
try("no code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?state=" + to.Query().Get("state") }, "failed")
|
||||
try("denied at Authentik", func(to *url.URL, f **http.Cookie) string {
|
||||
return "/auth/callback?error=access_denied&state=" + to.Query().Get("state")
|
||||
}, "denied")
|
||||
try("bad code", func(to *url.URL, f **http.Cookie) string {
|
||||
return "/auth/callback?code=stolen&state=" + url.QueryEscape(to.Query().Get("state"))
|
||||
}, "failed")
|
||||
try("wrong nonce", func(to *url.URL, f **http.Cookie) string {
|
||||
e.idp.mu.Lock()
|
||||
e.idp.nonce = "other"
|
||||
e.idp.mu.Unlock()
|
||||
return good(to)
|
||||
}, "failed")
|
||||
try("wrong audience", func(to *url.URL, f **http.Cookie) string {
|
||||
e.idp.mu.Lock()
|
||||
e.idp.claims = map[string]any{"sub": "x", "aud": "someone-else"}
|
||||
e.idp.mu.Unlock()
|
||||
return good(to)
|
||||
}, "failed")
|
||||
try("expired token", func(to *url.URL, f **http.Cookie) string {
|
||||
e.idp.mu.Lock()
|
||||
e.idp.claims = map[string]any{"sub": "x", "exp": time.Now().Add(-time.Hour).Unix()}
|
||||
e.idp.mu.Unlock()
|
||||
return good(to)
|
||||
}, "failed")
|
||||
try("wrong issuer", func(to *url.URL, f **http.Cookie) string {
|
||||
e.idp.mu.Lock()
|
||||
e.idp.claims = map[string]any{"sub": "x", "iss": "https://evil.example.com/"}
|
||||
e.idp.mu.Unlock()
|
||||
return good(to)
|
||||
}, "failed")
|
||||
try("no subject", func(to *url.URL, f **http.Cookie) string {
|
||||
e.idp.mu.Lock()
|
||||
e.idp.claims = map[string]any{"sub": ""}
|
||||
e.idp.mu.Unlock()
|
||||
return good(to)
|
||||
}, "failed")
|
||||
try("Authentik is down", func(to *url.URL, f **http.Cookie) string {
|
||||
e.idp.mu.Lock()
|
||||
e.idp.down = true
|
||||
e.idp.mu.Unlock()
|
||||
return good(to)
|
||||
}, "unavailable")
|
||||
|
||||
// edit with the version we have, then again with a stale one
|
||||
resp, n2 := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "remember the oat milk", "base": n["updated"]})
|
||||
if resp.StatusCode != 200 || n2["body"] != "remember the oat milk" {
|
||||
t.Fatalf("update: %d %v", resp.StatusCode, n2)
|
||||
}
|
||||
resp, c := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "stale", "base": n["updated"]})
|
||||
if resp.StatusCode != 409 {
|
||||
t.Fatalf("stale update: %d", resp.StatusCode)
|
||||
}
|
||||
if cur, _ := c["note"].(map[string]any); cur["body"] != "remember the oat milk" {
|
||||
t.Errorf("conflict should return the current note: %v", c)
|
||||
}
|
||||
if resp, _ := r.js("PUT", "/api/notes/"+id, map[string]any{"pinned": true}); resp.StatusCode != 200 {
|
||||
t.Errorf("pin: %d", resp.StatusCode)
|
||||
}
|
||||
_, list = r.js("GET", "/api/notes?view=pinned", nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Errorf("pinned view: %v", list)
|
||||
}
|
||||
|
||||
if resp, _ := r.js("DELETE", "/api/notes/"+id+"?purge=1", nil); resp.StatusCode != 400 {
|
||||
t.Errorf("purge of a live note: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := r.js("DELETE", "/api/notes/"+id, nil); resp.StatusCode != 200 {
|
||||
t.Errorf("trash: %d", resp.StatusCode)
|
||||
}
|
||||
_, sum := r.js("GET", "/api/summary", nil)
|
||||
if counts := sum["counts"].(map[string]any); counts["trash"] != float64(1) || counts["all"] != float64(0) {
|
||||
t.Errorf("summary after trashing: %v", sum)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/notes/"+id+"/restore", nil); resp.StatusCode != 200 {
|
||||
t.Errorf("restore: %d", resp.StatusCode)
|
||||
}
|
||||
r.js("DELETE", "/api/notes/"+id, nil)
|
||||
if resp, _ := r.js("DELETE", "/api/trash", nil); resp.StatusCode != 200 {
|
||||
t.Errorf("empty trash: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := r.js("GET", "/api/notes/"+id, nil); resp.StatusCode != 404 {
|
||||
t.Errorf("gone: %d", resp.StatusCode)
|
||||
}
|
||||
for _, bad := range []string{"..%2f..%2fbasking.json", "UPPER", "a.b"} {
|
||||
if resp, _ := r.js("GET", "/api/notes/"+bad, nil); resp.StatusCode != 404 {
|
||||
t.Errorf("odd id %q: %d", bad, resp.StatusCode)
|
||||
// and a next address that points anywhere but inside Basking is dropped
|
||||
for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/<script>", "javascript:alert(1)"} {
|
||||
to, flow := e.begin(bad)
|
||||
e.idp.mu.Lock()
|
||||
e.idp.down = false
|
||||
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice
|
||||
e.idp.mu.Unlock()
|
||||
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
|
||||
if w.Header().Get("Location") != "/" {
|
||||
t.Errorf("next %q led to %q", bad, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyEndpoint(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
resp, n := r.js("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
|
||||
if resp.StatusCode != 200 || n["id"] != "daily-2026-10-31" {
|
||||
t.Fatalf("%d %v", resp.StatusCode, n)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/daily", map[string]string{"date": "yesterday"}); resp.StatusCode != 400 {
|
||||
t.Errorf("bad date: %d", resp.StatusCode)
|
||||
func TestSignInWhenAuthentikIsUnreachable(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
e.idp.srv.Close()
|
||||
w := e.do("GET", "/auth/login", nil, nil)
|
||||
if w.Header().Get("Location") != "/?signin=unavailable" {
|
||||
t.Fatalf("went to %q", w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAndPlainTextCapture(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
resp, tok := r.js("POST", "/api/tokens", map[string]string{"name": "phone"})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("new token: %d %v", resp.StatusCode, tok)
|
||||
func TestAllowedList(t *testing.T) {
|
||||
e := setup(t, func(c map[string]any) { c["allowed"] = []string{"ALICE@example.com", "carol"} })
|
||||
to, flow := e.begin("")
|
||||
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), bob
|
||||
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
|
||||
if w.Header().Get("Location") != "/?signin=denied" || cookieNamed(w, sessionCookie) != nil {
|
||||
t.Fatalf("Bob should be turned away: %s", w.Header().Get("Location"))
|
||||
}
|
||||
token := tok["token"].(string)
|
||||
if !strings.HasPrefix(token, "bsk_") {
|
||||
t.Fatalf("token %q", token)
|
||||
ses := e.signIn(alice) // matched by email, whatever the case
|
||||
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
|
||||
t.Fatalf("Alice: %d", w.Code)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 {
|
||||
t.Errorf("duplicate name: %d", resp.StatusCode)
|
||||
if c := e.signIn(map[string]any{"sub": "carol-sub", "preferred_username": "Carol"}); c == nil { // matched by username
|
||||
t.Fatal("Carol should get in")
|
||||
}
|
||||
if b, _ := json.Marshal(r.cfg.Get()); strings.Contains(string(b), token) {
|
||||
t.Error("the token itself must not be stored")
|
||||
}
|
||||
|
||||
// a script with only the token: no cookie, no CSRF header, plain text body
|
||||
api := &rig{t: t, srv: r.srv, cfg: r.cfg}
|
||||
resp, b := api.do("POST", "/api/notes", "text/plain", "call mum #family", "Authorization", "Bearer "+token)
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("capture: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
if !strings.Contains(string(b), `"body":"call mum #family"`) {
|
||||
t.Errorf("body %s", b)
|
||||
}
|
||||
if resp, _ := api.do("POST", "/api/notes", "text/plain", "x", "Authorization", "Bearer bsk_wrong"); resp.StatusCode != 401 {
|
||||
t.Errorf("wrong token: %d", resp.StatusCode)
|
||||
}
|
||||
// a token cannot mint more tokens
|
||||
if resp, _ := api.do("POST", "/api/tokens", "application/json", `{"name":"x"}`, "Authorization", "Bearer "+token); resp.StatusCode != 403 {
|
||||
t.Errorf("token making a token: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := r.js("GET", "/api/tokens", nil)
|
||||
_ = list
|
||||
if resp, _ := r.js("DELETE", "/api/tokens/phone", nil); resp.StatusCode != 200 {
|
||||
t.Errorf("revoke: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := api.do("GET", "/api/notes", "", "", "Authorization", "Bearer "+token); resp.StatusCode != 401 {
|
||||
t.Errorf("revoked token: %d", resp.StatusCode)
|
||||
// taking someone off the list ends their session at once
|
||||
e2 := setup(t, func(c map[string]any) { c["allowed"] = []string{"someone-else"} })
|
||||
old := seal(e2.cfg, "session", sessionData{Sub: "alice-sub", Email: "alice@example.com", Exp: time.Now().Add(time.Hour).Unix()})
|
||||
if w := e2.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: old}}); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a valid cookie for someone no longer allowed: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
old := r.cookie
|
||||
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": "wrong", "new": "a brand new password"}); resp.StatusCode != 403 {
|
||||
t.Errorf("wrong current: %d", resp.StatusCode)
|
||||
func TestSessionsAreSignedAndExpire(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
ses := e.signIn(alice)
|
||||
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
|
||||
t.Fatal("valid session refused")
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "short"}); resp.StatusCode != 400 {
|
||||
t.Errorf("short: %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "a brand new password"})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("change: %d", resp.StatusCode)
|
||||
}
|
||||
// the old cookie stops working, the new one (sent back in the response) works
|
||||
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 401 {
|
||||
t.Errorf("old cookie after a password change: %d", resp2.StatusCode)
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == cookieName {
|
||||
r.cookie = c
|
||||
payload, sig, _ := strings.Cut(ses[0].Value, ".")
|
||||
forged := base64.RawURLEncoding.EncodeToString([]byte(`{"s":"mallory","n":"Mallory","x":99999999999}`))
|
||||
for name, v := range map[string]string{"forged payload": forged + "." + sig, "no signature": payload, "empty": ""} {
|
||||
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s accepted: %d", name, w.Code)
|
||||
}
|
||||
}
|
||||
if r.cookie == old {
|
||||
t.Fatal("no new cookie")
|
||||
expired := seal(e.cfg, "session", sessionData{Sub: "alice-sub", Exp: time.Now().Add(-time.Minute).Unix()})
|
||||
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: expired}}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expired session accepted: %d", w.Code)
|
||||
}
|
||||
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 {
|
||||
t.Errorf("new cookie: %d", resp2.StatusCode)
|
||||
// a cookie for another purpose, or signed with another secret, is not a session
|
||||
other := e.cfg
|
||||
other.Secret = "different"
|
||||
for name, v := range map[string]string{
|
||||
"flow cookie as session": seal(e.cfg, "flow", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
|
||||
"other secret": seal(other, "session", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
|
||||
} {
|
||||
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s accepted: %d", name, w.Code)
|
||||
}
|
||||
}
|
||||
w := e.do("POST", "/api/logout", nil, ses)
|
||||
if c := cookieNamed(w, sessionCookie); w.Code != 200 || c == nil || c.MaxAge >= 0 {
|
||||
t.Fatalf("logout: %d %+v", w.Code, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExport(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
r.js("POST", "/api/notes", map[string]string{"body": "one"})
|
||||
r.js("POST", "/api/notes", map[string]string{"body": "two"})
|
||||
resp, b := r.do("GET", "/api/export", "", "")
|
||||
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Disposition"), "attachment") {
|
||||
t.Fatalf("export: %d %v", resp.StatusCode, resp.Header)
|
||||
func TestWritesNeedTheHeader(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
ses := e.signIn(alice)
|
||||
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a write without the header: %d", w.Code)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil || len(zr.File) != 2 {
|
||||
t.Fatalf("zip: %v, %d files", err, len(zr.File))
|
||||
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a delete without the header: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachPersonHasTheirOwnNotebook(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
a, b := e.signIn(alice), e.signIn(bob)
|
||||
var mine struct{ ID string }
|
||||
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Alice's secret", "body": "dear diary"}, a), &mine)
|
||||
if mine.ID == "" {
|
||||
t.Fatal("no note made")
|
||||
}
|
||||
if w := e.do("GET", "/api/notes/"+mine.ID, nil, b); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("Bob can see Alice's page: %d", w.Code)
|
||||
}
|
||||
var list struct{ Notes []struct{ ID string } }
|
||||
jsonOf(t, e.do("GET", "/api/notes", nil, b), &list)
|
||||
if len(list.Notes) != 0 {
|
||||
t.Fatalf("Bob's list: %+v", list)
|
||||
}
|
||||
var found struct{ Hits []struct{ ID string } }
|
||||
jsonOf(t, e.do("GET", "/api/find?q=diary", nil, b), &found)
|
||||
if len(found.Hits) != 0 {
|
||||
t.Fatalf("Bob can search Alice's notes: %+v", found)
|
||||
}
|
||||
if w := e.do("PUT", "/api/notes/"+mine.ID, map[string]string{"body": "hacked"}, b); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("Bob can edit Alice's page: %d", w.Code)
|
||||
}
|
||||
// on disk each has a folder named from a hash, never from anything they chose
|
||||
entries, _ := os.ReadDir(filepath.Join(e.dir, "data", "users"))
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected two notebooks, got %d", len(entries))
|
||||
}
|
||||
for _, d := range entries {
|
||||
if strings.Contains(d.Name(), "alice") || strings.Contains(d.Name(), "bob") || len(d.Name()) != 32 {
|
||||
t.Errorf("folder name %q", d.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageLifecycle(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
ses := e.signIn(alice)
|
||||
type pg struct {
|
||||
ID, Title, Name, Parent, Body, Updated string
|
||||
Backlinks []struct{ ID, Name string }
|
||||
Error, Code string
|
||||
Note struct{ Body string }
|
||||
}
|
||||
call := func(method, path string, body any) (pg, int) {
|
||||
w := e.do(method, path, body, ses)
|
||||
var p pg
|
||||
json.Unmarshal(w.Body.Bytes(), &p)
|
||||
return p, w.Code
|
||||
}
|
||||
|
||||
plans, code := call("POST", "/api/notes", map[string]string{"title": "Plans", "body": "first"})
|
||||
if code != 201 || plans.Name != "Plans" {
|
||||
t.Fatalf("create: %d %+v", code, plans)
|
||||
}
|
||||
if _, code := call("POST", "/api/notes", map[string]string{"title": "plans"}); code != 422 {
|
||||
t.Fatalf("duplicate title on create: %d", code)
|
||||
}
|
||||
trip, _ := call("POST", "/api/notes", map[string]string{"title": "Trip", "body": "see [[Plans]]", "parent": plans.ID})
|
||||
if trip.Parent != plans.ID {
|
||||
t.Fatalf("subpage: %+v", trip)
|
||||
}
|
||||
if _, code := call("POST", "/api/notes", map[string]string{}); code != 400 {
|
||||
t.Fatalf("empty create: %d", code)
|
||||
}
|
||||
|
||||
got, _ := call("GET", "/api/notes/"+plans.ID, nil)
|
||||
if len(got.Backlinks) != 1 || got.Backlinks[0].Name != "Trip" {
|
||||
t.Fatalf("backlinks: %+v", got.Backlinks)
|
||||
}
|
||||
|
||||
upd, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "second", "base": plans.Updated})
|
||||
if code != 200 || upd.Body != "second" {
|
||||
t.Fatalf("update: %d %+v", code, upd)
|
||||
}
|
||||
stale, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "third", "base": plans.Updated})
|
||||
if code != 409 || stale.Note.Body != "second" {
|
||||
t.Fatalf("stale update: %d %+v", code, stale)
|
||||
}
|
||||
bad, code := call("PUT", "/api/notes/"+trip.ID, map[string]any{"title": "PLANS"})
|
||||
if code != 422 || bad.Code != "title_taken" || !strings.HasPrefix(bad.Error, "Another") {
|
||||
t.Fatalf("taken title: %d %+v", code, bad)
|
||||
}
|
||||
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"title": "Plans 2027"}); code != 200 {
|
||||
t.Fatalf("rename: %d", code)
|
||||
}
|
||||
if moved, _ := call("GET", "/api/notes/"+trip.ID, nil); moved.Body != "see [[Plans 2027]]" {
|
||||
t.Fatalf("links follow a rename: %q", moved.Body)
|
||||
}
|
||||
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"parent": trip.ID}); code != 400 {
|
||||
t.Fatalf("a page inside its own child: %d", code)
|
||||
}
|
||||
|
||||
var found struct{ Hits []struct{ ID, Name string } }
|
||||
jsonOf(t, e.do("GET", "/api/find?q=2027", nil, ses), &found)
|
||||
if len(found.Hits) != 2 || found.Hits[0].ID != plans.ID { // Trip mentions it in a link, but the title match leads
|
||||
t.Fatalf("find: %+v", found)
|
||||
}
|
||||
var tree struct {
|
||||
Pages []struct{ ID, Parent string }
|
||||
Counts struct{ All, Trash int }
|
||||
}
|
||||
jsonOf(t, e.do("GET", "/api/pages", nil, ses), &tree)
|
||||
if len(tree.Pages) != 2 || tree.Counts.All != 2 {
|
||||
t.Fatalf("pages: %+v", tree)
|
||||
}
|
||||
|
||||
if _, code := call("DELETE", "/api/notes/"+plans.ID, nil); code != 200 {
|
||||
t.Fatalf("trash: %d", code)
|
||||
}
|
||||
if kid, _ := call("GET", "/api/notes/"+trip.ID, nil); kid.Parent != "" {
|
||||
t.Fatalf("subpages move up when their page is trashed: %+v", kid)
|
||||
}
|
||||
if _, code := call("DELETE", "/api/notes/"+trip.ID+"?purge=1", nil); code != 400 {
|
||||
t.Fatalf("purging a live page: %d", code)
|
||||
}
|
||||
if _, code := call("POST", "/api/notes/"+plans.ID+"/restore", nil); code != 200 {
|
||||
t.Fatalf("restore: %d", code)
|
||||
}
|
||||
if _, code := call("GET", "/api/notes/..%2f..%2fetc%2fpasswd", nil); code != 404 {
|
||||
t.Fatalf("path tricks: %d", code)
|
||||
}
|
||||
|
||||
d, code := call("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
|
||||
if code != 200 || d.Title != "2026-10-31" {
|
||||
t.Fatalf("daily: %d %+v", code, d)
|
||||
}
|
||||
if _, code := call("POST", "/api/daily", map[string]string{"date": "nope"}); code != 400 {
|
||||
t.Fatalf("bad date: %d", code)
|
||||
}
|
||||
|
||||
w := e.do("GET", "/api/export", nil, ses)
|
||||
zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||
if err != nil || w.Header().Get("Content-Type") != "application/zip" {
|
||||
t.Fatalf("export: %v %s", err, w.Header().Get("Content-Type"))
|
||||
}
|
||||
var names []string
|
||||
for _, f := range zr.File {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
if fmt.Sprint(names) != "[basking/Journal/2026-10-31.md basking/Plans 2027.md basking/Trip.md]" && len(names) != 3 {
|
||||
t.Fatalf("export has %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
w := e.do("GET", "/", nil, nil)
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
for _, want := range []string{"default-src 'none'", "script-src 'self'", "style-src 'self'", "frame-ancestors 'none'"} {
|
||||
if !strings.Contains(csp, want) {
|
||||
t.Errorf("CSP lacks %q: %s", want, csp)
|
||||
}
|
||||
}
|
||||
if w.Header().Get("X-Frame-Options") != "DENY" || w.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("framing and sniffing headers missing")
|
||||
}
|
||||
if w.Header().Get("Strict-Transport-Security") != "" {
|
||||
t.Error("no HSTS over plain http")
|
||||
}
|
||||
if got := e.do("GET", "/api/me", nil, nil).Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("API responses must not be cached: %q", got)
|
||||
}
|
||||
s := setup(t, func(c map[string]any) { c["public_url"] = "https://notes.example.com/" })
|
||||
if s.do("GET", "/", nil, nil).Header().Get("Strict-Transport-Security") == "" {
|
||||
t.Error("HSTS expected behind https")
|
||||
}
|
||||
if !s.cfg.Secure() || s.cfg.PublicURL != "https://notes.example.com" {
|
||||
t.Errorf("public url: %+v", s.cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerWillNotStartHalfConfigured(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "basking.json")
|
||||
os.WriteFile(path, []byte(`{"public_url":"https://notes.example.com"}`), 0o600)
|
||||
h, _ := config.Load(path)
|
||||
_, err := New(h, "t")
|
||||
if err == nil {
|
||||
t.Fatal("started without Authentik settings")
|
||||
}
|
||||
for _, want := range []string{"oidc.issuer", "BASKING_OIDC_CLIENT_ID", "client_secret", "/auth/callback"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the message should mention %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
os.WriteFile(path, []byte(`{"public_url":"http://notes.example.com","oidc":{"issuer":"https://a.example.com/","client_id":"x","client_secret":"y"}}`), 0o600)
|
||||
h, _ = config.Load(path)
|
||||
if _, err := New(h, "t"); err == nil || !strings.Contains(err.Error(), "https") {
|
||||
t.Errorf("plain http on a real host should be refused: %v", err)
|
||||
}
|
||||
t.Setenv("BASKING_PUBLIC_URL", "https://notes.example.com")
|
||||
h, _ = config.Load(path)
|
||||
if _, err := New(h, "t"); err != nil {
|
||||
t.Errorf("environment settings should override the file: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); strings.Contains(string(b), "notes.example.com\"") && strings.Contains(string(b), "https://notes.example.com") {
|
||||
t.Error("environment values must never be written to the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailCapitalizes(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
fail(w, 400, "écrit mal")
|
||||
if !strings.Contains(w.Body.String(), "Écrit mal") {
|
||||
t.Errorf("%s", w.Body.String())
|
||||
fail(w, 400, "éclair is ready")
|
||||
var out map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &out)
|
||||
if out["error"] != "Éclair is ready" {
|
||||
t.Fatalf("%q", out["error"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user