- Daily database backups: jarl-backup.timer runs 'jarld backup', which writes jarl-<UTC time>.db into backup.dir, checks it before naming it, and prunes by the time in the name while always keeping the newest keep_at_least. New backup: section in jarl.yaml; the dashboard and 'jarl status' show the newest backup and warn when it is stale or failed. The Debian package enables the timer. - 'jarld restore [FILE | latest]': checks the backup, shows it next to the current database, keeps the current one aside and records the restore. 'jarld serve' holds a database lock, so a restore can't run under a live jarld and two jarlds can't share a database. - Token expiry warnings from 14 days ahead: dashboard, token lists, a daily log line, and a CLI warning via the Jarl-Token-Expires header; 'jarl token list --expiring'. - 'jarl zone serials' and the zone page's Name servers tab: compare the serial on a primary zone's secondaries (NS records, also-notify) or a secondary zone's primaries with this server's. - 'jarl zone delegation': for zones on the internet, check the parent's delegation and glue against the zone; an undelegated zone gets a note. - Server: Jarl/<version> header; CLI exit code 2 for failed checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
4.5 KiB
Go
118 lines
4.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/store"
|
|
)
|
|
|
|
// header sends a GET with a token and returns a response header.
|
|
func (e *apiEnv) header(path, token, name string) string {
|
|
e.t.Helper()
|
|
r, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(r)
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
e.t.Fatalf("GET %s = %d", path, resp.StatusCode)
|
|
}
|
|
return resp.Header.Get(name)
|
|
}
|
|
|
|
func TestTokenExpiryWarnings(t *testing.T) {
|
|
e := newAPIEnv(t)
|
|
e.do("POST", "/users", e.token, map[string]any{"username": "certbot", "password": "", "grant_strings": []string{"records:write"}}, 201)
|
|
var uid int64
|
|
for _, u := range e.list("/users", e.token) {
|
|
if u["username"] == "certbot" {
|
|
uid = int64(u["id"].(float64))
|
|
}
|
|
}
|
|
soon := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "soon", "expires_in": "5d"}, 201)
|
|
later := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "later", "expires_in": "90d"}, 201)
|
|
revoked := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "gone", "expires_in": "1d"}, 201)
|
|
e.do("DELETE", "/tokens/"+itoa(int64(revoked["id"].(float64))), e.token, nil, 204)
|
|
|
|
// The dashboard lists the expiring token for the administrator...
|
|
names := func(token string) []string {
|
|
var out []string
|
|
for _, t := range e.do("GET", "/status", token, nil, 200)["expiring_tokens"].([]any) {
|
|
out = append(out, t.(map[string]any)["name"].(string))
|
|
}
|
|
return out
|
|
}
|
|
if got := names(e.token); len(got) != 1 || got[0] != "soon" {
|
|
t.Errorf("admin's expiring tokens = %v", got)
|
|
}
|
|
// ...and for its own user.
|
|
if got := names(soon["token"].(string)); len(got) != 1 || got[0] != "soon" {
|
|
t.Errorf("certbot's expiring tokens = %v", got)
|
|
}
|
|
|
|
// Requests with the expiring token say so (the CLI warns); others don't.
|
|
if h := e.header("/auth/whoami", soon["token"].(string), api.TokenExpiresHeader); h == "" {
|
|
t.Error("no expiry header for a token that expires in 5 days")
|
|
} else if exp, err := time.Parse(time.RFC3339, h); err != nil || time.Until(exp) < 4*24*time.Hour {
|
|
t.Errorf("expiry header %q", h)
|
|
}
|
|
if h := e.header("/auth/whoami", later["token"].(string), api.TokenExpiresHeader); h != "" {
|
|
t.Errorf("expiry header for a 90-day token: %q", h)
|
|
}
|
|
if h := e.header("/auth/whoami", e.token, api.TokenExpiresHeader); h != "" {
|
|
t.Errorf("expiry header for a token without expiry: %q", h)
|
|
}
|
|
}
|
|
|
|
func TestBackupStatus(t *testing.T) {
|
|
e := newAPIEnvConfig(t, Config{Version: "test", BackupDir: "/var/backups/jarl", BackupWarnAfter: 48 * time.Hour})
|
|
backup := func() map[string]any {
|
|
b, _ := e.do("GET", "/status", e.token, nil, 200)["backup"].(map[string]any)
|
|
return b
|
|
}
|
|
if b := backup(); b == nil || b["last_at"] != nil || b["stale"] != false || b["dir"] != "/var/backups/jarl" {
|
|
t.Errorf("before any backup: %v", b)
|
|
}
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-3 * time.Hour), File: "/var/backups/jarl/jarl-x.db", Size: 1234})
|
|
if b := backup(); b["last_file"] != "/var/backups/jarl/jarl-x.db" || b["stale"] != false || b["last_error"] != nil {
|
|
t.Errorf("fresh backup: %v", b)
|
|
}
|
|
// A failure newer than the last success is reported; an older one isn't.
|
|
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-time.Hour), Error: "disk full"})
|
|
if b := backup(); b["last_error"] != "disk full" {
|
|
t.Errorf("failed backup: %v", b)
|
|
}
|
|
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-72 * time.Hour), File: "/var/backups/jarl/jarl-y.db"})
|
|
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-80 * time.Hour), Error: "old"})
|
|
if b := backup(); b["stale"] != true || b["last_error"] != nil {
|
|
t.Errorf("old backup: %v", b)
|
|
}
|
|
// Only administrators see it.
|
|
e.do("POST", "/users", e.token, map[string]any{"username": "viewer", "password": "", "role": "readonly"}, 201)
|
|
var uid int64
|
|
for _, u := range e.list("/users", e.token) {
|
|
if u["username"] == "viewer" {
|
|
uid = int64(u["id"].(float64))
|
|
}
|
|
}
|
|
tok := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
|
if st := e.do("GET", "/status", tok, nil, 200); st["backup"] != nil {
|
|
t.Errorf("non-admin sees backups: %v", st["backup"])
|
|
}
|
|
}
|
|
|
|
func TestServerHeader(t *testing.T) {
|
|
e := newAPIEnv(t)
|
|
if h := e.header("/auth/whoami", e.token, "Server"); h != "Jarl/test" {
|
|
t.Errorf("Server: %q", h)
|
|
}
|
|
}
|