ci / Web (lint, test, audit, build) (push) Successful in 1m1s
ci / Shell and licence headers (push) Successful in 7s
ci / Go (lint, test, vuln) (push) Failing after 2m40s
ci / Protobuf (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 2m27s
ci / End-to-end (server, agents, BIND, Kea) (push) Failing after 1m26s
ci / Development environment (compose) (push) Successful in 13s
ci / Integration (real BIND and Kea) (push) Failing after 1m58s
test/e2e runs server, PostgreSQL, BIND and Kea with both agents in containers (make test-e2e, CI job End-to-end): enrollment with the command from the API, revision 1 and 2 applied and served by BIND and Kea, unchanged data creates no revision, revoking disconnects the agent while BIND keeps serving. Fixes from the first CI run: quote a spec description containing a comma, exempt the agent lock-out routes in the route protection test, whole-second certificate lifetimes, a push replacing queued pulls, the truncation marker containing the letter checked, and curl|grep -q under pipefail in the server smoke test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
23 KiB
Go
590 lines
23 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"encoding/base32"
|
|
"net/http"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
)
|
|
|
|
// TestSpecMatchesRoutes: every operation in the spec is routed and vice versa.
|
|
func TestSpecMatchesRoutes(t *testing.T) {
|
|
doc, _ := loadSpec(t)
|
|
var inSpec []string
|
|
for path, item := range doc.Paths.Map() {
|
|
for method := range item.Operations() {
|
|
inSpec = append(inSpec, method+" /api/v1"+path)
|
|
}
|
|
}
|
|
var inRoutes []string
|
|
for _, r := range (&API{}).Routes() {
|
|
inRoutes = append(inRoutes, r.Method+" "+r.Path)
|
|
}
|
|
sort.Strings(inSpec)
|
|
sort.Strings(inRoutes)
|
|
for _, s := range inSpec {
|
|
if !slices.Contains(inRoutes, s) {
|
|
t.Errorf("in spec but not routed: %s", s)
|
|
}
|
|
}
|
|
for _, r := range inRoutes {
|
|
if !slices.Contains(inSpec, r) {
|
|
t.Errorf("routed but not in spec: %s", r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryRouteIsProtected pins the list of routes reachable without a
|
|
// session and checks that permission routes name a permission.
|
|
func TestEveryRouteIsProtected(t *testing.T) {
|
|
// Locking agents out is a security function that read-only mode must
|
|
// not block (routes.go).
|
|
lockOutRoutes := []string{
|
|
"DELETE /api/v1/agents/{id}",
|
|
"POST /api/v1/agents/{id}/revoke",
|
|
"DELETE /api/v1/enrollment-tokens/{id}",
|
|
}
|
|
publicAllowed := []string{
|
|
"GET /api/v1/system/info",
|
|
"POST /api/v1/auth/login",
|
|
"GET /api/v1/auth/oidc/start",
|
|
"GET /api/v1/auth/oidc/callback",
|
|
}
|
|
for _, r := range (&API{}).Routes() {
|
|
key := r.Method + " " + r.Path
|
|
switch r.Access {
|
|
case public:
|
|
if !slices.Contains(publicAllowed, key) {
|
|
t.Errorf("%s is public but not allowlisted", key)
|
|
}
|
|
case permission:
|
|
if r.Perm == "" {
|
|
t.Errorf("%s has no permission", key)
|
|
}
|
|
}
|
|
if r.Write && r.Method == http.MethodGet {
|
|
t.Errorf("%s: GET routes must not be write routes", key)
|
|
}
|
|
if r.Method != http.MethodGet && !r.Write && r.Access == permission && !strings.Contains(r.Path, "/license") &&
|
|
!strings.Contains(r.Path, "/saved-filters") && !strings.HasSuffix(r.Path, "/preview") &&
|
|
!slices.Contains(lockOutRoutes, key) {
|
|
t.Errorf("%s changes state but is not marked Write (read-only mode would not block it)", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoginFlowAndPasswordChange(t *testing.T) {
|
|
e := newEnv(t)
|
|
pw, err := e.svc.BootstrapAdmin(t.Context(), "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := e.svc.BootstrapAdmin(t.Context(), "admin2"); err == nil {
|
|
t.Error("second bootstrap must fail")
|
|
}
|
|
c := e.client()
|
|
|
|
r := c.mustStatus(401, "POST", "/api/v1/auth/login", loginRequest{Username: "admin", Password: "wrong-password!"})
|
|
if r.problem(t).Code != "invalid_credentials" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
r = c.mustStatus(401, "POST", "/api/v1/auth/login", loginRequest{Username: "nobody", Password: "wrong-password!"})
|
|
if r.problem(t).Code != "invalid_credentials" {
|
|
t.Error("unknown users must look like wrong passwords")
|
|
}
|
|
|
|
s := c.login("admin", pw)
|
|
if !s.User.MustChangePassword || s.User.Role != "admin" || len(s.Permissions) == 0 {
|
|
t.Fatalf("session %+v", s)
|
|
}
|
|
r = c.mustStatus(403, "GET", "/api/v1/networks", nil)
|
|
if r.problem(t).Code != "password_change_required" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
r = c.mustStatus(422, "POST", "/api/v1/auth/password", passwordChangeRequest{CurrentPassword: pw, NewPassword: "short"})
|
|
if r.problem(t).Problems[0].Code != "password_too_short" {
|
|
t.Errorf("problems %+v", r.problem(t).Problems)
|
|
}
|
|
c.mustStatus(204, "POST", "/api/v1/auth/password", passwordChangeRequest{CurrentPassword: pw, NewPassword: "a-new-password-1"})
|
|
c.mustStatus(200, "GET", "/api/v1/networks", nil)
|
|
|
|
c.mustStatus(204, "POST", "/api/v1/auth/logout", nil)
|
|
r = c.mustStatus(401, "GET", "/api/v1/networks", nil)
|
|
if r.problem(t).Code != "unauthenticated" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
c.csrf = ""
|
|
c.login("admin", "a-new-password-1")
|
|
|
|
// Idle sessions expire.
|
|
e.advance(2 * time.Hour)
|
|
c.mustStatus(401, "GET", "/api/v1/auth/session", nil)
|
|
}
|
|
|
|
func TestCSRFAndOrigin(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
body := map[string]any{"cidr": "192.0.2.0/24", "name": "x"}
|
|
|
|
token := c.csrf
|
|
c.csrf = ""
|
|
r := c.mustStatus(403, "POST", "/api/v1/networks", body)
|
|
if r.problem(t).Code != "csrf" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
c.csrf = "wrong"
|
|
c.mustStatus(403, "POST", "/api/v1/networks", body)
|
|
c.csrf = token
|
|
|
|
r = c.mustStatus(403, "POST", "/api/v1/networks", body, "Origin", "https://evil.example")
|
|
if r.problem(t).Code != "cross_origin" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
c.mustStatus(403, "POST", "/api/v1/networks", body, "Sec-Fetch-Site", "cross-site")
|
|
c.mustStatus(201, "POST", "/api/v1/networks", body, "Origin", e.srv.URL)
|
|
}
|
|
|
|
func TestLoginRateLimit(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.adminClient()
|
|
c := e.client()
|
|
var last *response
|
|
for range 6 {
|
|
last = c.do("POST", "/api/v1/auth/login", loginRequest{Username: "admin", Password: "wrong-password!"})
|
|
}
|
|
if last.Status != http.StatusTooManyRequests || last.Header.Get("Retry-After") == "" {
|
|
t.Fatalf("6th attempt: %d %s", last.Status, last.Body)
|
|
}
|
|
}
|
|
|
|
func TestRoles(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
operator := e.userClient(admin, "otto", "operator")
|
|
net := map[string]any{"cidr": "192.0.2.0/24", "name": "Office"}
|
|
|
|
viewer.mustStatus(200, "GET", "/api/v1/networks", nil)
|
|
r := viewer.mustStatus(403, "POST", "/api/v1/networks", net)
|
|
if r.problem(t).Code != "forbidden" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
viewer.mustStatus(403, "GET", "/api/v1/users", nil)
|
|
viewer.mustStatus(403, "GET", "/api/v1/audit", nil)
|
|
viewer.mustStatus(200, "GET", "/api/v1/license", nil)
|
|
viewer.mustStatus(403, "PUT", "/api/v1/license", licenseKeyRequest{Key: "X"})
|
|
|
|
operator.mustStatus(201, "POST", "/api/v1/networks", net)
|
|
operator.mustStatus(403, "GET", "/api/v1/users", nil)
|
|
operator.mustStatus(403, "PUT", "/api/v1/settings/oidc", map[string]any{}, skipRequestValidation, "1")
|
|
}
|
|
|
|
func TestNetworksAndAddresses(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
|
|
r := c.mustStatus(201, "POST", "/api/v1/networks", map[string]any{
|
|
"cidr": "192.0.2.0/24", "name": "Office", "gateway": "192.0.2.1", "domain": "Office.Example.TEST",
|
|
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
|
|
"dns_servers": []string{"192.0.2.53"}, "lease_time": 3600, "tags": []string{"HQ"},
|
|
})
|
|
var n service.NetworkDTO
|
|
r.json(t, &n)
|
|
if n.Domain != "office.example.test" || n.Utilization == nil || n.Utilization.Total != 254 || n.Version != 1 {
|
|
t.Fatalf("network %+v", n)
|
|
}
|
|
|
|
r = c.mustStatus(409, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.128/25", "name": "Overlap"})
|
|
if r.problem(t).Code != "network_overlap" {
|
|
t.Errorf("overlap code %s", r.problem(t).Code)
|
|
}
|
|
r = c.mustStatus(422, "POST", "/api/v1/networks", map[string]any{"cidr": "198.51.100.0/24", "name": "Bad", "gateway": "not-an-ip"},
|
|
skipRequestValidation, "1")
|
|
if p := r.problem(t).Problems; len(p) != 1 || p[0].Field != "gateway" || p[0].Code != "invalid_ip" {
|
|
t.Errorf("gateway problems %+v", p)
|
|
}
|
|
r = c.mustStatus(422, "POST", "/api/v1/networks", map[string]any{"cidr": "198.51.100.0/24", "name": "Bad", "gateway": "203.0.113.1"})
|
|
if p := r.problem(t).Problems; len(p) != 1 || p[0].Code != "ip_outside_network" {
|
|
t.Errorf("gateway outside problems %+v", p)
|
|
}
|
|
c.mustStatus(422, "POST", "/api/v1/networks", map[string]any{"cidr": "x", "name": "Bad", "extra": 1}, skipRequestValidation, "1")
|
|
|
|
// Addresses.
|
|
r = c.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.10", "type": "static", "hostname": "Web"})
|
|
var web service.AddressDTO
|
|
r.json(t, &web)
|
|
if web.FQDN != "web.office.example.test" || web.NetworkName != "Office" {
|
|
t.Fatalf("address %+v", web)
|
|
}
|
|
r = c.mustStatus(422, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.11", "type": "manual_dhcp"})
|
|
if p := r.problem(t).Problems; len(p) != 1 || p[0].Code != "mac_required" || p[0].Field != "mac" {
|
|
t.Errorf("mac problems %+v", p)
|
|
}
|
|
r = c.mustStatus(409, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.12", "type": "static", "hostname": "WEB"})
|
|
if r.problem(t).Problems[0].Code != "hostname_taken" {
|
|
t.Errorf("hostname dup %+v", r.problem(t))
|
|
}
|
|
r = c.mustStatus(422, "POST", "/api/v1/addresses", map[string]any{"ip": "203.0.113.5", "type": "static"})
|
|
if r.problem(t).Problems[0].Code != "no_network_for_ip" {
|
|
t.Errorf("no network %+v", r.problem(t))
|
|
}
|
|
|
|
r = c.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.150", "type": "dynamic", "mac": "00-11-22-33-44-55", "hostname": "laptop"})
|
|
var dyn service.AddressDTO
|
|
r.json(t, &dyn)
|
|
|
|
// Preview of the type switch dynamic → manual_dhcp.
|
|
r = c.mustStatus(200, "POST", "/api/v1/addresses/preview", map[string]any{"id": dyn.ID, "address": map[string]any{
|
|
"ip": "192.0.2.150", "type": "manual_dhcp", "mac": "00:11:22:33:44:55", "hostname": "laptop"}})
|
|
var pv struct {
|
|
Effects []struct{ Code string } `json:"effects"`
|
|
}
|
|
r.json(t, &pv)
|
|
var codes []string
|
|
for _, ef := range pv.Effects {
|
|
codes = append(codes, ef.Code)
|
|
}
|
|
if !slices.Equal(codes, []string{"dns.add_records", "dhcp.add_reservation", "dhcp.pool_exclude"}) {
|
|
t.Errorf("preview %v", codes)
|
|
}
|
|
|
|
upd := map[string]any{"ip": "192.0.2.150", "type": "manual_dhcp", "mac": "00:11:22:33:44:55", "hostname": "laptop", "version": dyn.Version}
|
|
r = c.mustStatus(200, "PUT", "/api/v1/addresses/"+dyn.ID, upd)
|
|
var res service.AddressDTO
|
|
r.json(t, &res)
|
|
if res.Type != "manual_dhcp" || res.Version != dyn.Version+1 {
|
|
t.Errorf("updated %+v", res)
|
|
}
|
|
r = c.mustStatus(409, "PUT", "/api/v1/addresses/"+dyn.ID, upd)
|
|
if r.problem(t).Code != "version_conflict" {
|
|
t.Errorf("stale update %s", r.problem(t).Code)
|
|
}
|
|
|
|
// Table query.
|
|
r = c.mustStatus(200, "GET", "/api/v1/addresses?type=static,manual_dhcp&sort=hostname&order=desc&limit=1", nil)
|
|
var page service.AddressPage
|
|
r.json(t, &page)
|
|
if page.Total != 2 || len(page.Items) != 1 || page.Items[0].Hostname != "web" {
|
|
t.Errorf("page %+v", page)
|
|
}
|
|
c.mustStatus(422, "GET", "/api/v1/addresses?sort=bogus", nil, skipRequestValidation, "1")
|
|
r = c.mustStatus(200, "GET", "/api/v1/addresses?q=00-11-22", nil)
|
|
r.json(t, &page)
|
|
if page.Total != 1 {
|
|
t.Errorf("MAC search: %d", page.Total)
|
|
}
|
|
|
|
// Network detail, map, update with stale version.
|
|
r = c.mustStatus(200, "GET", "/api/v1/networks/"+n.ID, nil)
|
|
r.json(t, &n)
|
|
if n.Utilization.ByType["static"] != 1 || n.Utilization.PoolEffective != 99 {
|
|
t.Errorf("utilization %+v", n.Utilization)
|
|
}
|
|
r = c.mustStatus(200, "GET", "/api/v1/networks/"+n.ID+"/map", nil)
|
|
var m struct {
|
|
Cells []struct{ IP, State string } `json:"cells"`
|
|
}
|
|
r.json(t, &m)
|
|
if len(m.Cells) != 256 || m.Cells[1].State != "gateway" || m.Cells[10].State != "static" || m.Cells[150].State != "manual_dhcp" {
|
|
t.Errorf("map cells: %d %+v %+v", len(m.Cells), m.Cells[1], m.Cells[10])
|
|
}
|
|
upNet := map[string]any{"name": "Office", "domain": "corp.test", "gateway": "192.0.2.1", "dhcp_enabled": true,
|
|
"pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}}, "version": n.Version}
|
|
c.mustStatus(200, "PUT", "/api/v1/networks/"+n.ID, upNet)
|
|
r = c.mustStatus(200, "GET", "/api/v1/addresses/"+web.ID, nil)
|
|
r.json(t, &web)
|
|
if web.FQDN != "web.corp.test" {
|
|
t.Errorf("fqdn after domain change: %s", web.FQDN)
|
|
}
|
|
// Shrinking the pool would orphan nothing here, but disabling DHCP
|
|
// invalidates the reservation.
|
|
upNet["dhcp_enabled"], upNet["pools"], upNet["version"] = false, []any{}, n.Version+1
|
|
r = c.mustStatus(422, "PUT", "/api/v1/networks/"+n.ID, upNet)
|
|
if r.problem(t).Code != "network_change_invalidates_entries" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
|
|
r = c.mustStatus(409, "DELETE", "/api/v1/networks/"+n.ID, nil)
|
|
if r.problem(t).Code != "network_not_empty" || r.problem(t).Params["count"] != "2" {
|
|
t.Errorf("delete non-empty: %+v", r.problem(t))
|
|
}
|
|
c.mustStatus(204, "DELETE", "/api/v1/networks/"+n.ID+"?force=true", nil)
|
|
c.mustStatus(404, "GET", "/api/v1/addresses/"+web.ID, nil)
|
|
c.mustStatus(404, "GET", "/api/v1/does-not-exist", nil, skipRequestValidation, "1")
|
|
}
|
|
|
|
func TestImportExportBulk(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
c.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office", "domain": "office.test",
|
|
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}}})
|
|
|
|
bad := "ip,type,mac,hostname,description,owner,tags\n" +
|
|
"192.0.2.10,static,,srv1,=cmd|' /C calc'!A0,IT,a;b\n" +
|
|
"192.0.2.11,manual_dhcp,,printer,,,\n" +
|
|
"not-an-ip,static,,,,,\n"
|
|
r := c.mustStatus(422, "POST", "/api/v1/addresses/import", bad)
|
|
var ir service.ImportResult
|
|
r.json(t, &ir)
|
|
if len(ir.Problems) != 2 || ir.Created != 0 {
|
|
t.Fatalf("problems %+v", ir)
|
|
}
|
|
var page service.AddressPage
|
|
c.mustStatus(200, "GET", "/api/v1/addresses", nil).json(t, &page)
|
|
if page.Total != 0 {
|
|
t.Fatal("a failed import must not write anything")
|
|
}
|
|
|
|
good := "ip,type,mac,hostname,description,owner,tags\n" +
|
|
"192.0.2.10,static,,srv1,=cmd|' /C calc'!A0,IT,a;b\n" +
|
|
"192.0.2.11,manual_dhcp,00:11:22:33:44:55,printer,,,\n"
|
|
r = c.mustStatus(200, "POST", "/api/v1/addresses/import?dry_run=true", good)
|
|
r.json(t, &ir)
|
|
if !ir.DryRun || ir.Created != 2 {
|
|
t.Errorf("dry run %+v", ir)
|
|
}
|
|
c.mustStatus(200, "GET", "/api/v1/addresses", nil).json(t, &page)
|
|
if page.Total != 0 {
|
|
t.Fatal("dry run must not write")
|
|
}
|
|
c.mustStatus(200, "POST", "/api/v1/addresses/import", good).json(t, &ir)
|
|
if ir.Created != 2 {
|
|
t.Fatalf("import %+v", ir)
|
|
}
|
|
// Re-import updates instead of duplicating.
|
|
c.mustStatus(200, "POST", "/api/v1/addresses/import", good).json(t, &ir)
|
|
if ir.Updated != 2 || ir.Created != 0 {
|
|
t.Errorf("re-import %+v", ir)
|
|
}
|
|
|
|
r = c.mustStatus(200, "GET", "/api/v1/addresses/export", nil)
|
|
csvText := string(r.Body)
|
|
if !strings.HasPrefix(csvText, "ip,type,mac,hostname") || !strings.Contains(csvText, "'=cmd") {
|
|
t.Errorf("export must neutralize formulas:\n%s", csvText)
|
|
}
|
|
|
|
c.mustStatus(200, "GET", "/api/v1/addresses", nil).json(t, &page)
|
|
ids := []string{page.Items[0].ID, page.Items[1].ID}
|
|
r = c.mustStatus(422, "POST", "/api/v1/addresses/bulk", map[string]any{"ids": ids, "set_type": "dynamic"})
|
|
var br bulkResponse
|
|
r.json(t, &br)
|
|
if br.Changed != 0 || len(br.Problems) != 2 {
|
|
t.Errorf("bulk to dynamic outside pool: %+v", br)
|
|
}
|
|
r = c.mustStatus(200, "POST", "/api/v1/addresses/bulk", map[string]any{"ids": ids, "add_tags": []string{"Imported"}})
|
|
r.json(t, &br)
|
|
if br.Changed != 2 {
|
|
t.Errorf("bulk tags %+v", br)
|
|
}
|
|
c.mustStatus(200, "GET", "/api/v1/addresses?tag=imported", nil).json(t, &page)
|
|
if page.Total != 2 {
|
|
t.Errorf("tag filter %d", page.Total)
|
|
}
|
|
c.mustStatus(200, "POST", "/api/v1/addresses/bulk", map[string]any{"ids": ids, "delete": true})
|
|
c.mustStatus(200, "GET", "/api/v1/addresses", nil).json(t, &page)
|
|
if page.Total != 0 {
|
|
t.Errorf("bulk delete left %d", page.Total)
|
|
}
|
|
}
|
|
|
|
func TestAuditLog(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
c.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office"})
|
|
|
|
r := c.mustStatus(200, "GET", "/api/v1/audit?object_type=network", nil)
|
|
var page auditPage
|
|
r.json(t, &page)
|
|
if len(page.Items) != 1 || page.Items[0].Action != "network.create" || page.Items[0].ActorName != "admin" ||
|
|
page.Items[0].RequestID == "" || len(page.Items[0].After) == 0 {
|
|
t.Fatalf("audit %+v", page.Items)
|
|
}
|
|
r = c.mustStatus(200, "GET", "/api/v1/audit?limit=2", nil)
|
|
r.json(t, &page)
|
|
if page.NextBeforeID == nil {
|
|
t.Error("expected a next page (login, password change, network create)")
|
|
}
|
|
r = c.mustStatus(200, "GET", "/api/v1/audit/export?format=csv", nil)
|
|
if !strings.Contains(string(r.Body), "network.create") {
|
|
t.Error("CSV export misses entries")
|
|
}
|
|
r = c.mustStatus(200, "GET", "/api/v1/audit/export?format=json", nil)
|
|
if strings.Count(string(r.Body), "\n") < 3 {
|
|
t.Errorf("JSON lines export: %s", r.Body)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyMode(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
c.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office"})
|
|
|
|
// Trial over, no license: read-only.
|
|
e.advance(31 * 24 * time.Hour)
|
|
c.login("admin", "admin-password-123") // the old session idled out
|
|
r := c.mustStatus(403, "POST", "/api/v1/networks", map[string]any{"cidr": "198.51.100.0/24", "name": "New"})
|
|
if r.problem(t).Code != "license_read_only" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
c.mustStatus(200, "GET", "/api/v1/networks", nil)
|
|
c.mustStatus(200, "GET", "/api/v1/addresses/export", nil)
|
|
var st service.LicenseStatus
|
|
c.mustStatus(200, "GET", "/api/v1/license", nil).json(t, &st)
|
|
if st.State != "read_only" || st.Reason != "trial_expired" {
|
|
t.Errorf("license %+v", st)
|
|
}
|
|
// The way out stays open.
|
|
r = c.mustStatus(422, "PUT", "/api/v1/license", licenseKeyRequest{Key: "UNKNOWN"})
|
|
if r.problem(t).Code != "license_invalid" || r.problem(t).Params["provider_code"] != "NOT_FOUND" {
|
|
t.Errorf("set key %+v", r.problem(t))
|
|
}
|
|
// Personal settings still work.
|
|
c.mustStatus(201, "POST", "/api/v1/saved-filters", savedFilterRequest{Name: "Mine", Query: []byte(`{"q":"x"}`)})
|
|
}
|
|
|
|
func TestTOTP(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
var setup service.TOTPSetup
|
|
c.mustStatus(200, "POST", "/api/v1/auth/totp/setup", nil).json(t, &setup)
|
|
secret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(setup.Secret)
|
|
if err != nil || !strings.HasPrefix(setup.URI, "otpauth://totp/") {
|
|
t.Fatalf("setup %+v %v", setup, err)
|
|
}
|
|
r := c.mustStatus(422, "POST", "/api/v1/auth/totp/enable", codeRequest{Code: "000000"})
|
|
if r.problem(t).Code != "invalid_totp" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
c.mustStatus(204, "POST", "/api/v1/auth/totp/enable", codeRequest{Code: auth.GenerateTOTP(secret, *e.now)})
|
|
|
|
c2 := e.client()
|
|
s := c2.login("admin", "admin-password-123")
|
|
if !s.MFARequired || len(s.Permissions) != 0 {
|
|
t.Fatalf("session must be MFA-pending: %+v", s)
|
|
}
|
|
r = c2.mustStatus(401, "GET", "/api/v1/networks", nil)
|
|
if r.problem(t).Code != "mfa_required" {
|
|
t.Errorf("code %s", r.problem(t).Code)
|
|
}
|
|
// The code used for enabling cannot be replayed.
|
|
c2.mustStatus(422, "POST", "/api/v1/auth/totp/verify", codeRequest{Code: auth.GenerateTOTP(secret, *e.now)})
|
|
e.advance(30 * time.Second)
|
|
c2.mustStatus(200, "POST", "/api/v1/auth/totp/verify", codeRequest{Code: auth.GenerateTOTP(secret, *e.now)})
|
|
c2.mustStatus(200, "GET", "/api/v1/networks", nil)
|
|
|
|
c2.mustStatus(401, "POST", "/api/v1/auth/totp/disable", passwordConfirmRequest{Password: "wrong"})
|
|
c2.mustStatus(204, "POST", "/api/v1/auth/totp/disable", passwordConfirmRequest{Password: "admin-password-123"})
|
|
}
|
|
|
|
func TestUserAdministration(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
var me sessionResponse
|
|
c.mustStatus(200, "GET", "/api/v1/auth/session", nil).json(t, &me)
|
|
|
|
r := c.mustStatus(409, "PUT", "/api/v1/users/"+me.User.ID, map[string]any{"role": "viewer"})
|
|
if r.problem(t).Code != "last_admin" {
|
|
t.Errorf("demote last admin: %s", r.problem(t).Code)
|
|
}
|
|
r = c.mustStatus(409, "DELETE", "/api/v1/users/"+me.User.ID, nil)
|
|
if r.problem(t).Code != "self_delete" {
|
|
t.Errorf("self delete: %s", r.problem(t).Code)
|
|
}
|
|
|
|
var created service.CreatedUser
|
|
c.mustStatus(201, "POST", "/api/v1/users", map[string]any{"username": "bob", "role": "operator", "email": "bob@example.test"}).json(t, &created)
|
|
if created.InitialPassword == "" || !created.User.MustChangePassword {
|
|
t.Fatalf("created %+v", created)
|
|
}
|
|
r = c.mustStatus(409, "POST", "/api/v1/users", map[string]any{"username": "BOB", "role": "viewer"})
|
|
if r.problem(t).Problems[0].Code != "username_taken" {
|
|
t.Errorf("dup username %+v", r.problem(t))
|
|
}
|
|
c.mustStatus(422, "POST", "/api/v1/users", map[string]any{"username": "eve", "role": "root"}, skipRequestValidation, "1")
|
|
|
|
bob := e.client()
|
|
bob.login("bob", created.InitialPassword)
|
|
var pw map[string]string
|
|
c.mustStatus(200, "POST", "/api/v1/users/"+created.User.ID+"/reset-password", nil).json(t, &pw)
|
|
bob.mustStatus(401, "GET", "/api/v1/auth/session", nil) // sessions ended
|
|
bob.login("bob", pw["password"])
|
|
|
|
c.mustStatus(200, "PUT", "/api/v1/users/"+created.User.ID, map[string]any{"disabled": true})
|
|
bob.mustStatus(401, "GET", "/api/v1/auth/session", nil)
|
|
c.mustStatus(204, "DELETE", "/api/v1/users/"+created.User.ID, nil)
|
|
|
|
var list listResponse[service.UserDTO]
|
|
c.mustStatus(200, "GET", "/api/v1/users", nil).json(t, &list)
|
|
if len(list.Items) != 1 {
|
|
t.Errorf("users %+v", list.Items)
|
|
}
|
|
}
|
|
|
|
func TestOIDCSettingsAndSystemInfo(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
var info systemInfo
|
|
e.client().mustStatus(200, "GET", "/api/v1/system/info", nil).json(t, &info)
|
|
if info.AuthMethods.OIDC.Enabled || info.Product != "DDI-Suite" {
|
|
t.Errorf("info %+v", info)
|
|
}
|
|
|
|
cfg := map[string]any{"enabled": true, "issuer": "https://idp.example.test/realms/x", "client_id": "ddi",
|
|
"display_name": "Firmen-SSO", "scopes": []string{"profile"}, "username_claim": "preferred_username",
|
|
"role_claim": "groups", "role_mapping": map[string]string{"ddi-admins": "admin"}, "default_role": ""}
|
|
r := c.mustStatus(422, "PUT", "/api/v1/settings/oidc", cfg)
|
|
if r.problem(t).Problems[0].Field != "client_secret" {
|
|
t.Errorf("missing secret %+v", r.problem(t))
|
|
}
|
|
cfg["client_secret"] = "s3cr3t"
|
|
var got service.OIDCSettings
|
|
c.mustStatus(200, "PUT", "/api/v1/settings/oidc", cfg).json(t, &got)
|
|
if !got.ClientSecretSet {
|
|
t.Error("secret not stored")
|
|
}
|
|
r = c.mustStatus(200, "GET", "/api/v1/settings/oidc", nil)
|
|
if strings.Contains(string(r.Body), "s3cr3t") {
|
|
t.Fatal("the client secret must never be returned")
|
|
}
|
|
delete(cfg, "client_secret") // keep the stored secret
|
|
cfg["display_name"] = "SSO"
|
|
c.mustStatus(200, "PUT", "/api/v1/settings/oidc", cfg).json(t, &got)
|
|
if !got.ClientSecretSet || got.DisplayName != "SSO" {
|
|
t.Errorf("update keeping secret: %+v", got)
|
|
}
|
|
e.client().mustStatus(200, "GET", "/api/v1/system/info", nil).json(t, &info)
|
|
if !info.AuthMethods.OIDC.Enabled || info.AuthMethods.OIDC.DisplayName != "SSO" {
|
|
t.Errorf("info %+v", info)
|
|
}
|
|
// Without a public URL the flow redirects back with an error.
|
|
r = e.client().mustStatus(302, "GET", "/api/v1/auth/oidc/start", nil)
|
|
if !strings.HasPrefix(r.Header.Get("Location"), "/login?error=") {
|
|
t.Errorf("location %s", r.Header.Get("Location"))
|
|
}
|
|
}
|
|
|
|
func TestSavedFilters(t *testing.T) {
|
|
e := newEnv(t)
|
|
c := e.adminClient()
|
|
var f struct {
|
|
ID string `json:"id"`
|
|
}
|
|
c.mustStatus(201, "POST", "/api/v1/saved-filters", savedFilterRequest{Name: "Drucker", Query: []byte(`{"q":"printer","type":["manual_dhcp"]}`)}).json(t, &f)
|
|
c.mustStatus(409, "POST", "/api/v1/saved-filters", savedFilterRequest{Name: "Drucker", Query: []byte(`{}`)})
|
|
var list listResponse[map[string]any]
|
|
c.mustStatus(200, "GET", "/api/v1/saved-filters", nil).json(t, &list)
|
|
if len(list.Items) != 1 {
|
|
t.Fatalf("filters %+v", list)
|
|
}
|
|
c.mustStatus(204, "DELETE", "/api/v1/saved-filters/"+f.ID, nil)
|
|
c.mustStatus(404, "DELETE", "/api/v1/saved-filters/"+f.ID, nil)
|
|
}
|