From 5f9b1f4eea1516848c6324b9823c991757c4b10c Mon Sep 17 00:00:00 2001
From: Jumpy Squirrel <jsquirrel_github_9a6d@packetloss.de>
Date: Sun, 8 Sep 2024 11:49:04 +0200
Subject: [PATCH] feat(#228): read all add-info endpoint

---
 api/openapi-spec/openapi.yaml                 |  71 +++++++
 internal/api/v1/addinfo/api.go                |   7 +
 .../repository/database/dbrepo/interface.go   |   1 +
 .../database/historizeddb/implementation.go   |   4 +
 .../database/inmemorydb/implementation.go     |  16 ++
 .../database/mysqldb/implementation.go        |  30 +++
 internal/service/attendeesrv/addinfo.go       |  16 ++
 internal/service/attendeesrv/interfaces.go    |   5 +
 .../web/controller/addinfoctl/addinfoctl.go   |  58 +++++-
 .../web/controller/attendeectl/config_test.go |   4 +
 test/acceptance/addinfo_acc_test.go           | 178 ++++++++++++++++++
 11 files changed, 386 insertions(+), 4 deletions(-)
 create mode 100644 internal/api/v1/addinfo/api.go

diff --git a/api/openapi-spec/openapi.yaml b/api/openapi-spec/openapi.yaml
index 2452696..d4c943e 100644
--- a/api/openapi-spec/openapi.yaml
+++ b/api/openapi-spec/openapi.yaml
@@ -32,6 +32,60 @@ tags:
   - name: info
     description: Health and other public status information
 paths:
+  /additional-info/{area}:
+    get:
+      tags:
+        - additional
+      summary: obtain all current additional info for an area
+      description: |
+        Returns the current additional info for an area (e.g. regdesk, sponsordesk, overdue, ...) for all attendees.
+        
+        User will need to have permissions on any of their registrations as set in configuration to access it
+        (self-read permission is not sufficient).
+
+        Additional infos are returned as json objects encoded in strings.
+      operationId: getAllAdditionalInfo
+      parameters:
+        - name: area
+          in: path
+          description: Area (must match [a-z]+), note that 'overdue' is used internally. The configuration contains a list of allowed areas.
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AdditionalInfoFullArea'
+        '400':
+          description: Invalid area supplied (area must match [a-z]+), or is not listed in the configuration and is thus also invalid. Note that area 'overdue' will also lead to this response, this area is used for internal purposes.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '401':
+          description: Authorization required
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '403':
+          description: You do not have permission to see this attendee or area
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '500':
+          description: An unexpected error occurred. This includes database errors. A best effort attempt is made to return details in the body.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      security:
+        - BearerAuth: []
+        - ApiKeyAuth: []
   /attendees:
     get:
       tags:
@@ -1620,6 +1674,23 @@ paths:
         - ApiKeyAuth: []
 components:
   schemas:
+    AdditionalInfoFullArea:
+      type: object
+      required:
+        - area
+        - values
+      properties:
+        area:
+          type: string
+          example: myarea
+          description: the area that was requested
+        values:
+          additionalProperties: true
+          description: a map of attendee ids to additional info values, encoded as strings. Note that contents of the values may need to be unquoted to extract json values. Also note there is no guarantee the string values will contain valid json, it fully depends on what the tool using this additional info area has written.
+          example:
+            '1': '{"hello":"you"}'
+            '2': '{"you":"too"}'
+            '3': 'not a json value'
     Attendee:
       type: object
       required:
diff --git a/internal/api/v1/addinfo/api.go b/internal/api/v1/addinfo/api.go
new file mode 100644
index 0000000..4e8f955
--- /dev/null
+++ b/internal/api/v1/addinfo/api.go
@@ -0,0 +1,7 @@
+package addinfo
+
+type AdditionalInfoFullArea struct {
+	Area string `json:"area"` // the area that was requested
+
+	Values map[string]string `json:"values"` // values by attendee id
+}
diff --git a/internal/repository/database/dbrepo/interface.go b/internal/repository/database/dbrepo/interface.go
index 1a9a860..c4f1c5f 100644
--- a/internal/repository/database/dbrepo/interface.go
+++ b/internal/repository/database/dbrepo/interface.go
@@ -40,6 +40,7 @@ type Repository interface {
 	UpdateBan(ctx context.Context, b *entity.Ban) error
 	DeleteBan(ctx context.Context, b *entity.Ban) error
 
+	GetAllAdditionalInfoForArea(ctx context.Context, area string) ([]*entity.AdditionalInfo, error)
 	GetAdditionalInfoFor(ctx context.Context, attendeeId uint, area string) (*entity.AdditionalInfo, error)
 	WriteAdditionalInfo(ctx context.Context, ad *entity.AdditionalInfo) error
 
diff --git a/internal/repository/database/historizeddb/implementation.go b/internal/repository/database/historizeddb/implementation.go
index 5308d07..f7a3418 100644
--- a/internal/repository/database/historizeddb/implementation.go
+++ b/internal/repository/database/historizeddb/implementation.go
@@ -232,6 +232,10 @@ func (r *HistorizingRepository) DeleteBan(ctx context.Context, b *entity.Ban) er
 
 // --- additional info ---
 
+func (r *HistorizingRepository) GetAllAdditionalInfoForArea(ctx context.Context, area string) ([]*entity.AdditionalInfo, error) {
+	return r.wrappedRepository.GetAllAdditionalInfoForArea(ctx, area)
+}
+
 func (r *HistorizingRepository) GetAdditionalInfoFor(ctx context.Context, attendeeId uint, area string) (*entity.AdditionalInfo, error) {
 	return r.wrappedRepository.GetAdditionalInfoFor(ctx, attendeeId, area)
 }
diff --git a/internal/repository/database/inmemorydb/implementation.go b/internal/repository/database/inmemorydb/implementation.go
index efc4156..b6574d6 100644
--- a/internal/repository/database/inmemorydb/implementation.go
+++ b/internal/repository/database/inmemorydb/implementation.go
@@ -377,6 +377,22 @@ func (r *InMemoryRepository) GetAllAdditionalInfoOrEmptyMap(ctx context.Context,
 	return byAttendeeId
 }
 
+func (r *InMemoryRepository) GetAllAdditionalInfoForArea(ctx context.Context, area string) ([]*entity.AdditionalInfo, error) {
+	result := make([]*entity.AdditionalInfo, 0)
+	for attendeeId, areaMap := range r.addInfo {
+		areaValue, ok := areaMap[area]
+		if ok {
+			entry := &entity.AdditionalInfo{
+				AttendeeId: attendeeId,
+				Area:       area,
+				JsonValue:  areaValue.JsonValue,
+			}
+			result = append(result, entry)
+		}
+	}
+	return result, nil
+}
+
 func (r *InMemoryRepository) GetAdditionalInfoFor(ctx context.Context, attendeeId uint, area string) (*entity.AdditionalInfo, error) {
 	byAttendeeId := r.GetAllAdditionalInfoOrEmptyMap(ctx, attendeeId)
 	ai, ok := byAttendeeId[area]
diff --git a/internal/repository/database/mysqldb/implementation.go b/internal/repository/database/mysqldb/implementation.go
index 6af1ead..c1937ae 100644
--- a/internal/repository/database/mysqldb/implementation.go
+++ b/internal/repository/database/mysqldb/implementation.go
@@ -386,6 +386,36 @@ func (r *MysqlRepository) DeleteBan(ctx context.Context, b *entity.Ban) error {
 
 // --- additional info ---
 
+func (r *MysqlRepository) GetAllAdditionalInfoForArea(ctx context.Context, area string) ([]*entity.AdditionalInfo, error) {
+	result := make([]*entity.AdditionalInfo, 0)
+	addInfoBuffer := entity.AdditionalInfo{}
+	queryBuffer := entity.AdditionalInfo{Area: area}
+
+	rows, err := r.db.Model(&entity.AdditionalInfo{}).Where(&queryBuffer).Order("attendee_id").Rows()
+	if err != nil {
+		aulogging.Logger.Ctx(ctx).Error().WithErr(err).Printf("error reading additional infos for area %s: %s", area, err.Error())
+		return result, err
+	}
+	defer func() {
+		err2 := rows.Close()
+		if err2 != nil {
+			aulogging.Logger.Ctx(ctx).Warn().WithErr(err2).Printf("secondary error closing recordset during additional info read: %s", err2.Error())
+		}
+	}()
+
+	for rows.Next() {
+		err = r.db.ScanRows(rows, &addInfoBuffer)
+		if err != nil {
+			aulogging.Logger.Ctx(ctx).Error().WithErr(err).Printf("error reading additional info during find for area %s: %s", area, err.Error())
+			return result, err
+		}
+		copiedAddInfo := addInfoBuffer
+		result = append(result, &copiedAddInfo)
+	}
+
+	return result, nil
+}
+
 func (r *MysqlRepository) GetAdditionalInfoFor(ctx context.Context, attendeeId uint, area string) (*entity.AdditionalInfo, error) {
 	var ai entity.AdditionalInfo
 	err := r.db.Model(&entity.AdditionalInfo{}).Where(&entity.AdditionalInfo{AttendeeId: attendeeId, Area: area}).Last(&ai).Error
diff --git a/internal/service/attendeesrv/addinfo.go b/internal/service/attendeesrv/addinfo.go
index b99eb5b..eaa7f45 100644
--- a/internal/service/attendeesrv/addinfo.go
+++ b/internal/service/attendeesrv/addinfo.go
@@ -2,11 +2,27 @@ package attendeesrv
 
 import (
 	"context"
+	"fmt"
 	"github.com/eurofurence/reg-attendee-service/internal/repository/config"
 	"github.com/eurofurence/reg-attendee-service/internal/repository/database"
 	"github.com/eurofurence/reg-attendee-service/internal/web/util/ctxvalues"
 )
 
+func (s *AttendeeServiceImplData) GetFullAdditionalInfoArea(ctx context.Context, area string) (map[string]string, error) {
+	entries, err := database.GetRepository().GetAllAdditionalInfoForArea(ctx, area)
+	if err != nil {
+		return make(map[string]string), err
+	}
+	result := make(map[string]string)
+	for _, entry := range entries {
+		if entry != nil {
+			key := fmt.Sprintf("%d", entry.AttendeeId)
+			result[key] = entry.JsonValue
+		}
+	}
+	return result, nil
+}
+
 func (s *AttendeeServiceImplData) GetAdditionalInfo(ctx context.Context, attendeeId uint, area string) (string, error) {
 	existing, err := database.GetRepository().GetAdditionalInfoFor(ctx, attendeeId, area)
 	return existing.JsonValue, err
diff --git a/internal/service/attendeesrv/interfaces.go b/internal/service/attendeesrv/interfaces.go
index 3c7456c..0538953 100644
--- a/internal/service/attendeesrv/interfaces.go
+++ b/internal/service/attendeesrv/interfaces.go
@@ -70,6 +70,11 @@ type AttendeeService interface {
 	GetBan(ctx context.Context, id uint) (*entity.Ban, error)
 	GetAllBans(ctx context.Context) ([]*entity.Ban, error)
 
+	// GetFullAdditionalInfoArea obtains all additional info values for an area.
+	//
+	// May return an empty map if no entries found. This is not an error.
+	GetFullAdditionalInfoArea(ctx context.Context, area string) (map[string]string, error)
+
 	// GetAdditionalInfo obtains additional info for a given attendeeId and area.
 	//
 	// If this returns an empty string, then no value existed.
diff --git a/internal/web/controller/addinfoctl/addinfoctl.go b/internal/web/controller/addinfoctl/addinfoctl.go
index b30ed67..5ba2a9c 100644
--- a/internal/web/controller/addinfoctl/addinfoctl.go
+++ b/internal/web/controller/addinfoctl/addinfoctl.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	aulogging "github.com/StephanHCB/go-autumn-logging"
+	"github.com/eurofurence/reg-attendee-service/internal/api/v1/addinfo"
 	"github.com/eurofurence/reg-attendee-service/internal/repository/config"
 	"github.com/eurofurence/reg-attendee-service/internal/service/attendeesrv"
 	"github.com/eurofurence/reg-attendee-service/internal/web/filter"
@@ -33,6 +34,8 @@ func Create(server chi.Router, attendeeSrv attendeesrv.AttendeeService) {
 	server.Post("/api/rest/v1/attendees/{id}/additional-info/{area}", filter.LoggedInOrApiToken(filter.WithTimeout(3*time.Second, writeAdditionalInfoHandler)))
 	server.Delete("/api/rest/v1/attendees/{id}/additional-info/{area}", filter.LoggedInOrApiToken(filter.WithTimeout(3*time.Second, deleteAdditionalInfoHandler)))
 
+	server.Get("/api/rest/v1/additional-info/{area}", filter.LoggedInOrApiToken(filter.WithTimeout(60*time.Second, getAllAdditionalInfoHandler)))
+
 	areaRegexp = regexp.MustCompile("^[a-z]+$")
 }
 
@@ -110,6 +113,26 @@ func deleteAdditionalInfoHandler(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusNoContent)
 }
 
+func getAllAdditionalInfoHandler(w http.ResponseWriter, r *http.Request) {
+	ctx, area, err := ctxFullAreaReadAllowedAndExists_MustReturn(w, r)
+	if err != nil {
+		return
+	}
+
+	values, err := attendeeService.GetFullAdditionalInfoArea(ctx, area)
+	if err != nil {
+		ctlutil.ErrorHandler(ctx, w, r, "addinfo.read.error", http.StatusInternalServerError, url.Values{})
+		return
+	}
+
+	result := addinfo.AdditionalInfoFullArea{
+		Area:   area,
+		Values: values,
+	}
+	w.Header().Add(headers.ContentType, media.ContentTypeApplicationJson)
+	ctlutil.WriteJson(ctx, w, &result)
+}
+
 func ctxIdAreaAllowedAndExists_MustReturn(w http.ResponseWriter, r *http.Request, wantWriteAccess bool) (context.Context, uint, string, error) {
 	ctx := r.Context()
 
@@ -145,6 +168,28 @@ func ctxIdAreaAllowedAndExists_MustReturn(w http.ResponseWriter, r *http.Request
 	return ctx, id, area, nil
 }
 
+func ctxFullAreaReadAllowedAndExists_MustReturn(w http.ResponseWriter, r *http.Request) (context.Context, string, error) {
+	ctx := r.Context()
+
+	area, err := areaFromVarsValidated_MustReturn(ctx, w, r)
+	if err != nil {
+		return ctx, area, err
+	}
+
+	allowed, err := attendeeService.CanAccessAdditionalInfoArea(ctx, area)
+	if err != nil {
+		ctlutil.ErrorHandler(ctx, w, r, "addinfo.read.error", http.StatusInternalServerError, url.Values{})
+		return ctx, area, err
+	}
+	if !allowed {
+		culprit := ctxvalues.Subject(ctx)
+		ctlutil.UnauthorizedError(ctx, w, r, "you are not authorized for this additional info area - the attempt has been logged", fmt.Sprintf("unauthorized access attempt for add info area %s by %s", area, culprit))
+		return ctx, area, errors.New("forbidden")
+	}
+
+	return ctx, area, nil
+}
+
 func idAndAreaFromVarsValidated_MustReturn(ctx context.Context, w http.ResponseWriter, r *http.Request) (uint, string, error) {
 	idStr := chi.URLParam(r, "id")
 	id, err := strconv.ParseUint(idStr, 10, 32)
@@ -152,22 +197,27 @@ func idAndAreaFromVarsValidated_MustReturn(ctx context.Context, w http.ResponseW
 		ctlutil.InvalidAttendeeIdErrorHandler(ctx, w, r, idStr)
 		return uint(id), "", err
 	}
+	area, err := areaFromVarsValidated_MustReturn(ctx, w, r)
+	return uint(id), area, err
+}
+
+func areaFromVarsValidated_MustReturn(ctx context.Context, w http.ResponseWriter, r *http.Request) (string, error) {
 	area := chi.URLParam(r, "area")
 	if !areaRegexp.MatchString(area) {
 		aulogging.Logger.Ctx(ctx).Warn().Printf("received invalid additional info area '%s'", area)
 		ctlutil.ErrorHandler(ctx, w, r, "addinfo.area.invalid", http.StatusBadRequest, url.Values{"area": []string{"must match [a-z]+"}})
-		return uint(id), area, errors.New("invalid additional info area")
+		return area, errors.New("invalid additional info area")
 	}
 	if area == "overdue" {
 		aulogging.Logger.Ctx(ctx).Warn().Printf("received invalid additional info area '%s'", area)
 		ctlutil.ErrorHandler(ctx, w, r, "addinfo.area.invalid", http.StatusBadRequest, url.Values{"area": []string{"the special value 'overdue' is used internally and is forbidden here"}})
-		return uint(id), area, errors.New("invalid additional info area")
+		return area, errors.New("invalid additional info area")
 	}
 	if validation.NotInAllowedValues(config.AdditionalInfoFieldNames(), area) {
 		aulogging.Logger.Ctx(ctx).Warn().Printf("received additional info area '%s' not listed in configuration", area)
 		ctlutil.ErrorHandler(ctx, w, r, "addinfo.area.unlisted", http.StatusBadRequest, url.Values{"area": []string{"areas must be enabled in configuration"}})
-		return uint(id), area, errors.New("unlisted additional info area")
+		return area, errors.New("unlisted additional info area")
 	}
 
-	return uint(id), area, nil
+	return area, nil
 }
diff --git a/internal/web/controller/attendeectl/config_test.go b/internal/web/controller/attendeectl/config_test.go
index 2b460f2..c8900ec 100644
--- a/internal/web/controller/attendeectl/config_test.go
+++ b/internal/web/controller/attendeectl/config_test.go
@@ -147,6 +147,10 @@ func (s *MockAttendeeService) GetAdditionalInfo(ctx context.Context, attendeeId
 	return "", nil
 }
 
+func (s *MockAttendeeService) GetFullAdditionalInfoArea(ctx context.Context, area string) (map[string]string, error) {
+	return map[string]string{}, nil
+}
+
 func (s *MockAttendeeService) WriteAdditionalInfo(ctx context.Context, attendeeId uint, area string, value string) error {
 	return nil
 }
diff --git a/test/acceptance/addinfo_acc_test.go b/test/acceptance/addinfo_acc_test.go
index 0cd8b9f..724294c 100644
--- a/test/acceptance/addinfo_acc_test.go
+++ b/test/acceptance/addinfo_acc_test.go
@@ -2,6 +2,7 @@ package acceptance
 
 import (
 	"github.com/eurofurence/reg-attendee-service/docs"
+	"github.com/eurofurence/reg-attendee-service/internal/api/v1/addinfo"
 	"github.com/eurofurence/reg-attendee-service/internal/api/v1/admin"
 	"github.com/stretchr/testify/require"
 	"net/http"
@@ -579,3 +580,180 @@ func TestDeleteAdditionalInfo_Unset(t *testing.T) {
 	docs.Then("then the request fails and the correct error is returned")
 	tstRequireErrorResponse(t, response, http.StatusNotFound, "addinfo.notfound.error", url.Values{})
 }
+
+// getAllAdditionalInfo
+
+func TestGetAllAdditionalInfo_AnonDeny(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.Given("given an existing attendee with an additional info field set")
+	location1, _ := tstRegisterAttendee(t, "aia1-")
+	created := tstPerformPost(location1+"/additional-info/myarea", `{"aia1":"something"}`, tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, created.status)
+
+	docs.Given("given an unauthenticated user")
+	token := tstNoToken()
+
+	docs.When("when they attempt to read the additional info area for all attendees")
+	response := tstPerformGet("/api/rest/v1/additional-info/myarea", token)
+
+	docs.Then("then the request is denied as unauthenticated (401) and the correct error is returned")
+	tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation")
+}
+
+func TestGetAllAdditionalInfo_UserDeny(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.Given("given an existing attendee with an additional info field set")
+	location1, att1 := tstRegisterAttendee(t, "aia2-")
+	created := tstPerformPost(location1+"/additional-info/myarea", `{"aia2":"something"}`, tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, created.status)
+
+	docs.When("when they attempt to access the additional info area for all attendees but do not have access")
+	token := tstValidUserToken(t, att1.Id)
+	response := tstPerformGet("/api/rest/v1/additional-info/myarea", token)
+
+	docs.Then("then the request is denied as unauthorized (403) and the correct error is returned")
+	tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this additional info area - the attempt has been logged")
+}
+
+func TestGetAllAdditionalInfo_UserWithPermissionAllow(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.Given("given three existing attendees, two of which have an additional info field set")
+	location1, att1 := tstRegisterAttendee(t, "aia3a-")
+	created1 := tstPerformPost(location1+"/additional-info/myarea", `{"aia3a":"something"}`, tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, created1.status)
+
+	location2, _ := tstRegisterAttendeeWithToken(t, "aia3b-", tstValidUserToken(t, 101))
+	created2 := tstPerformPost(location2+"/additional-info/myarea", `{"aia3b":"something else"}`, tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, created2.status)
+
+	_, _ = tstRegisterAttendeeWithToken(t, "aia3c-", tstValidUserToken(t, 102))
+
+	docs.Given("given the first attendee has been granted access to the additional info area")
+	body := admin.AdminInfoDto{
+		Permissions: "myarea",
+	}
+	accessGranted := tstPerformPut(location1+"/admin", tstRenderJson(body), tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, accessGranted.status)
+
+	docs.When("when they attempt to access the additional info area for all attendees")
+	token := tstValidUserToken(t, att1.Id)
+	response := tstPerformGet("/api/rest/v1/additional-info/myarea", token)
+
+	docs.Then("then the request is successful and they can retrieve the additional info again")
+	expectedValues := map[string]string{
+		"1": "{\"aia3a\":\"something\"}",
+		"4": "{\"aia3b\":\"something else\"}", // ids in-memory are a global sequence
+	}
+	expected := addinfo.AdditionalInfoFullArea{
+		Area:   "myarea",
+		Values: expectedValues,
+	}
+	actual := addinfo.AdditionalInfoFullArea{}
+	tstRequireSuccessResponse(t, response, http.StatusOK, &actual)
+	require.Equal(t, expected.Area, actual.Area)
+	require.EqualValues(t, expected.Values, actual.Values)
+}
+
+func TestGetAllAdditionalInfo_UserSelfDeny(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.Given("given an existing attendee with an additional info field set")
+	location1, att1 := tstRegisterAttendee(t, "aia3a-")
+	created := tstPerformPost(location1+"/additional-info/selfread", `{"aia3a":"something"}`, tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, created.status)
+
+	docs.When("when they attempt to access the additional info area with self read permissions")
+	token := tstValidUserToken(t, att1.Id)
+	response := tstPerformGet("/api/rest/v1/additional-info/selfread", token)
+
+	docs.Then("then the request is denied as unauthorized (403) and the correct error is returned")
+	tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this additional info area - the attempt has been logged")
+}
+
+func TestGetAllAdditionalInfo_AdminAllow(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.Given("given an existing attendee with an additional info field set")
+	location1, _ := tstRegisterAttendee(t, "aia4-")
+	created := tstPerformPost(location1+"/additional-info/myarea", `{"aia4":"something"}`, tstValidAdminToken(t))
+	require.Equal(t, http.StatusNoContent, created.status)
+
+	docs.When("when an admin attempts to access the additional info")
+	token := tstValidAdminToken(t)
+	response := tstPerformGet("/api/rest/v1/additional-info/myarea", token)
+
+	docs.Then("then the request is successful and the response is as expected")
+	expectedValues := map[string]string{
+		"1": "{\"aia4\":\"something\"}",
+	}
+	expected := addinfo.AdditionalInfoFullArea{
+		Area:   "myarea",
+		Values: expectedValues,
+	}
+	actual := addinfo.AdditionalInfoFullArea{}
+	tstRequireSuccessResponse(t, response, http.StatusOK, &actual)
+	require.Equal(t, expected.Area, actual.Area)
+	require.EqualValues(t, expected.Values, actual.Values)
+}
+
+func TestGetAllAdditionalInfo_InvalidArea(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.When("when an admin attempts to access additional info but supplies an invalid area")
+	token := tstValidAdminToken(t)
+	response := tstPerformGet("/api/rest/v1/additional-info/area-cannot-contain-dashes", token)
+
+	docs.Then("then the request fails and the correct error is returned")
+	tstRequireErrorResponse(t, response, http.StatusBadRequest, "addinfo.area.invalid", url.Values{"area": []string{"must match [a-z]+"}})
+}
+
+func TestGetAllAdditionalInfo_NotConfiguredArea(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.When("when an admin attempts to access all additional info but asks for an area that is not listed in the configuration")
+	token := tstValidAdminToken(t)
+	response := tstPerformGet("/api/rest/v1/additional-info/unlisted", token)
+
+	docs.Then("then the request fails and the correct error is returned")
+	tstRequireErrorResponse(t, response, http.StatusBadRequest, "addinfo.area.unlisted", url.Values{"area": []string{"areas must be enabled in configuration"}})
+}
+
+func TestGetAllAdditionalInfo_Unset(t *testing.T) {
+	docs.Given("given the configuration for standard registration")
+	tstSetup(false, false, true)
+	defer tstShutdown()
+
+	docs.Given("given an existing attendee")
+	_, _ = tstRegisterAttendee(t, "aia7-")
+
+	docs.When("when an admin reads all additional info for a valid area that has no values assigned")
+	token := tstValidAdminToken(t)
+	response := tstPerformGet("/api/rest/v1/additional-info/myarea", token)
+
+	docs.Then("then the request is successful with an appropriate response with an empty values object, which is not missing")
+	expected := addinfo.AdditionalInfoFullArea{
+		Area:   "myarea",
+		Values: map[string]string{},
+	}
+	actual := addinfo.AdditionalInfoFullArea{}
+	tstRequireSuccessResponse(t, response, http.StatusOK, &actual)
+	require.Equal(t, expected.Area, actual.Area)
+	require.EqualValues(t, expected.Values, actual.Values)
+}