Merge pull request #94 from kristianwind/to-udgaver-af-samme-program
feat(udgaver): VERDANDE_EDITION afgør, om instansen er et opgaveprogram
This commit is contained in:
@@ -38,6 +38,28 @@ type Config struct {
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// Edition decides which half of the program this instance serves.
|
||||
//
|
||||
// "full" is tasks, projects and notes together — what Verdande has always
|
||||
// been. "notes" mounts the notes half and the base underneath it, and leaves
|
||||
// the task routes unmounted: not hidden in the interface, absent from the
|
||||
// router, so an instance that is not a task manager cannot be asked to be one
|
||||
// through its API.
|
||||
//
|
||||
// Notes already stand nearly alone — they reference no task type anywhere in
|
||||
// the store, and the one link between the halves is a `note_links` row whose
|
||||
// kind happens to be "task". What is NOT separable is everything underneath
|
||||
// both: accounts, sessions, invites, sharing, notifications, attachments,
|
||||
// search. Hence one program with two faces rather than two programs: the
|
||||
// alternative is two copies of the base, and two copies of a thing is how the
|
||||
// copies start to disagree.
|
||||
//
|
||||
// Projects stay mounted in both. In the notes edition a project is a
|
||||
// notebook — the same column, the same sharing, a different word — because a
|
||||
// second grouping concept beside `note_shares` would be a second thing to keep
|
||||
// in step for a difference nobody can see.
|
||||
Edition string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
@@ -136,6 +158,7 @@ func Load() (*Config, error) {
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
@@ -171,6 +194,19 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
|
||||
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
|
||||
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
|
||||
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
|
||||
// som ikke burde.
|
||||
switch c.Edition {
|
||||
case EditionFull, EditionNotes:
|
||||
case "":
|
||||
c.Edition = EditionFull
|
||||
default:
|
||||
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
// The two faces of the program. Strings rather than a bool, because a bool named
|
||||
// `NotesOnly` answers one question and this one will be asked again — and a
|
||||
// third edition would mean changing every reading of it rather than adding a case.
|
||||
const (
|
||||
EditionFull = "full"
|
||||
EditionNotes = "notes"
|
||||
)
|
||||
|
||||
// NotesOnly is the one question the router asks. Written as a method so the
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
|
||||
// edition. That is the silent direction of this fault: an instance set up to be a
|
||||
// notes program would serve every task route, and the only way to notice would be
|
||||
// to spot that something works which should not.
|
||||
func TestUnknownEditionIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string // the edition we expect, or "" when Load must fail
|
||||
}{
|
||||
{"", EditionFull}, // unset is the full program, as it has always been
|
||||
{EditionFull, EditionFull},
|
||||
{EditionNotes, EditionNotes},
|
||||
{"note", ""}, // a letter short
|
||||
{"Notes", ""}, // the right word, the wrong case
|
||||
{"none", ""}, // plausible and meaning nothing
|
||||
} {
|
||||
t.Setenv("VERDANDE_EDITION", tc.value)
|
||||
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
|
||||
|
||||
got, err := Load()
|
||||
if tc.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
|
||||
continue
|
||||
}
|
||||
if got.Edition != tc.want {
|
||||
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
|
||||
}
|
||||
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
|
||||
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The notes edition is a different program, and this is what makes that true.
|
||||
//
|
||||
// A gate in a route tree is the easiest thing in the world to believe and the
|
||||
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
|
||||
// exists, and nothing ever asks whether it covers the routes it was written for.
|
||||
// So this does not check that a gate is *present*. It walks both routers and
|
||||
// compares the two sets of routes they actually serve.
|
||||
//
|
||||
// The difference is written down below. A route added later that differs between
|
||||
// the editions — in either direction — fails here until somebody says which half
|
||||
// it belongs to. That is the point: the list is the decision, and the walk is
|
||||
// what keeps the decision honest.
|
||||
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
full := routesOf(t, config.EditionFull)
|
||||
notes := routesOf(t, config.EditionNotes)
|
||||
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/upcoming",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
"POST /api/v1/tasks/{taskID}/comments",
|
||||
"POST /api/v1/tasks/{taskID}/complete",
|
||||
"POST /api/v1/tasks/{taskID}/move",
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
|
||||
var unexpected, stillThere []string
|
||||
for r := range full {
|
||||
if notes[r] {
|
||||
if withheld[r] {
|
||||
stillThere = append(stillThere, r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !withheld[r] {
|
||||
unexpected = append(unexpected, r)
|
||||
}
|
||||
}
|
||||
// And the other direction: the notes edition must not grow a route the full
|
||||
// one lacks. A gate that adds is not a gate either.
|
||||
var onlyInNotes []string
|
||||
for r := range notes {
|
||||
if !full[r] {
|
||||
onlyInNotes = append(onlyInNotes, r)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(unexpected)
|
||||
sort.Strings(stillThere)
|
||||
sort.Strings(onlyInNotes)
|
||||
for _, r := range unexpected {
|
||||
t.Errorf("withheld from the notes edition but not written down here: %s", r)
|
||||
}
|
||||
for _, r := range stillThere {
|
||||
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
|
||||
}
|
||||
for _, r := range onlyInNotes {
|
||||
t.Errorf("served only by the notes edition: %s", r)
|
||||
}
|
||||
|
||||
// Printed on the green as well as the red. "The editions differ correctly" over
|
||||
// zero withheld routes and over twenty-nine are the same pass, and only one of
|
||||
// them means anything — so the number is on screen either way.
|
||||
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
|
||||
|
||||
// The half that stops this being a deletion rather than a gate: what the notes
|
||||
// edition is FOR has to still be there.
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routesOf builds a server at one edition and returns every route it serves.
|
||||
func routesOf(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
srv := New(&config.Config{
|
||||
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
|
||||
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
out := map[string]bool{}
|
||||
err = chi.Walk(srv.router,
|
||||
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if route != "/" {
|
||||
route = strings.TrimSuffix(route, "/")
|
||||
}
|
||||
out[method+" "+route] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
if len(out) < 50 {
|
||||
// A walk that found almost nothing would make every comparison below pass.
|
||||
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -285,6 +285,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
// Everything past this point needs a complete login.
|
||||
r.Group(func(r chi.Router) {
|
||||
// Opgavehalvdelen monteres kun i den fulde udgave. Ikke skjult i
|
||||
// fladen — fraværende fra routeren, så en instans, der ikke er et
|
||||
// opgaveprogram, heller ikke kan bedes om at være det gennem sit API.
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
@@ -294,8 +298,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/mcp", s.handleMCP)
|
||||
|
||||
r.Get("/today", s.handleToday)
|
||||
if full {
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
@@ -303,6 +310,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
|
||||
@@ -468,8 +476,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
|
||||
if full {
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
@@ -478,6 +489,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
}
|
||||
r.Get("/people", s.handlePeople)
|
||||
r.Get("/search", s.handleSearch)
|
||||
|
||||
@@ -583,6 +595,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
|
||||
if full {
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTasks)
|
||||
r.Post("/", s.handleCreateTask)
|
||||
@@ -604,6 +617,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user