Files
DDI/server/internal/httpapi/api_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 56347ec5ff
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: end-to-end test of the agent path, fix first CI findings
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>
2026-10-13 19:29:58 +02:00

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)
}