REVIEW-001 (M0) HIGH H2: loopback was enforced only at BIND, so a reverse proxy on 127.0.0.1 relaying remote clients reached /setup and could create the top admin — exactly the first-visitor-wins D-025 claims to close "in code". In setup mode, reject any request whose RESOLVED client IP is not loopback (404 before dispatch); an unparseable/empty client IP fails closed. The resolved IP is the one XFF produces, so a trusted proxy forwarding a remote client does not launder it (see the new test). Also: - M1c/L3s: pin the setup module at the door. serveSetupAction rejects Module != "setup", and any other resolution naming it (a GUID path, a reserved workflow) 404s instead of falling through to serveGUID. - L1s: read/clear the session cookie under its transport-correct name. Tests: the harness speaks from a loopback peer by default (the operator reaches /setup over a tunnel) and configures a public base_url where the nudge must fire (L7c). New tests cover the per-request setup gate (including the trusted-proxy relay and the loopback tunnel) and the module pin, both of which fail without the fix.
534 lines
19 KiB
Go
534 lines
19 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
|
)
|
|
|
|
// member is one enrolled user with a live session (the chain's four tiers).
|
|
type member struct {
|
|
userID int64
|
|
identityID int64
|
|
guid string
|
|
sessionID string
|
|
email string
|
|
role users.Role
|
|
}
|
|
|
|
type recoveryHarness struct {
|
|
t *testing.T
|
|
ctx context.Context
|
|
db *store.DB
|
|
users *users.Store
|
|
ids *auth.IdentitiesService
|
|
sessions *auth.SessionsService
|
|
requests *recovery.Store
|
|
mailer *mail.Fake
|
|
csrf *httpx.CSRF
|
|
pre *httpx.PreSessionCSRF
|
|
srv *server.Server
|
|
|
|
admin member
|
|
director member
|
|
manager member
|
|
team member
|
|
peer member // a second team member (same role as team)
|
|
}
|
|
|
|
func newRecoveryHarness(t *testing.T) *recoveryHarness {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := db.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
aud := audit.New(db.DB)
|
|
us := users.New(db.DB, aud)
|
|
|
|
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x51}, auth.KeySize))
|
|
if err != nil {
|
|
t.Fatalf("NewAEADSealer: %v", err)
|
|
}
|
|
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
|
|
ids := core.Identities()
|
|
sessions := core.Sessions()
|
|
|
|
const (
|
|
csrfKey = "recovery-csrf-key-0123456789abcdef"
|
|
cookieSecret = "recovery-cookie-secret-0123456789abcdef"
|
|
)
|
|
csrf, err := httpx.NewCSRF([]byte(csrfKey))
|
|
if err != nil {
|
|
t.Fatalf("NewCSRF: %v", err)
|
|
}
|
|
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
|
|
if err != nil {
|
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
|
}
|
|
|
|
h := &recoveryHarness{
|
|
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
|
|
mailer: mail.NewFake(), csrf: csrf, pre: pre,
|
|
}
|
|
h.requests = recovery.NewStore(db.DB, aud)
|
|
|
|
// The chain, built top-down (D-019).
|
|
h.admin = h.enroll(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
|
|
h.director = h.enroll(ctx, h.admin.userID, "director@example.com", "Director", users.RoleDirector)
|
|
h.manager = h.enroll(ctx, h.director.userID, "manager@example.com", "Manager", users.RoleManager)
|
|
h.team = h.enroll(ctx, h.manager.userID, "team@example.com", "Team", users.RoleTeam)
|
|
h.peer = h.enroll(ctx, h.manager.userID, "peer@example.com", "Peer", users.RoleTeam)
|
|
|
|
rec, err := recovery.New(recovery.Deps{
|
|
Requests: h.requests,
|
|
Users: us,
|
|
Identities: ids,
|
|
Mailer: h.mailer,
|
|
CSRF: pre,
|
|
Audit: aud,
|
|
BaseURL: "https://myday.example.com",
|
|
Logger: nil,
|
|
// M2s makes the notification asynchronous by default; these assertions
|
|
// are about no-spam semantics, so run it synchronously for determinism.
|
|
Dispatch: func(fn func()) { fn() },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("recovery.New: %v", err)
|
|
}
|
|
|
|
reg := server.NewMapRegistry()
|
|
reg.RegisterCore("home", server.Home)
|
|
reg.RegisterModule(server.NewModule("home").
|
|
SetDefault("dashboard", server.Home).
|
|
AddRoute("home", "home", server.Home))
|
|
server.RegisterRecoveryFlow(reg, rec)
|
|
|
|
cfg := &config.Config{
|
|
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"},
|
|
Database: config.Database{Path: db.Path()},
|
|
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
|
|
}
|
|
srv, err := server.New(server.Deps{
|
|
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
|
|
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
|
|
RoleGate: server.NewTiers(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("server.New: %v", err)
|
|
}
|
|
h.srv = srv
|
|
return h
|
|
}
|
|
|
|
// enroll creates a user on the chain, activates an identity and mints a session.
|
|
func (h *recoveryHarness) enroll(ctx context.Context, creator int64, email, name string, role users.Role) member {
|
|
h.t.Helper()
|
|
uid, err := h.users.Create(ctx, creator, email, name, role)
|
|
if err != nil {
|
|
h.t.Fatalf("create %s: %v", role, err)
|
|
}
|
|
id, guid, err := h.ids.Mint(ctx, uid)
|
|
if err != nil {
|
|
h.t.Fatalf("Mint %s: %v", role, err)
|
|
}
|
|
if err := h.ids.Activate(ctx, id); err != nil {
|
|
h.t.Fatalf("Activate %s: %v", role, err)
|
|
}
|
|
sess, err := h.sessions.Mint(ctx, id, "127.0.0.1", "test")
|
|
if err != nil {
|
|
h.t.Fatalf("session Mint %s: %v", role, err)
|
|
}
|
|
return member{userID: uid, identityID: id, guid: guid, sessionID: sess.ID, email: email, role: role}
|
|
}
|
|
|
|
func (h *recoveryHarness) preCSRF() (*http.Cookie, string) {
|
|
h.t.Helper()
|
|
tok, err := h.pre.Token()
|
|
if err != nil {
|
|
h.t.Fatalf("pre-session Token: %v", err)
|
|
}
|
|
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
|
|
}
|
|
|
|
// access posts the recovery request form (anonymous, A15).
|
|
func (h *recoveryHarness) access(email string) *httptest.ResponseRecorder {
|
|
h.t.Helper()
|
|
cookie, token := h.preCSRF()
|
|
form := url.Values{"email": {email}, "csrf": {token}}
|
|
req := httptest.NewRequest("POST", "/access?do=auth&action=recover", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(cookie)
|
|
rec := httptest.NewRecorder()
|
|
h.srv.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// sessionCookie is the session cookie the door expects for this harness's
|
|
// transport. base_url is https here, so the cookie carries the `__Host-`
|
|
// prefix (L1s).
|
|
func (h *recoveryHarness) sessionCookie(id string) *http.Cookie {
|
|
return &http.Cookie{Name: httpx.SessionCookieNameFor(true), Value: id}
|
|
}
|
|
|
|
// decide issues a session-gated approve/deny action as actor.
|
|
func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder {
|
|
h.t.Helper()
|
|
form := url.Values{
|
|
"request": {strconv.FormatInt(requestID, 10)},
|
|
"csrf": {h.csrf.Token(actor.sessionID)},
|
|
}
|
|
req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(h.sessionCookie(actor.sessionID))
|
|
rec := httptest.NewRecorder()
|
|
h.srv.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// openRequest submits team's recovery request and returns its id.
|
|
func (h *recoveryHarness) openRequest(m member) recovery.Request {
|
|
h.t.Helper()
|
|
if rec := h.access(m.email); rec.Code != http.StatusOK {
|
|
h.t.Fatalf("POST /access = %d, want 200", rec.Code)
|
|
}
|
|
req, err := h.requests.Pending(h.ctx, m.userID)
|
|
if err != nil {
|
|
h.t.Fatalf("Pending(%d): %v", m.userID, err)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// auditActions returns the set of audit actions recorded.
|
|
func (h *recoveryHarness) auditActions(t *testing.T) map[string]bool {
|
|
t.Helper()
|
|
rows, err := h.db.Query(`SELECT action FROM audit_log`)
|
|
if err != nil {
|
|
t.Fatalf("read audit_log: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
got := map[string]bool{}
|
|
for rows.Next() {
|
|
var a string
|
|
if err := rows.Scan(&a); err != nil {
|
|
t.Fatalf("scan audit action: %v", err)
|
|
}
|
|
got[a] = true
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("iterate audit_log: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// ── AC-2 ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestSpec007AC2_NoSelfApprovalOrLowerRole: the requester can never approve
|
|
// their own request, and a peer (same role) cannot either (D-019, AC-2).
|
|
func TestSpec007AC2_NoSelfApprovalOrLowerRole(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
req := h.openRequest(h.team)
|
|
|
|
// The requester themselves -> forbidden. The body must be EXACTLY the
|
|
// client error — a returned error would make the door append a 500 on top
|
|
// of an already-written response.
|
|
if rec := h.decide("approve", h.team, req.ID); rec.Code != http.StatusForbidden {
|
|
t.Errorf("self-approval = %d, want 403 (AC-2)", rec.Code)
|
|
} else if got := rec.Body.String(); strings.TrimSpace(got) != "forbidden" {
|
|
t.Errorf("self-approval body = %q, want exactly %q", got, "forbidden")
|
|
}
|
|
// A peer (same role) -> forbidden.
|
|
if rec := h.decide("approve", h.peer, req.ID); rec.Code != http.StatusForbidden {
|
|
t.Errorf("peer approval = %d, want 403 (AC-2)", rec.Code)
|
|
}
|
|
|
|
// Nothing changed: the identity is still active, the request still pending.
|
|
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
|
if err != nil || idn.Status != auth.StatusActive {
|
|
t.Errorf("identity after a refused approval = %+v, want still active (AC-2)", idn)
|
|
}
|
|
still, err := h.requests.Get(h.ctx, req.ID)
|
|
if err != nil || still.Status != recovery.StatusPending {
|
|
t.Errorf("request after a refused approval = %+v, want pending (AC-2)", still)
|
|
}
|
|
}
|
|
|
|
// TestSpec007AC2_R7_ChainAuthority: the recovery authority is the CHAIN (R-7).
|
|
// A director in a DIFFERENT subtree is a role above a team member but not on
|
|
// their manager line, so they may NOT approve; the admin (the root) may.
|
|
func TestSpec007AC2_R7_ChainAuthority(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
|
|
// A second director, created by the admin: role-above team, but NOT an
|
|
// ancestor of team (team -> manager -> director -> admin).
|
|
directorB := h.enroll(h.ctx, h.admin.userID, "director2@example.com", "Director2", users.RoleDirector)
|
|
|
|
req := h.openRequest(h.team)
|
|
|
|
// Role-above but off the chain -> forbidden (R-7's correction).
|
|
if rec := h.decide("approve", directorB, req.ID); rec.Code != http.StatusForbidden {
|
|
t.Errorf("non-ancestor director approval = %d, want 403 (R-7)", rec.Code)
|
|
}
|
|
// The request is untouched by the refusal.
|
|
if still, err := h.requests.Get(h.ctx, req.ID); err != nil || still.Status != recovery.StatusPending {
|
|
t.Errorf("request after a refused off-chain approval = %+v, want pending (R-7)", still)
|
|
}
|
|
|
|
// The admin is the root of the chain and may approve.
|
|
if rec := h.decide("approve", h.admin, req.ID); rec.Code != http.StatusOK {
|
|
t.Errorf("admin approval = %d, want 200 (R-7)", rec.Code)
|
|
}
|
|
}
|
|
|
|
// ── AC-3 ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestSpec007AC3_ApprovalRotatesIdentity: an approver on the requester's chain
|
|
// revokes the old identity and all its sessions and mints a NEW GUID, then
|
|
// emails the enrolment link (D-007, AC-3).
|
|
func TestSpec007AC3_ApprovalRotatesIdentity(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
req := h.openRequest(h.team)
|
|
|
|
rec := h.decide("approve", h.manager, req.ID) // manager is above team
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// The OLD identity is revoked and its GUID tombstoned.
|
|
old, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
|
if err != nil {
|
|
t.Fatalf("ByGUID(old): %v", err)
|
|
}
|
|
if old.Status != auth.StatusRevoked {
|
|
t.Errorf("old identity status = %s, want revoked (AC-3)", old.Status)
|
|
}
|
|
// Every session of the old identity is dead.
|
|
if _, err := h.sessions.Validate(h.ctx, h.team.sessionID); err == nil {
|
|
t.Error("a pre-approval session survived the rotation (AC-3)")
|
|
}
|
|
|
|
// A NEW GUID is minted, pending enrollment, under the same user.
|
|
fresh, err := h.ids.ByUserID(h.ctx, h.team.userID)
|
|
if err != nil {
|
|
t.Fatalf("ByUserID: %v", err)
|
|
}
|
|
if fresh.GUID == h.team.guid {
|
|
t.Error("approval reissued the old GUID (AC-3)")
|
|
}
|
|
if fresh.Status != auth.StatusPendingEnrollment {
|
|
t.Errorf("new identity status = %s, want pending_enrollment (AC-3)", fresh.Status)
|
|
}
|
|
|
|
// The enrolment link reached the address on record.
|
|
found := false
|
|
for _, m := range h.mailer.To(h.team.email) {
|
|
if strings.Contains(m.Body, fresh.GUID) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no enrolment email carrying the new GUID reached %s (AC-3)", h.team.email)
|
|
}
|
|
|
|
// The request is approved.
|
|
done, err := h.requests.Get(h.ctx, req.ID)
|
|
if err != nil || done.Status != recovery.StatusApproved {
|
|
t.Errorf("request = %+v, want approved (AC-3)", done)
|
|
}
|
|
}
|
|
|
|
// ── AC-4 ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestSpec007AC4_AccessIsGeneric: /access never reveals whether the email
|
|
// matched — the same status and the same body either way (A3, AC-4).
|
|
func TestSpec007AC4_AccessIsGeneric(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
|
|
hit := h.access(h.team.email)
|
|
miss := h.access("nobody@example.com")
|
|
|
|
if hit.Code != http.StatusOK || miss.Code != http.StatusOK {
|
|
t.Fatalf("handle codes = %d/%d, want 200/200", hit.Code, miss.Code)
|
|
}
|
|
if hit.Body.String() != miss.Body.String() {
|
|
t.Errorf("the /access response differs on a hit vs a miss:\nhit: %q\nmiss: %q",
|
|
hit.Body.String(), miss.Body.String())
|
|
}
|
|
if !strings.Contains(hit.Body.String(), "If a record exists") {
|
|
t.Errorf("the /access notice is not generic: %q", hit.Body.String())
|
|
}
|
|
}
|
|
|
|
// ── AC-6 ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestSpec007AC6_RecoveryIsAudited: request, approve, deny and the identity
|
|
// rotation are all recorded (A11, AC-6).
|
|
func TestSpec007AC6_RecoveryIsAudited(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
|
|
// approve
|
|
req := h.openRequest(h.team)
|
|
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
|
|
t.Fatalf("approve = %d, want 200", rec.Code)
|
|
}
|
|
// deny
|
|
req2 := h.openRequest(h.manager)
|
|
if rec := h.decide("deny", h.director, req2.ID); rec.Code != http.StatusOK {
|
|
t.Fatalf("deny = %d, want 200", rec.Code)
|
|
}
|
|
|
|
got := h.auditActions(t)
|
|
for _, want := range []string{
|
|
"recovery.request",
|
|
"recovery.approve",
|
|
"recovery.deny",
|
|
"identity.rotate",
|
|
} {
|
|
if !got[want] {
|
|
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── supporting behaviour ─────────────────────────────────────────────────────
|
|
|
|
// TestSpec007AccessIssuesPreSessionCSRF: the anonymous /access form carries the
|
|
// signed pre-session token (A15) and the action without it is refused.
|
|
func TestSpec007AccessIssuesPreSessionCSRF(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
|
|
rec := request(h.srv, "GET", "/access")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /access = %d, want 200", rec.Code)
|
|
}
|
|
if !hasCookie(rec, httpx.PreSessionCookieName) {
|
|
t.Error("GET /access issued no pre-session CSRF cookie (A15)")
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
|
t.Errorf("GET /access Cache-Control = %q, want no-store", got)
|
|
}
|
|
|
|
// Without the double-submit token the action is refused (A15).
|
|
req := httptest.NewRequest("POST", "/access?do=auth&action=recover",
|
|
strings.NewReader(url.Values{"email": {h.team.email}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rr := httptest.NewRecorder()
|
|
h.srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("recovery request without CSRF = %d, want 403 (A15)", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestSpec007_RepeatRequestDoesNotRenotify: a second POST for the same user
|
|
// reuses the pending request and does NOT email the chain again (no spam).
|
|
func TestSpec007_RepeatRequestDoesNotRenotify(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
|
|
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
|
|
t.Fatalf("first POST /access = %d, want 200", rec.Code)
|
|
}
|
|
first := len(h.mailer.Sent())
|
|
if first == 0 {
|
|
t.Fatal("the first request sent no notification")
|
|
}
|
|
|
|
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
|
|
t.Fatalf("second POST /access = %d, want 200", rec.Code)
|
|
}
|
|
if got := len(h.mailer.Sent()); got != first {
|
|
t.Errorf("a repeated request re-notified the chain: %d messages, want %d", got, first)
|
|
}
|
|
}
|
|
|
|
// TestSpec007_SecondDecisionRefused: the request is claimed atomically — a
|
|
// second approve/deny is refused and does not rotate a second time.
|
|
func TestSpec007_SecondDecisionRefused(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
req := h.openRequest(h.team)
|
|
|
|
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
|
|
t.Fatalf("first approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := h.decide("approve", h.director, req.ID); rec.Code != http.StatusConflict {
|
|
t.Errorf("second approve = %d, want 409 (already decided)", rec.Code)
|
|
}
|
|
|
|
// The user has exactly one live (pending) identity — no double rotation.
|
|
var n int
|
|
if err := h.db.QueryRow(
|
|
`SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, h.team.userID,
|
|
).Scan(&n); err != nil {
|
|
t.Fatalf("count live identities: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("live identities = %d, want exactly 1 (no double rotation)", n)
|
|
}
|
|
}
|
|
|
|
// TestSpec007_ExpiredRequestCannotBeApproved: a request past the ~72h window
|
|
// is expired and refused (SPEC-007 §4).
|
|
func TestSpec007_ExpiredRequestCannotBeApproved(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
req := h.openRequest(h.team)
|
|
|
|
// Age the request well past the TTL.
|
|
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
|
|
if _, err := h.db.Exec(`UPDATE recovery_requests SET requested_at = ? WHERE request_id = ?`, past, req.ID); err != nil {
|
|
t.Fatalf("age request: %v", err)
|
|
}
|
|
|
|
rec := h.decide("approve", h.manager, req.ID)
|
|
if rec.Code != http.StatusGone {
|
|
t.Errorf("approve an expired request = %d, want 410", rec.Code)
|
|
}
|
|
// The identity was NOT rotated.
|
|
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
|
if err != nil || idn.Status != auth.StatusActive {
|
|
t.Errorf("identity after an expired approval = %+v, want still active", idn)
|
|
}
|
|
stored, err := h.requests.Get(h.ctx, req.ID)
|
|
if err != nil || stored.Status != recovery.StatusExpired {
|
|
t.Errorf("request = %+v, want expired", stored)
|
|
}
|
|
}
|
|
|
|
// TestSpec007DenyDoesNotRotate: a denial leaves the requester's identity intact.
|
|
func TestSpec007DenyDoesNotRotate(t *testing.T) {
|
|
h := newRecoveryHarness(t)
|
|
req := h.openRequest(h.team)
|
|
|
|
if rec := h.decide("deny", h.director, req.ID); rec.Code != http.StatusOK {
|
|
t.Fatalf("deny = %d, want 200", rec.Code)
|
|
}
|
|
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
|
if err != nil || idn.Status != auth.StatusActive {
|
|
t.Errorf("identity after a denial = %+v, want still active", idn)
|
|
}
|
|
done, err := h.requests.Get(h.ctx, req.ID)
|
|
if err != nil || done.Status != recovery.StatusDenied {
|
|
t.Errorf("request = %+v, want denied", done)
|
|
}
|
|
}
|