Files
coder/coderd/inboxnotifications_test.go
Ehab Younes f0e2fce326 fix: let scoped tokens list and mark inbox notifications read (#30176)
`inbox_notification:read` and `inbox_notification:update` existed but weren't
in the public scope catalog, so only `coder:all` tokens could use the inbox.
The inbox endpoints also returned 500s or a clean socket close when a scope
was missing, instead of a 403.

- Add `inbox_notification:read` and `inbox_notification:update` to the public
  scope catalog. `create` stays internal, so there is no
  `inbox_notification:*`. No migration is needed.
- Every inbox endpoint checks scopes up front and returns 403 naming the
  missing scope. The watch endpoint checks before the websocket upgrade.
- `read-status` requires both `read` and `update`. It returns 404 unless the
  notification belongs to the caller, so an owner can no longer mark another
  user's notification read.
2026-10-06 13:01:11 +03:00

1093 lines
38 KiB
Go

package coderd_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/websocket"
)
const (
inboxNotificationsPageSize = 25
)
var failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16")
func TestInboxNotification_Watch(t *testing.T) {
t.Parallel()
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
// For now the idea is that the runner takes too long to insert the entries, could be worth
// investigating a manual Tx.
// see: https://github.com/coder/internal/issues/503
if runtime.GOOS == "windows" {
t.Skip("our runners are randomly taking too long to insert entries")
}
t.Run("Failure Modes", func(t *testing.T) {
tests := []struct {
name string
expectedError string
listTemplate string
listTarget string
listReadStatus string
listStartingBefore string
}{
{"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""},
{"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""},
{"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/notifications/inbox/watch", nil,
codersdk.ListInboxNotificationsRequestToQueryParams(codersdk.ListInboxNotificationsRequest{
Targets: tt.listTarget,
Templates: tt.listTemplate,
ReadStatus: tt.listReadStatus,
StartingBefore: tt.listStartingBefore,
})...)
require.NoError(t, err)
defer resp.Body.Close()
err = codersdk.ReadBodyAsError(resp)
require.ErrorContains(t, err, tt.expectedError)
})
}
})
t.Run("NOK - missing scope", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, client)
client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead},
})
require.NoError(t, err)
u, err := client.URL.Parse("/api/v2/notifications/inbox/watch")
require.NoError(t, err)
// nolint:bodyclose
_, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{token.Key},
},
})
require.Error(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Pubsub: ps,
Database: db,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
token, err := member.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeInboxNotificationRead},
})
require.NoError(t, err)
u, err := member.URL.Parse("/api/v2/notifications/inbox/watch")
require.NoError(t, err)
// nolint:bodyclose
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{token.Key},
},
})
if err != nil {
if resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
}, "notification title", "notification content", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err := wsConn.Read(ctx)
require.NoError(t, err)
var notif codersdk.GetInboxNotificationResponse
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 1, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
// check for the fallback icon logic
require.Equal(t, codersdk.InboxNotificationFallbackIconWorkspace, notif.Notification.Icon)
})
t.Run("OK - change format", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Pubsub: ps,
Database: db,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
u, err := member.URL.Parse("/api/v2/notifications/inbox/watch?format=plaintext")
require.NoError(t, err)
// nolint:bodyclose
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{member.SessionToken()},
},
})
if err != nil {
if resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
}, "# Notification Title", "This is the __content__.", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err := wsConn.Read(ctx)
require.NoError(t, err)
var notif codersdk.GetInboxNotificationResponse
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 1, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
require.Equal(t, "Notification Title", notif.Notification.Title)
require.Equal(t, "This is the content.", notif.Notification.Content)
})
t.Run("OK - filters on templates", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Pubsub: ps,
Database: db,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?templates=%v", notifications.TemplateWorkspaceOutOfMemory))
require.NoError(t, err)
// nolint:bodyclose
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{member.SessionToken()},
},
})
if err != nil {
if resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
}, "memory related title", "memory related content", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err := wsConn.Read(ctx)
require.NoError(t, err)
var notif codersdk.GetInboxNotificationResponse
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 1, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
require.Equal(t, "memory related title", notif.Notification.Title)
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfDisk.String(),
}, "disk related title", "disk related title", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
}, "second memory related title", "second memory related title", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err = wsConn.Read(ctx)
require.NoError(t, err)
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 3, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
require.Equal(t, "second memory related title", notif.Notification.Title)
})
t.Run("OK - filters on targets", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Pubsub: ps,
Database: db,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
correctTarget := uuid.New()
u, err := member.URL.Parse(fmt.Sprintf("/api/v2/notifications/inbox/watch?targets=%v", correctTarget.String()))
require.NoError(t, err)
// nolint:bodyclose
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{member.SessionToken()},
},
})
if err != nil {
if resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
Targets: []uuid.UUID{correctTarget},
}, "memory related title", "memory related content", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err := wsConn.Read(ctx)
require.NoError(t, err)
var notif codersdk.GetInboxNotificationResponse
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 1, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
require.Equal(t, "memory related title", notif.Notification.Title)
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
Targets: []uuid.UUID{uuid.New()},
}, "second memory related title", "second memory related title", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
dispatchFunc, err = inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
Targets: []uuid.UUID{correctTarget},
}, "another memory related title", "another memory related title", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err = wsConn.Read(ctx)
require.NoError(t, err)
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 3, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
require.Equal(t, "another memory related title", notif.Notification.Title)
})
}
func TestInboxNotifications_List(t *testing.T) {
t.Parallel()
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
// For now the idea is that the runner takes too long to insert the entries, could be worth
// investigating a manual Tx.
// see: https://github.com/coder/internal/issues/503
if runtime.GOOS == "windows" {
t.Skip("our runners are randomly taking too long to insert entries")
}
t.Run("Failure Modes", func(t *testing.T) {
tests := []struct {
name string
expectedError string
listTemplate string
listTarget string
listReadStatus string
listStartingBefore string
}{
{"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""},
{"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""},
{"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""},
{"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
// create a new notifications to fill the database with data
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
Templates: tt.listTemplate,
Targets: tt.listTarget,
ReadStatus: tt.listReadStatus,
StartingBefore: tt.listStartingBefore,
})
require.ErrorContains(t, err, tt.expectedError)
require.Empty(t, notifs.Notifications)
require.Zero(t, notifs.UnreadCount)
})
}
})
t.Run("OK empty", func(t *testing.T) {
t.Parallel()
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, _ = coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
})
t.Run("OK with pagination", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 40 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 40, notifs.UnreadCount)
require.Len(t, notifs.Notifications, inboxNotificationsPageSize)
require.Equal(t, "Notification 39", notifs.Notifications[0].Title)
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID.String(),
})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 40, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 15)
require.Equal(t, "Notification 14", notifs.Notifications[0].Title)
})
t.Run("OK check icons", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: func() uuid.UUID {
switch i {
case 0:
return notifications.TemplateWorkspaceCreated
case 1:
return notifications.TemplateWorkspaceMarkedForDeletion
case 2:
return notifications.TemplateUserAccountActivated
case 3:
return notifications.TemplateTemplateDeprecated
default:
return notifications.TemplateTestNotification
}
}(),
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Icon: func() string {
if i == 9 {
return "https://dev.coder.com/icon.png"
}
return ""
}(),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 10, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 10)
require.Equal(t, "https://dev.coder.com/icon.png", notifs.Notifications[0].Icon)
require.Equal(t, codersdk.InboxNotificationFallbackIconWorkspace, notifs.Notifications[9].Icon)
require.Equal(t, codersdk.InboxNotificationFallbackIconWorkspace, notifs.Notifications[8].Icon)
require.Equal(t, codersdk.InboxNotificationFallbackIconAccount, notifs.Notifications[7].Icon)
require.Equal(t, codersdk.InboxNotificationFallbackIconTemplate, notifs.Notifications[6].Icon)
require.Equal(t, codersdk.InboxNotificationFallbackIconOther, notifs.Notifications[4].Icon)
})
t.Run("OK with template filter", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: func() uuid.UUID {
if i%2 == 0 {
return notifications.TemplateWorkspaceOutOfMemory
}
return notifications.TemplateWorkspaceOutOfDisk
}(),
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
Templates: notifications.TemplateWorkspaceOutOfMemory.String(),
})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 10, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 5)
require.Equal(t, "Notification 8", notifs.Notifications[0].Title)
require.Equal(t, codersdk.InboxNotificationFallbackIconWorkspace, notifs.Notifications[0].Icon)
})
t.Run("OK with target filter", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
filteredTarget := uuid.New()
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Targets: func() []uuid.UUID {
if i%2 == 0 {
return []uuid.UUID{filteredTarget}
}
return []uuid.UUID{}
}(),
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
Targets: filteredTarget.String(),
})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 10, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 5)
require.Equal(t, "Notification 8", notifs.Notifications[0].Title)
})
t.Run("OK with multiple filters", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
filteredTarget := uuid.New()
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: func() uuid.UUID {
if i < 5 {
return notifications.TemplateWorkspaceOutOfMemory
}
return notifications.TemplateWorkspaceOutOfDisk
}(),
Targets: func() []uuid.UUID {
if i%2 == 0 {
return []uuid.UUID{filteredTarget}
}
return []uuid.UUID{}
}(),
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
Targets: filteredTarget.String(),
Templates: notifications.TemplateWorkspaceOutOfDisk.String(),
})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 10, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 2)
require.Equal(t, "Notification 8", notifs.Notifications[0].Title)
})
}
func TestInboxNotifications_ReadStatus(t *testing.T) {
t.Parallel()
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
// For now the idea is that the runner takes too long to insert the entries, could be worth
// investigating a manual Tx.
// see: https://github.com/coder/internal/issues/503
if runtime.GOOS == "windows" {
t.Skip("our runners are randomly taking too long to insert entries")
}
t.Run("ok", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)
updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
require.NoError(t, err)
require.NotNil(t, updatedNotif)
require.NotZero(t, updatedNotif.Notification.ReadAt)
require.Equal(t, 19, updatedNotif.UnreadCount)
updatedNotif, err = client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: false,
})
require.NoError(t, err)
require.NotNil(t, updatedNotif)
require.Nil(t, updatedNotif.Notification.ReadAt)
require.Equal(t, 20, updatedNotif.UnreadCount)
})
t.Run("NOK - wrong id", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)
updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, "xxx-xxx-xxx", codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
require.ErrorContains(t, err, `Invalid UUID "xxx-xxx-xxx"`)
require.Equal(t, 0, updatedNotif.UnreadCount)
require.Empty(t, updatedNotif.Notification)
})
t.Run("NOK - unknown id", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)
updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, failingPaginationUUID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Equal(t, 0, updatedNotif.UnreadCount)
require.Empty(t, updatedNotif.Notification)
})
t.Run("NOK - other user's notification", func(t *testing.T) {
t.Parallel()
ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
notif := dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: "Notification",
Actions: json.RawMessage("[]"),
Content: "Content",
CreatedAt: dbtime.Now(),
})
// Owners can update any inbox, but this endpoint only acts on their own.
updatedNotif, err := ownerClient.UpdateInboxNotificationReadStatus(ctx, notif.ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, updatedNotif.Notification)
notifs, err := memberClient.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.Len(t, notifs.Notifications, 1)
require.Nil(t, notifs.Notifications[0].ReadAt)
})
}
func TestInboxNotifications_MarkAllAsRead(t *testing.T) {
t.Parallel()
// I skip these tests specifically on windows as for now they are flaky - only on Windows.
// For now the idea is that the runner takes too long to insert the entries, could be worth
// investigating a manual Tx.
// see: https://github.com/coder/internal/issues/503
if runtime.GOOS == "windows" {
t.Skip("our runners are randomly taking too long to insert entries")
}
t.Run("ok", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)
err = client.MarkAllInboxNotificationsAsRead(ctx)
require.NoError(t, err)
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)
for i := range 10 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}
notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 10, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 25)
})
}
func TestInboxNotifications_Scopes(t *testing.T) {
t.Parallel()
var (
read = codersdk.APIKeyScopeInboxNotificationRead
update = codersdk.APIKeyScopeInboxNotificationUpdate
)
// Unreadable rows are filtered out, not refused, so check the contents.
listInbox := func(ctx context.Context, client *codersdk.Client, id uuid.UUID) error {
res, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
if err != nil {
return err
}
if len(res.Notifications) != 1 || res.Notifications[0].ID != id || res.UnreadCount != 1 {
return xerrors.Errorf("want notification %s with 1 unread, got %+v", id, res)
}
return nil
}
markRead := func(ctx context.Context, client *codersdk.Client, id uuid.UUID) error {
res, err := client.UpdateInboxNotificationReadStatus(ctx, id.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
if err != nil {
return err
}
if res.Notification.ID != id || res.Notification.ReadAt == nil || res.UnreadCount != 0 {
return xerrors.Errorf("want notification %s read with 0 unread, got %+v", id, res)
}
return nil
}
markAllRead := func(ctx context.Context, client *codersdk.Client, _ uuid.UUID) error {
return client.MarkAllInboxNotificationsAsRead(ctx)
}
tests := []struct {
name string
scopes []codersdk.APIKeyScope
call func(context.Context, *codersdk.Client, uuid.UUID) error
missing codersdk.APIKeyScope
wantMarkedRead bool
}{
{name: "OK - list", scopes: []codersdk.APIKeyScope{read}, call: listInbox},
{name: "NOK - list - missing read", scopes: []codersdk.APIKeyScope{update}, call: listInbox, missing: read},
{name: "OK - read status", scopes: []codersdk.APIKeyScope{read, update}, call: markRead, wantMarkedRead: true},
{name: "NOK - read status - missing read", scopes: []codersdk.APIKeyScope{update}, call: markRead, missing: read},
{name: "NOK - read status - missing update", scopes: []codersdk.APIKeyScope{read}, call: markRead, missing: update},
{name: "OK - mark all", scopes: []codersdk.APIKeyScope{update}, call: markAllRead, wantMarkedRead: true},
{name: "NOK - mark all - missing update", scopes: []codersdk.APIKeyScope{read}, call: markAllRead, missing: update},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, nil)
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
notif := dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: "Notification",
Actions: json.RawMessage("[]"),
Content: "Content",
CreatedAt: dbtime.Now(),
})
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scopes: tt.scopes,
})
require.NoError(t, err)
scoped := codersdk.New(client.URL)
scoped.SetSessionToken(token.Key)
err = tt.call(ctx, scoped, notif.ID)
if tt.missing != "" {
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
require.Contains(t, sdkErr.Detail, string(tt.missing))
} else {
require.NoError(t, err)
}
notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.Len(t, notifs.Notifications, 1)
require.Equal(t, tt.wantMarkedRead, notifs.Notifications[0].ReadAt != nil)
})
}
}