Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ func (m *mockConfigStore) GetRIUtilizationCache(_ context.Context, _ string, _ i
func (m *mockConfigStore) UpsertRIUtilizationCache(_ context.Context, _ string, _ int, _ []byte, _ time.Time) error {
return nil
}
func (m *mockConfigStore) UpsertNotificationMute(_ context.Context, _, _, _ string) error {
return nil
}
func (m *mockConfigStore) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) {
return false, nil
}

// TestNewCollector tests the NewCollector function
func TestNewCollector(t *testing.T) {
Expand Down
126 changes: 126 additions & 0 deletions internal/api/handler_notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package api

import (
"context"
"fmt"
"html/template"
"os"
"strings"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/logging"
"github.com/aws/aws-lambda-go/events"
)

// mutePageCSP is the Content-Security-Policy for the unsubscribe confirmation
// page. The page is intentionally minimal (no external scripts or styles), so
// we can lock it down tightly.
const mutePageCSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"

// unsubscribeConfirmTmpl is the HTML confirmation page rendered after a
// successful one-click unsubscribe. No user-supplied data is interpolated via
// {{.}} without html/template escaping, so there is no XSS vector.
var unsubscribeConfirmTmpl = template.Must(template.New("unsub").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unsubscribed</title>
<style>
body{font-family:sans-serif;max-width:480px;margin:80px auto;padding:0 1rem;color:#222}
h1{font-size:1.4rem}
p{line-height:1.6}
</style>
</head>
<body>
<h1>You have been unsubscribed.</h1>
<p>You will no longer receive <strong>{{.ScopeLabel}}</strong> emails at this address.</p>
<p>This preference is saved. You do not need to click again.</p>
</body>
</html>
`))

// scopeLabel returns a human-readable label for a notification scope.
func scopeLabel(scope string) string {
switch scope {
case string(common.ScopePurchaseApprovals):
return "purchase approval request"
case string(common.ScopeRIExchangeApprovals):
return "RI exchange approval request"
default:
return "notification"
}
}

// muteSecretKey loads the NOTIFICATION_MUTE_SECRET env var as bytes. Returns
// nil when unset, which causes DeriveMuteToken to fall back to its dev key.
func muteSecretKey() []byte {
v := os.Getenv("NOTIFICATION_MUTE_SECRET")
if v == "" {
return nil
}
return []byte(v)
}

// unsubscribeHandler handles GET /api/notifications/unsubscribe.
// The URL carries a signed token that encodes (email, scope); the handler
// verifies the HMAC, upserts the mute row, and returns a confirmation page.
//
// Auth: AuthPublic (token-based, no login required — mirrors approve/cancel).
func (h *Handler) unsubscribeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) {
token := req.QueryStringParameters["token"]
email := req.QueryStringParameters["email"]
scope := req.QueryStringParameters["scope"]

if token == "" || email == "" || scope == "" {
return nil, NewClientError(400, "token, email and scope are required")
}

// Reject unknown scopes early so we never create phantom rows.
validScope := scope == string(common.ScopePurchaseApprovals) ||
scope == string(common.ScopeRIExchangeApprovals)
if !validScope {
return nil, NewClientError(400, fmt.Sprintf("unknown notification scope: %s", scope))
}

if !common.VerifyMuteToken(muteSecretKey(), email, scope, token) {
logging.Warnf("notifications/unsubscribe: invalid token for scope=%s", scope)
return nil, NewClientError(401, "invalid or expired unsubscribe token")
}

if err := h.config.UpsertNotificationMute(ctx, email, scope, token); err != nil {
logging.Errorf("notifications/unsubscribe: store error: %v", err)
return nil, fmt.Errorf("could not save unsubscribe preference: %w", err)
}

logging.Infof("notifications/unsubscribe: muted scope=%s for %s", scope, redactEmailLocal(email))

var buf strings.Builder
if err := unsubscribeConfirmTmpl.Execute(&buf, struct{ ScopeLabel string }{
ScopeLabel: scopeLabel(scope),
}); err != nil {
return nil, fmt.Errorf("render unsubscribe page: %w", err)
}

return &rawResponse{
contentType: "text/html; charset=utf-8",
body: buf.String(),
csp: mutePageCSP,
}, nil
}

// redactEmailLocal returns just the domain part with the local masked, e.g.
// "us***@example.com". Reuses the same masking logic as email/sender.go but
// without importing that package into api (avoids a dependency cycle).
func redactEmailLocal(email string) string {
at := strings.LastIndex(email, "@")
if at < 0 {
return "***"
}
local := email[:at]
domain := email[at:] // includes '@'
if len(local) <= 2 {
return "***" + domain
}
return local[:2] + "***" + domain
}
207 changes: 207 additions & 0 deletions internal/api/handler_notifications_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package api

import (
"context"
"strings"
"testing"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// ---------------------------------------------------------------------------
// GET /api/notifications/unsubscribe
// ---------------------------------------------------------------------------

func validUnsubToken(email, scope string) string {
return common.DeriveMuteToken(nil, email, scope)
}

func TestUnsubscribeHandler_Success(t *testing.T) {
ctx := context.Background()
email := "user@example.com"
scope := string(common.ScopePurchaseApprovals)
token := validUnsubToken(email, scope)

mockStore := new(MockConfigStore)
mockStore.On("UpsertNotificationMute", ctx, email, scope, token).Return(nil)
t.Cleanup(func() { mockStore.AssertExpectations(t) })

h := &Handler{config: mockStore}
r := newTestRouter(h)

req := &events.LambdaFunctionURLRequest{
QueryStringParameters: map[string]string{
"token": token,
"email": email,
"scope": scope,
},
}
result, err := r.unsubscribeHandler(ctx, req, nil)
require.NoError(t, err)
raw, ok := result.(*rawResponse)
require.True(t, ok, "expected *rawResponse")
assert.Equal(t, "text/html; charset=utf-8", raw.contentType)
assert.Contains(t, raw.body, "Unsubscribed")
assert.Contains(t, raw.body, "purchase approval request")
}

func TestUnsubscribeHandler_ForgedToken_Returns401(t *testing.T) {
ctx := context.Background()
req := &events.LambdaFunctionURLRequest{
QueryStringParameters: map[string]string{
"token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"email": "attacker@example.com",
"scope": string(common.ScopePurchaseApprovals),
},
}
h := &Handler{config: new(MockConfigStore)}
r := newTestRouter(h)

_, err := r.unsubscribeHandler(ctx, req, nil)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}

func TestUnsubscribeHandler_MissingParams_Returns400(t *testing.T) {
ctx := context.Background()
h := &Handler{config: new(MockConfigStore)}
r := newTestRouter(h)

req := &events.LambdaFunctionURLRequest{
QueryStringParameters: map[string]string{
"token": "something",
// email and scope missing
},
}
_, err := r.unsubscribeHandler(ctx, req, nil)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 400, ce.code)
}

func TestUnsubscribeHandler_UnknownScope_Returns400(t *testing.T) {
ctx := context.Background()
email := "user@example.com"
scope := "unknown_scope"
// Use the dev-key token so HMAC passes but scope guard fires first.
token := common.DeriveMuteToken(nil, email, scope)

req := &events.LambdaFunctionURLRequest{
QueryStringParameters: map[string]string{
"token": token,
"email": email,
"scope": scope,
},
}
h := &Handler{config: new(MockConfigStore)}
r := newTestRouter(h)

_, err := r.unsubscribeHandler(ctx, req, nil)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 400, ce.code)
}

func TestUnsubscribeHandler_StoreError_Returns500(t *testing.T) {
ctx := context.Background()
email := "user@example.com"
scope := string(common.ScopePurchaseApprovals)
token := validUnsubToken(email, scope)

mockStore := new(MockConfigStore)
mockStore.On("UpsertNotificationMute", mock.Anything, email, scope, token).
Return(assert.AnError)
t.Cleanup(func() { mockStore.AssertExpectations(t) })

h := &Handler{config: mockStore}
r := newTestRouter(h)

req := &events.LambdaFunctionURLRequest{
QueryStringParameters: map[string]string{
"token": token,
"email": email,
"scope": scope,
},
}
_, err := r.unsubscribeHandler(ctx, req, nil)
require.Error(t, err)
_, isClient := IsClientError(err)
assert.False(t, isClient, "store error should be a 500, not a client error")
}

// ---------------------------------------------------------------------------
// scopeLabel helper
// ---------------------------------------------------------------------------

func TestScopeLabel_KnownScopes(t *testing.T) {
assert.Equal(t, "purchase approval request", scopeLabel(string(common.ScopePurchaseApprovals)))
assert.Equal(t, "RI exchange approval request", scopeLabel(string(common.ScopeRIExchangeApprovals)))
assert.Equal(t, "notification", scopeLabel("bogus"))
}

// ---------------------------------------------------------------------------
// redactEmailLocal
// ---------------------------------------------------------------------------

func TestRedactEmailLocal(t *testing.T) {
cases := []struct {
in, want string
}{
{"user@example.com", "us***@example.com"},
{"ab@x.com", "***@x.com"},
{"a@x.com", "***@x.com"},
{"noemail", "***"},
}
for _, c := range cases {
assert.Equal(t, c.want, redactEmailLocal(c.in), "input: %s", c.in)
}
}

// ---------------------------------------------------------------------------
// isPublicEndpoint includes /api/notifications/unsubscribe
// ---------------------------------------------------------------------------

func TestIsPublicEndpoint_UnsubscribePath(t *testing.T) {
h := &Handler{}
assert.True(t, h.isPublicEndpoint("/api/notifications/unsubscribe"))
assert.True(t, h.isPublicEndpoint("/api/notifications/unsubscribe?token=x&email=y&scope=z"))
}

// ---------------------------------------------------------------------------
// Route is registered (router smoke test)
// ---------------------------------------------------------------------------

func TestRouter_UnsubscribeRoute_Registered(t *testing.T) {
// Verify the route is wired: an unsigned token returns 401, which can only
// happen if the router dispatched to the correct handler.
ctx := context.Background()
h := &Handler{config: new(MockConfigStore)}
r := NewRouter(h)

req := &events.LambdaFunctionURLRequest{
RequestContext: events.LambdaFunctionURLRequestContext{
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{
Method: "GET",
Path: "/api/notifications/unsubscribe",
},
},
QueryStringParameters: map[string]string{
"token": strings.Repeat("a", 64),
"email": "user@example.com",
"scope": string(common.ScopePurchaseApprovals),
},
}
_, err := r.Route(ctx, "GET", "/api/notifications/unsubscribe", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}
1 change: 1 addition & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func (h *Handler) isPublicEndpoint(path string) bool {
"/api/auth/forgot-password",
"/api/auth/reset-password",
"/api/register/", // GET /api/register/:token (trailing slash avoids matching /api/registrations)
"/api/notifications/unsubscribe",
"/docs",
"/api/docs",
}
Expand Down
20 changes: 20 additions & 0 deletions internal/api/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,26 @@ func (m *MockConfigStore) WithTx(ctx context.Context, fn func(tx pgx.Tx) error)
return fn(nil)
}

// UpsertNotificationMute defaults to a no-op success so tests that do not
// exercise the mute path are unaffected.
func (m *MockConfigStore) UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error {
if !m.isExpected("UpsertNotificationMute") {
return nil
}
args := m.Called(ctx, recipientEmail, scope, unmuteToken)
return args.Error(0)
}

// IsNotificationMuted defaults to (false, nil) so callers that don't care
// proceed without being blocked by the mute check.
func (m *MockConfigStore) IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error) {
if !m.isExpected("IsNotificationMuted") {
return false, nil
}
args := m.Called(ctx, recipientEmail, scope)
return args.Bool(0), args.Error(1)
}

// isExpected returns true when at least one .On(method, ...) expectation
// has been registered on this mock. Lets us write "default no-op" stubs
// above that route through m.Called only when the test explicitly cares.
Expand Down
Loading
Loading