9029d48584
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
119 lines
3.5 KiB
Go
119 lines
3.5 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. 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 + "'"
|
|
}
|