b0a02c010b
Closes #5. Three of the server's tunables were environment variables, which meant changing how long an incident waits before being paged again required editing a chart, merging it and waiting for a reconcile. They are behaviour rather than infrastructure, and the difference is who needs to change them and how often. The split is by who owns the value. What stays in the environment is where the server is plugged in: the listen address, the DSN, the ntfy URL and token, the public URL. Those are needed before the database is open and two of them are credentials -- the settings endpoint reports that ntfy is configured and that a token is set, and never what either is. What moves is how it behaves: the notify repeat interval, the stale window and the archive window. The environment variable becomes the seed rather than the setting, written once on first start and never overwritten, so a redeploy cannot put a chart's default back over an administrator's edit -- the rule the per-team dead man's switches already follow. The loops read the current value per tick, so a change at 02:00 is obeyed at 02:00. Key/value rather than a column per knob: #6 and #7 will both add settings, and a table shaped one-column-per-setting needs a migration for each. The cost is that values are text and the accessor has to say what type it wanted, which settings.go does in one place. Unknown keys are refused rather than stored -- a typo that wrote notify_repeat_second would otherwise sit in the table looking like configuration and doing nothing -- and each value has bounds loose enough to catch a slipped decimal point without having an opinion about anybody's rota. Disabling an account is new, and is not deleting one. Deleting a user nulls acknowledged_by and assigned_to, which quietly rewrites who did what during an incident months after the fact. A disabled user cannot authenticate by either credential, loses their sessions immediately, and stays the name on every acknowledgement they made. The check is part of the lookup in serveAs rather than a test afterwards, so there is no path where the row is loaded and the flag is then forgotten. The page itself is a fourth tab, shown only to an administrator and only as a courtesy: every endpoint under it is refused with 403 regardless, so somebody who types /admin gets an explanation rather than a blank screen. It lists teams with their size and open-incident count, users with their flags, and the settings with their bounds -- plus the environment half, read-only, so somebody hunting for the ntfy URL learns where it lives instead of concluding the server has none. Delete is disabled rather than offered-and-refused for a team with open incidents, and neither admin action is offered on your own account, since the server refuses both. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package api_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
|
)
|
|
|
|
// Tests run against a real Postgres, because the server does. SQLite's
|
|
// ":memory:" gave every test a private database for free; Postgres has no
|
|
// equivalent, so isolation is bought with a schema per test.
|
|
//
|
|
// A schema rather than a database: CREATE DATABASE copies a template on disk and
|
|
// costs a hundred milliseconds or so each time, while CREATE SCHEMA plus the one
|
|
// baseline migration is a few, and the suite runs a few hundred of them. Each
|
|
// test's pool is pinned to its own schema through search_path, so two tests
|
|
// cannot see each other's rows even though they share a server.
|
|
//
|
|
// TERDUT_TEST_DSN must point at a database the test role may create schemas in:
|
|
//
|
|
// postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable
|
|
//
|
|
// `make test-db` starts one locally; ci.yaml runs one as a service container.
|
|
// An unset DSN fails rather than skips, deliberately — a suite that quietly
|
|
// tests nothing is worse than one that does not run.
|
|
const testDSNEnv = "TERDUT_TEST_DSN"
|
|
|
|
// testConfig is the environment half of the server's configuration, which the
|
|
// admin settings page renders read-only and SeedSettings seeds the editable
|
|
// half from. The durations match the defaults config.Load would produce, so a
|
|
// test that never touches the settings table behaves as a fresh install does.
|
|
func testConfig() config.Config {
|
|
return config.Config{
|
|
Addr: ":8080",
|
|
ArchiveAfter: 7 * 24 * time.Hour,
|
|
StaleAfter: 6 * time.Hour,
|
|
NotifyRepeat: 15 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
|
|
// a path segment. Every test that does not say otherwise works inside it.
|
|
const defaultTeam = "1"
|
|
|
|
var schemaSeq int
|
|
|
|
// newTestDB returns a migrated database private to this test, and drops it
|
|
// afterwards.
|
|
func newTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
dsn := os.Getenv(testDSNEnv)
|
|
if dsn == "" {
|
|
t.Fatalf("%s is not set: these tests need Postgres.\n"+
|
|
"Run `make test-db` for a local one, then\n"+
|
|
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
|
|
testDSNEnv, testDSNEnv)
|
|
}
|
|
|
|
schemaSeq++
|
|
schema := fmt.Sprintf("test_%d_%d", os.Getpid(), schemaSeq)
|
|
|
|
admin, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect to %s: %v", testDSNEnv, err)
|
|
}
|
|
defer admin.Close()
|
|
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
|
t.Fatalf("create schema %s: %v", schema, err)
|
|
}
|
|
|
|
database, err := db.Open(withSearchPath(dsn, schema))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := db.Migrate(database); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
database.Close()
|
|
cleanup, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer cleanup.Close()
|
|
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
|
t.Logf("drop schema %s: %v", schema, err)
|
|
}
|
|
})
|
|
|
|
return database
|
|
}
|
|
|
|
// withSearchPath pins a DSN to one schema, so every connection the pool opens
|
|
// lands there and nothing has to qualify a table name.
|
|
//
|
|
// Handles both DSN spellings: a postgres:// URL, and libpq's keyword/value form.
|
|
func withSearchPath(dsn, schema string) string {
|
|
opt := "-csearch_path=" + schema
|
|
|
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
|
u, err := url.Parse(dsn)
|
|
if err == nil {
|
|
q := u.Query()
|
|
q.Set("options", opt)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
}
|
|
return dsn + " options='" + opt + "'"
|
|
}
|