// ui_enrollment_tokens_test.go — covers NS-02 token-recovery handlers: // revoke deletes the row, regenerate swaps the row out for a fresh // raw token redirected to /hosts/pending/{newToken}. package http import ( "context" "errors" stdhttp "net/http" "strings" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // mintTestToken seeds an enrolment token via the same helper the live // /hosts/new flow uses, returning the (raw, hash) pair. func mintTestToken(t *testing.T, srv *Server) (raw, hash string) { t.Helper() tok, _, err := srv.mintEnrollmentToken(context.Background(), "rest:http://r:8000/x/", "u", "p", []string{"/etc"}) if err != nil { t.Fatalf("mint: %v", err) } return tok, auth.HashToken(tok) } // TestEnrollmentTokenRevokeDeletesRow: POST .../revoke removes the // row and 303s back to /hosts/new. func TestEnrollmentTokenRevokeDeletesRow(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) _, hash := mintTestToken(t, srv) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/enrollment-tokens/"+hash+"/revoke", strings.NewReader("")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) cli := &stdhttp.Client{ CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { return stdhttp.ErrUseLastResponse }, } res, err := cli.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusSeeOther { t.Fatalf("status: got %d, want 303", res.StatusCode) } if loc := res.Header.Get("Location"); loc != "/hosts/new" { t.Errorf("Location: got %q, want /hosts/new", loc) } if _, err := st.GetEnrollmentTokenAttachments(context.Background(), hash); !errors.Is(err, store.ErrNotFound) { t.Errorf("post-revoke lookup: want ErrNotFound, got %v", err) } var n int if err := st.DB().QueryRow( `SELECT COUNT(*) FROM audit_log WHERE action = 'enrollment_token.revoked'`).Scan(&n); err != nil { t.Fatalf("count audit: %v", err) } if n != 1 { t.Errorf("audit rows: got %d, want 1", n) } } // TestEnrollmentTokenRegenerateSwapsRow: POST .../regenerate revokes // the old hash, mints a fresh raw token preserving the repo URL/user/ // password attachments, and 303s to the new pending page. func TestEnrollmentTokenRegenerateSwapsRow(t *testing.T) { t.Parallel() srv, ts, st := rawTestServerWithUI(t) oldRaw, oldHash := mintTestToken(t, srv) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/enrollment-tokens/"+oldHash+"/regenerate", strings.NewReader("")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) cli := &stdhttp.Client{ CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { return stdhttp.ErrUseLastResponse }, } res, err := cli.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusSeeOther { t.Fatalf("status: got %d, want 303", res.StatusCode) } loc := res.Header.Get("Location") if !strings.HasPrefix(loc, "/hosts/pending/") { t.Fatalf("Location: got %q, want /hosts/pending/", loc) } newRaw := strings.TrimPrefix(loc, "/hosts/pending/") if newRaw == "" || newRaw == oldRaw { t.Fatalf("regenerate produced same/empty token (old=%q, new=%q)", oldRaw, newRaw) } // Old hash gone; new hash present with the same paths attachment. if _, err := st.GetEnrollmentTokenAttachments(context.Background(), oldHash); !errors.Is(err, store.ErrNotFound) { t.Errorf("old hash should be gone; got %v", err) } att, err := st.GetEnrollmentTokenAttachments(context.Background(), auth.HashToken(newRaw)) if err != nil { t.Fatalf("new hash lookup: %v", err) } if len(att.InitialPaths) != 1 || att.InitialPaths[0] != "/etc" { t.Errorf("attachments: got paths %v, want [/etc]", att.InitialPaths) } var n int if err := st.DB().QueryRow( `SELECT COUNT(*) FROM audit_log WHERE action = 'enrollment_token.regenerated'`).Scan(&n); err != nil { t.Fatalf("count audit: %v", err) } if n != 1 { t.Errorf("audit rows: got %d, want 1", n) } } // TestEnrollmentTokenRegenerateMissingTokenRedirects: hitting // regenerate with an unknown hash 303s back to /hosts/new without a // 5xx (idempotent re-submit safety). func TestEnrollmentTokenRegenerateMissingTokenRedirects(t *testing.T) { t.Parallel() _, ts, st := rawTestServerWithUI(t) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/enrollment-tokens/deadbeef/regenerate", strings.NewReader("")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) cli := &stdhttp.Client{ CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { return stdhttp.ErrUseLastResponse }, } res, err := cli.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusSeeOther { t.Fatalf("status: got %d, want 303", res.StatusCode) } if loc := res.Header.Get("Location"); loc != "/hosts/new" { t.Errorf("Location: got %q, want /hosts/new", loc) } }