137 lines
5.5 KiB
Go
137 lines
5.5 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestAllowlistRoutes covers GET /servers/{name}/allowlist and PUT
|
|
// /servers/{name}/allowlist/{uuid}: the owner and an admin read and change the
|
|
// list, anyone else is refused before the repo is touched, a UUID that is not on
|
|
// the list is 404, and each change is audited with the UUID it touched.
|
|
func TestAllowlistRoutes(t *testing.T) {
|
|
const friend = "0f8fad5b-d9cb-469f-a165-70867728950e"
|
|
const other = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
|
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
|
admin := &Principal{UserID: "admin1", Role: "admin", ViaAdminAccess: true}
|
|
stranger := &Principal{UserID: "other", Email: "[email protected]", Role: "user"}
|
|
added := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
mk := func(p *Principal) (*API, *fakeRepo) {
|
|
repo := newFakeRepo()
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
repo.allowEntries["survival"] = []AllowlistEntry{
|
|
{MCUUID: friend, Username: "Steve", AddedAt: added, CanWake: true},
|
|
{MCUUID: other, AddedAt: added.Add(-time.Hour), CanWake: false},
|
|
}
|
|
a := newTestAPI(repo, newFakeCluster())
|
|
a.External = staticExternal{p: p}
|
|
return a, repo
|
|
}
|
|
list := func(t *testing.T, a *API) []AllowlistEntry {
|
|
t.Helper()
|
|
w := do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/allowlist", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Server string `json:"server"`
|
|
Entries []AllowlistEntry `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("list: bad JSON: %v", err)
|
|
}
|
|
if resp.Server != "survival" {
|
|
t.Fatalf("list: server = %q", resp.Server)
|
|
}
|
|
return resp.Entries
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
label string
|
|
p *Principal
|
|
}{{"owner", owner}, {"admin", admin}} {
|
|
t.Run(tc.label+" lists and revokes", func(t *testing.T) {
|
|
a, repo := mk(tc.p)
|
|
got := list(t, a)
|
|
if len(got) != 2 || got[0].MCUUID != friend || got[0].Username != "Steve" || !got[0].CanWake ||
|
|
got[1].MCUUID != other || got[1].CanWake || !got[0].AddedAt.Equal(added) {
|
|
t.Fatalf("entries = %+v", got)
|
|
}
|
|
|
|
// An upper-case UUID names the same entry: the route canonicalizes it.
|
|
w := do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/allowlist/"+strings.ToUpper(friend),
|
|
`{"can_wake":false}`, jsonHeader)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("revoke: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if list(t, a)[0].CanWake {
|
|
t.Fatal("revoke left the wake right in place")
|
|
}
|
|
if len(repo.audits) != 1 || repo.audits[0].Action != "allowlist.revoke" ||
|
|
repo.audits[0].ServerName != "survival" || repo.audits[0].ActorUserID != tc.p.UserID ||
|
|
!strings.Contains(string(repo.audits[0].Payload), friend) {
|
|
t.Fatalf("revoke audit = %+v", repo.audits)
|
|
}
|
|
|
|
w = do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/allowlist/"+other,
|
|
`{"can_wake":true}`, jsonHeader)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("restore: code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if !list(t, a)[1].CanWake {
|
|
t.Fatal("restore did not give the wake right back")
|
|
}
|
|
if len(repo.audits) != 2 || repo.audits[1].Action != "allowlist.restore" {
|
|
t.Fatalf("restore audit = %+v", repo.audits)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("stranger is refused on both routes", func(t *testing.T) {
|
|
a, repo := mk(stranger)
|
|
if w := do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/allowlist", "", nil); w.Code != http.StatusForbidden {
|
|
t.Fatalf("list: code = %d, want 403", w.Code)
|
|
}
|
|
w := do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/allowlist/"+friend, `{"can_wake":false}`, jsonHeader)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("revoke: code = %d, want 403", w.Code)
|
|
}
|
|
if !repo.allowEntries["survival"][0].CanWake || len(repo.audits) != 0 {
|
|
t.Fatalf("a refused revoke changed something: %+v, audits %+v", repo.allowEntries["survival"], repo.audits)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown server, unknown entry and bad input", func(t *testing.T) {
|
|
a, repo := mk(owner)
|
|
for _, c := range []struct {
|
|
method, path, body string
|
|
want int
|
|
}{
|
|
{"GET", "/api/v1/servers/nowhere/allowlist", "", http.StatusNotFound},
|
|
{"PUT", "/api/v1/servers/nowhere/allowlist/" + friend, `{"can_wake":false}`, http.StatusNotFound},
|
|
{"PUT", "/api/v1/servers/survival/allowlist/11111111-2222-3333-4444-555555555555", `{"can_wake":false}`, http.StatusNotFound},
|
|
{"PUT", "/api/v1/servers/survival/allowlist/not-a-uuid", `{"can_wake":false}`, http.StatusBadRequest},
|
|
{"PUT", "/api/v1/servers/survival/allowlist/" + friend, `{}`, http.StatusBadRequest},
|
|
{"GET", "/api/v1/servers/Bad_Name/allowlist", "", http.StatusBadRequest},
|
|
} {
|
|
if w := do(a.ExternalHandler(), c.method, c.path, c.body, jsonHeader); w.Code != c.want {
|
|
t.Errorf("%s %s %s: code = %d, want %d (%s)", c.method, c.path, c.body, w.Code, c.want, w.Body.String())
|
|
}
|
|
}
|
|
if !repo.allowEntries["survival"][0].CanWake || len(repo.audits) != 0 {
|
|
t.Fatalf("a failed call changed something: %+v, audits %+v", repo.allowEntries["survival"], repo.audits)
|
|
}
|
|
|
|
repo.allowlistErr = errors.New("db down")
|
|
w := do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/allowlist", "", nil)
|
|
if w.Code != http.StatusInternalServerError || strings.Contains(w.Body.String(), "db down") {
|
|
t.Fatalf("a failed read: code = %d (%s), want an opaque 500", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|