Просмотр исходного кода

misc fixes

feedbag: cap linked accounts at 4 in the management API
oservice: send SigTime 0 when no profile is set
mgmt api: return error when sender doesn't exist
config: reject empty entries in TOC and web API listener lists
config: document WEBAPI_LISTENERS as plain host:port pairs
Mike 5 дней назад
Родитель
Сommit
43fb9875ee

+ 2 - 2
api.yml

@@ -550,7 +550,7 @@ paths:
         '200':
           description: Message sent or stored for offline delivery.
         '400':
-          description: Bad request. Invalid input data.
+          description: Bad request. Invalid input data, or the sender does not exist.
         '403':
           description: Message blocked by the recipient.
         '404':
@@ -1328,7 +1328,7 @@ paths:
               schema:
                 $ref: '#/components/schemas/MessageResponse'
         '409':
-          description: Linked account relationship already exists.
+          description: Linked account relationship already exists, or the user already has the maximum of 4 linked accounts.
           content:
             application/json:
               schema:

+ 8 - 5
config/config.go

@@ -152,7 +152,7 @@ type Config struct {
 	TOCListeners            []string `envconfig:"TOC_LISTENERS" required:"true" basic:"0.0.0.0:9898" ssl:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`
 	APIListener             string   `envconfig:"API_LISTENER" required:"true" basic:"127.0.0.1:8080" ssl:"127.0.0.1:8080" description:"Network listener for management API binds to. Only 1 listener can be specified. (Default 127.0.0.1 restricts to same machine only)."`
 	WebAPIEnabled           bool     `envconfig:"WEBAPI_ENABLED" required:"false" default:"true" basic:"true" ssl:"true" description:"Enable the WebAPI, which serves web-based AIM clients such as AIM Express. When enabled, the server listens on WEBAPI_LISTENERS."`
-	WebAPIListeners         []string `envconfig:"WEBAPI_LISTENERS" required:"false" basic:"0.0.0.0:8081" ssl:"0.0.0.0:8081" description:"Network listeners for WebAPI. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:8081\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:8081,LAN://192.168.1.10:8082"`
+	WebAPIListeners         []string `envconfig:"WEBAPI_LISTENERS" required:"false" basic:"0.0.0.0:8081" ssl:"0.0.0.0:8081" description:"Network listeners for WebAPI.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:8081\n\t// Multiple listeners\n\t0.0.0.0:8081,192.168.1.10:8082"`
 	WebAPIAllowedOrigins    []string `envconfig:"WEBAPI_ALLOWED_ORIGINS" required:"false" basic:"http://localhost:*" ssl:"http://localhost:*" description:"Origins allowed to call the WebAPI from a browser (CORS). A browser blocks a cross-origin response whose origin is not listed here, so the client serving the web app must appear in this list.\n\nFormat:\n\t- Comma-separated list of [SCHEME]://[HOSTNAME]:[PORT]\n\t- An origin is the scheme, host and port together: a client served from another port needs its own entry\n\t- Omit the port when it is the scheme default, the way a browser writes it: https://aim.example.com, not https://aim.example.com:443\n\t- No trailing slash, path, query or fragment\n\t- An entry may contain one wildcard (*) standing in for 0 or more characters, in the host or the port: https://*.example.com, http://localhost:* . Only one wildcard per entry, the scheme cannot be wildcarded, and matching one costs a little more per request\n\t- A lone * allows any origin, which is also what an unset or empty value means\n\nExamples:\n\t// Single origin\n\thttps://ras.dev\n\t// Web app on a separate port, plus the API host itself\n\thttp://localhost:8000,https://ras.dev\n\t// Any origin (development only)\n\t*"`
 
 	DBPath                 string `envconfig:"DB_PATH" required:"true" basic:"oscar.sqlite" ssl:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
@@ -410,11 +410,13 @@ func (c *Config) ParseICBMLegacyCharset() (encoding.Encoding, error) {
 }
 
 func (c *Config) Validate() error {
-	// Validate TOCListeners (format: hostname:port pairs)
+	// Validate TOCListeners (format: hostname:port pairs). Every entry is
+	// bound as given, and an empty address binds a random port on all
+	// interfaces.
 	for _, listener := range c.TOCListeners {
 		listener = strings.TrimSpace(listener)
 		if listener == "" {
-			continue
+			return fmt.Errorf("invalid TOC listeners %q: empty entry. Remove the trailing or doubled comma from TOC_LISTENERS", strings.Join(c.TOCListeners, ","))
 		}
 
 		host, port, err := net.SplitHostPort(listener)
@@ -450,11 +452,12 @@ func (c *Config) Validate() error {
 		return fmt.Errorf("invalid API listener %q: missing port. Valid format: HOST:PORT (e.g., 127.0.0.1:8080)", c.APIListener)
 	}
 
-	// Validate WebAPIListeners (format: hostname:port pairs, no scheme)
+	// Validate WebAPIListeners (format: hostname:port pairs, no scheme). Every
+	// entry is bound as given, and an empty address binds :80 on all interfaces.
 	for _, listener := range c.WebAPIListeners {
 		listener = strings.TrimSpace(listener)
 		if listener == "" {
-			continue
+			return fmt.Errorf("invalid web API listeners %q: empty entry. Remove the trailing or doubled comma from WEBAPI_LISTENERS", strings.Join(c.WebAPIListeners, ","))
 		}
 
 		host, port, err := net.SplitHostPort(listener)

+ 51 - 1
config/config_test.go

@@ -640,7 +640,28 @@ func TestConfigValidate(t *testing.T) {
 				APIListener:     "127.0.0.1:8080",
 				WebAPIListeners: []string{"0.0.0.0:8081"},
 			},
-			wantErr: false,
+			wantErr:     true,
+			errContains: "invalid TOC listeners \"   ,  ,  \": empty entry",
+		},
+		{
+			name: "TOC listeners with trailing comma",
+			config: Config{
+				TOCListeners:    []string{"0.0.0.0:9898", ""},
+				APIListener:     "127.0.0.1:8080",
+				WebAPIListeners: []string{"0.0.0.0:8081"},
+			},
+			wantErr:     true,
+			errContains: "invalid TOC listeners \"0.0.0.0:9898,\": empty entry. Remove the trailing or doubled comma from TOC_LISTENERS",
+		},
+		{
+			name: "TOC listeners with doubled comma",
+			config: Config{
+				TOCListeners:    []string{"0.0.0.0:9898", "", "192.168.1.10:9899"},
+				APIListener:     "127.0.0.1:8080",
+				WebAPIListeners: []string{"0.0.0.0:8081"},
+			},
+			wantErr:     true,
+			errContains: "invalid TOC listeners \"0.0.0.0:9898,,192.168.1.10:9899\": empty entry",
 		},
 		{
 			name: "whitespace-only API listener",
@@ -668,6 +689,35 @@ func TestConfigValidate(t *testing.T) {
 				APIListener:     "127.0.0.1:8080",
 				WebAPIListeners: []string{"   ", "  "},
 			},
+			wantErr:     true,
+			errContains: "invalid web API listeners \"   ,  \": empty entry",
+		},
+		{
+			name: "web API listeners with trailing comma",
+			config: Config{
+				TOCListeners:    []string{"0.0.0.0:9898"},
+				APIListener:     "127.0.0.1:8080",
+				WebAPIListeners: []string{"0.0.0.0:8081", ""},
+			},
+			wantErr:     true,
+			errContains: "invalid web API listeners \"0.0.0.0:8081,\": empty entry. Remove the trailing or doubled comma from WEBAPI_LISTENERS",
+		},
+		{
+			name: "web API listeners with doubled comma",
+			config: Config{
+				TOCListeners:    []string{"0.0.0.0:9898"},
+				APIListener:     "127.0.0.1:8080",
+				WebAPIListeners: []string{"0.0.0.0:8081", "", "192.168.1.10:8082"},
+			},
+			wantErr:     true,
+			errContains: "invalid web API listeners \"0.0.0.0:8081,,192.168.1.10:8082\": empty entry",
+		},
+		{
+			name: "no web API listeners",
+			config: Config{
+				TOCListeners: []string{"0.0.0.0:9898"},
+				APIListener:  "127.0.0.1:8080",
+			},
 			wantErr: false,
 		},
 		{

+ 7 - 5
config/settings.env

@@ -52,13 +52,15 @@ export API_LISTENER=127.0.0.1:8080
 # When enabled, the server listens on WEBAPI_LISTENERS.
 export WEBAPI_ENABLED=true
 
-# Network listeners for WebAPI. See OSCAR_LISTENERS doc for more details.
+# Network listeners for WebAPI.
+# 
+# Format: Comma-separated list of hostname:port pairs.
 # 
 # Examples:
-# 	// Listen on all interfaces
-# 	LAN://0.0.0.0:8081
-# 	// Separate Internet and LAN config
-# 	WAN://142.250.176.206:8081,LAN://192.168.1.10:8082
+# 	// All interfaces
+# 	0.0.0.0:8081
+# 	// Multiple listeners
+# 	0.0.0.0:8081,192.168.1.10:8082
 export WEBAPI_LISTENERS=0.0.0.0:8081
 
 # Origins allowed to call the WebAPI from a browser (CORS). A browser blocks a

+ 7 - 5
config/ssl/settings.env

@@ -88,13 +88,15 @@ export API_LISTENER=127.0.0.1:8080
 # When enabled, the server listens on WEBAPI_LISTENERS.
 export WEBAPI_ENABLED=true
 
-# Network listeners for WebAPI. See OSCAR_LISTENERS doc for more details.
+# Network listeners for WebAPI.
+# 
+# Format: Comma-separated list of hostname:port pairs.
 # 
 # Examples:
-# 	// Listen on all interfaces
-# 	LAN://0.0.0.0:8081
-# 	// Separate Internet and LAN config
-# 	WAN://142.250.176.206:8081,LAN://192.168.1.10:8082
+# 	// All interfaces
+# 	0.0.0.0:8081
+# 	// Multiple listeners
+# 	0.0.0.0:8081,192.168.1.10:8082
 export WEBAPI_LISTENERS=0.0.0.0:8081
 
 # Origins allowed to call the WebAPI from a browser (CORS). A browser blocks a

+ 1 - 1
foodgroup/feedbag.go

@@ -87,7 +87,7 @@ func (s *FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame)
 	maxItemsByClass[wire.FeedbagClassIdRootCreator] = 1
 	maxItemsByClass[wire.FeedbagClassIdImportTimestamp] = 1
 	maxItemsByClass[wire.FeedbagClassIdBart] = 200
-	maxItemsByClass[wire.FeedbagClassIdAlInfo] = 4
+	maxItemsByClass[wire.FeedbagClassIdAlInfo] = state.MaxLinkedScreenNames
 
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{

+ 5 - 3
foodgroup/oservice.go

@@ -963,9 +963,11 @@ func sessionUserInfo(instance *state.SessionInstance, multiInstance bool) wire.T
 		info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, bartIDs))
 	}
 
-	// the profile is account-level, so report the most recent update made by
-	// any instance
-	info.Append(wire.NewTLVBE(wire.OServiceUserInfoSigTime, uint32(instance.Profile().UpdateTime.Unix())))
+	var sigTime uint32
+	if updated := instance.Profile().UpdateTime; !updated.IsZero() {
+		sigTime = uint32(updated.Unix())
+	}
+	info.Append(wire.NewTLVBE(wire.OServiceUserInfoSigTime, sigTime))
 
 	// user flags are the same for every instance, except for the away flag,
 	// which is only set when every instance is away

+ 3 - 3
foodgroup/oservice_test.go

@@ -3005,7 +3005,7 @@ func TestOServiceService_UserInfoQuery_UserInfoBlocks(t *testing.T) {
 		// The account block always carries a sig time, zero when no profile is set.
 		sigVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoSigTime)
 		require.True(t, ok)
-		require.Equal(t, uint32(time.Time{}.Unix()), sigVal)
+		require.Equal(t, uint32(0), sigVal)
 
 		// A client that does not speak the multi-instance fields is told nothing
 		// about the session's other connections.
@@ -3079,7 +3079,7 @@ func TestOServiceService_UserInfoQuery_UserInfoBlocks(t *testing.T) {
 		// The account block always carries a sig time, zero without a profile.
 		sigVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoSigTime)
 		require.True(t, ok)
-		require.Equal(t, uint32(time.Time{}.Unix()), sigVal)
+		require.Equal(t, uint32(0), sigVal)
 
 		primaryBytes, ok := got.UserInfo[0].Bytes(wire.OServiceUserInfoPrimaryInstance)
 		require.True(t, ok)
@@ -3122,7 +3122,7 @@ func TestOServiceService_UserInfoQuery_UserInfoBlocks(t *testing.T) {
 		// The account block always carries a sig time, zero without a profile.
 		sigVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoSigTime)
 		require.True(t, ok)
-		require.Equal(t, uint32(time.Time{}.Unix()), sigVal)
+		require.Equal(t, uint32(0), sigVal)
 
 		// The oldest instance is the primary one
 		primaryBytes, ok := got.UserInfo[0].Bytes(wire.OServiceUserInfoPrimaryInstance)

+ 17 - 2
server/http/mgmt_api.go

@@ -99,7 +99,7 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 
 	// Handlers for '/instant-message' route
 	mux.HandleFunc("POST /instant-message", func(w http.ResponseWriter, r *http.Request) {
-		postInstantMessageHandler(w, r, icbmSender, logger)
+		postInstantMessageHandler(w, r, userManager, icbmSender, logger)
 	})
 
 	// Handlers for '/version' route
@@ -627,13 +627,24 @@ func writeUnescapeChatURL(w http.ResponseWriter, out []chatRoom) {
 }
 
 // postInstantMessageHandler handles the POST /instant-message endpoint.
-func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, icbmSender ICBMSender, logger *slog.Logger) {
+func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, icbmSender ICBMSender, logger *slog.Logger) {
 	input := instantMessage{}
 	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
 		http.Error(w, "malformed input", http.StatusBadRequest)
 		return
 	}
 
+	sender, err := userManager.User(r.Context(), state.NewIdentScreenName(input.From))
+	if err != nil {
+		logger.Error("error retrieving sender POST /instant-message", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if sender == nil {
+		http.Error(w, "sender does not exist", http.StatusBadRequest)
+		return
+	}
+
 	sess := state.NewSession()
 	sess.SetIdentScreenName(state.NewIdentScreenName(input.From))
 	sess.SetDisplayScreenName(state.DisplayScreenName(input.From))
@@ -1499,6 +1510,10 @@ func postLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManage
 		errorMsg(w, "linked account already exists", http.StatusConflict)
 		return
 	}
+	if len(fbList.LinkedScreenNames()) >= state.MaxLinkedScreenNames {
+		errorMsg(w, fmt.Sprintf("user already has the maximum of %d linked accounts", state.MaxLinkedScreenNames), http.StatusConflict)
+		return
+	}
 	fbList.AddLinkedScreenName(body.LinkedScreenName)
 
 	if pending := fbList.PendingUpdates(); len(pending) > 0 {

+ 166 - 44
server/http/mgmt_api_test.go

@@ -1816,36 +1816,47 @@ func TestPrivateChatHandler_GET(t *testing.T) {
 }
 
 func TestInstantMessageHandler_POST(t *testing.T) {
+	senderExists := getUserParams{
+		{
+			screenName: state.NewIdentScreenName("sender_sn"),
+			result:     &state.User{IdentScreenName: state.NewIdentScreenName("sender_sn")},
+		},
+	}
+
 	tt := []struct {
-		name       string
-		body       string
-		wantText   string
-		wantStore  bool
-		sendCalled bool
-		reply      *wire.SNACMessage
-		sendErr    error
-		want       string
-		statusCode int
+		name          string
+		body          string
+		getUserParams getUserParams
+		wantText      string
+		wantStore     bool
+		sendCalled    bool
+		reply         *wire.SNACMessage
+		sendErr       error
+		want          string
+		statusCode    int
 	}{
 		{
-			name:       "send an instant message",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
-			sendCalled: true,
-			want:       `Message sent successfully.`,
-			statusCode: http.StatusOK,
+			name:          "send an instant message",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
+			getUserParams: senderExists,
+			sendCalled:    true,
+			want:          `Message sent successfully.`,
+			statusCode:    http.StatusOK,
 		},
 		{
-			name:       "send an instant message with non-ASCII text",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"héllo wörld 日本 😀"}`,
-			wantText:   "héllo wörld 日本 \uFFFD",
-			sendCalled: true,
-			want:       `Message sent successfully.`,
-			statusCode: http.StatusOK,
+			name:          "send an instant message with non-ASCII text",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"héllo wörld 日本 😀"}`,
+			wantText:      "héllo wörld 日本 \uFFFD",
+			getUserParams: senderExists,
+			sendCalled:    true,
+			want:          `Message sent successfully.`,
+			statusCode:    http.StatusOK,
 		},
 		{
-			name:       "recipient is offline",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
-			sendCalled: true,
+			name:          "recipient is offline",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
+			getUserParams: senderExists,
+			sendCalled:    true,
 			reply: &wire.SNACMessage{
 				Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
 				Body:  wire.SNACError{Code: wire.ErrorCodeNotLoggedOn},
@@ -1854,18 +1865,20 @@ func TestInstantMessageHandler_POST(t *testing.T) {
 			statusCode: http.StatusNotFound,
 		},
 		{
-			name:       "store message for offline recipient",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":true}`,
-			wantStore:  true,
-			sendCalled: true,
-			want:       `Message sent successfully.`,
-			statusCode: http.StatusOK,
+			name:          "store message for offline recipient",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":true}`,
+			wantStore:     true,
+			getUserParams: senderExists,
+			sendCalled:    true,
+			want:          `Message sent successfully.`,
+			statusCode:    http.StatusOK,
 		},
 		{
-			name:       "offline recipient does not accept stored message",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":true}`,
-			wantStore:  true,
-			sendCalled: true,
+			name:          "offline recipient does not accept stored message",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":true}`,
+			wantStore:     true,
+			getUserParams: senderExists,
+			sendCalled:    true,
 			reply: &wire.SNACMessage{
 				Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
 				Body:  wire.SNACError{Code: wire.ErrorCodeNotLoggedOn},
@@ -1874,9 +1887,10 @@ func TestInstantMessageHandler_POST(t *testing.T) {
 			statusCode: http.StatusNotFound,
 		},
 		{
-			name:       "do not store message for offline recipient",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":false}`,
-			sendCalled: true,
+			name:          "do not store message for offline recipient",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":false}`,
+			getUserParams: senderExists,
+			sendCalled:    true,
 			reply: &wire.SNACMessage{
 				Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
 				Body:  wire.SNACError{Code: wire.ErrorCodeNotLoggedOn},
@@ -1885,9 +1899,10 @@ func TestInstantMessageHandler_POST(t *testing.T) {
 			statusCode: http.StatusNotFound,
 		},
 		{
-			name:       "sender is blocked",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
-			sendCalled: true,
+			name:          "sender is blocked",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
+			getUserParams: senderExists,
+			sendCalled:    true,
 			reply: &wire.SNACMessage{
 				Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
 				Body:  wire.SNACError{Code: wire.ErrorCodeInLocalPermitDeny},
@@ -1896,10 +1911,35 @@ func TestInstantMessageHandler_POST(t *testing.T) {
 			statusCode: http.StatusForbidden,
 		},
 		{
-			name:       "send fails",
-			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
-			sendCalled: true,
-			sendErr:    io.EOF,
+			name:          "send fails",
+			body:          `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
+			getUserParams: senderExists,
+			sendCalled:    true,
+			sendErr:       io.EOF,
+			want:          `internal server error`,
+			statusCode:    http.StatusInternalServerError,
+		},
+		{
+			name: "sender does not exist",
+			body: `{"from":"sender_sn","to":"recip_sn","text":"hello world!","storeOffline":true}`,
+			getUserParams: getUserParams{
+				{
+					screenName: state.NewIdentScreenName("sender_sn"),
+					result:     nil,
+				},
+			},
+			want:       `sender does not exist`,
+			statusCode: http.StatusBadRequest,
+		},
+		{
+			name: "sender lookup fails",
+			body: `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
+			getUserParams: getUserParams{
+				{
+					screenName: state.NewIdentScreenName("sender_sn"),
+					err:        io.EOF,
+				},
+			},
 			want:       `internal server error`,
 			statusCode: http.StatusInternalServerError,
 		},
@@ -1916,6 +1956,13 @@ func TestInstantMessageHandler_POST(t *testing.T) {
 			request := httptest.NewRequest(http.MethodPost, "/instant-message", strings.NewReader(tc.body))
 			responseRecorder := httptest.NewRecorder()
 
+			userManager := newMockUserManager(t)
+			for _, params := range tc.getUserParams {
+				userManager.EXPECT().
+					User(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+
 			sendCalled := false
 			icbmSender := func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
 				sendCalled = true
@@ -1959,7 +2006,7 @@ func TestInstantMessageHandler_POST(t *testing.T) {
 				return tc.reply, tc.sendErr
 			}
 
-			postInstantMessageHandler(responseRecorder, request, icbmSender, slog.Default())
+			postInstantMessageHandler(responseRecorder, request, userManager, icbmSender, slog.Default())
 
 			assert.Equal(t, tc.sendCalled, sendCalled)
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
@@ -5476,6 +5523,81 @@ func TestPostLinkedAccountHandler(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:       "link below cap returns 201",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"test2"}`,
+			statusCode: http.StatusCreated,
+			want:       ``,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+						{
+							screenName: state.NewIdentScreenName("test2"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test2")},
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result: []wire.FeedbagItem{
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link1", ItemID: 1},
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link2", ItemID: 2},
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link3", ItemID: 3},
+							},
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							items: []wire.FeedbagItem{
+								{ItemID: 4, ClassID: wire.FeedbagClassIdAlInfo, Name: "test2"},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "link cap reached returns 409",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"test2"}`,
+			statusCode: http.StatusConflict,
+			want:       `{"message":"user already has the maximum of 4 linked accounts"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+						{
+							screenName: state.NewIdentScreenName("test2"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test2")},
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result: []wire.FeedbagItem{
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link1", ItemID: 1},
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link2", ItemID: 2},
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link3", ItemID: 3},
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "link4", ItemID: 4},
+							},
+						},
+					},
+				},
+			},
+		},
 		{
 			name:       "insert error returns 500",
 			screenname: "test1",

+ 4 - 0
state/feedbag_list.go

@@ -10,6 +10,10 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
+// MaxLinkedScreenNames is the most linked screen names a feedbag may hold, as
+// advertised to clients in the feedbag rights reply.
+const MaxLinkedScreenNames = 4
+
 // ErrGroupNotFound is returned when a feedbag group cannot be found.
 var ErrGroupNotFound = errors.New("group not found")