Files
notifier/internal/service/service_tenant_test.go
igodwin eda033ff9b
CI / Lint (push) Successful in 2m29s
Build and Publish Container / build-and-publish (push) Successful in 2m58s
CI / Vulnerability scan (push) Successful in 44s
CI / Test (push) Successful in 1m45s
fix: clear golangci-lint backlog and make lint job blocking
Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').

With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:32:51 -07:00

195 lines
6.5 KiB
Go

package service
import (
"context"
"fmt"
"testing"
"github.com/google/uuid"
"github.com/igodwin/notifier/internal/auth"
"github.com/igodwin/notifier/internal/domain"
)
// ctxForClient builds a context carrying an auth.Context for the given
// client and roles, as REST/gRPC middleware would attach after authenticating
// a request.
func ctxForClient(clientID string, roles ...string) context.Context {
return auth.ContextWithAuth(context.Background(), &auth.Context{
ClientID: clientID,
Roles: roles,
})
}
// TestTenantScopingNonAdminSeesOnlyOwnNotifications verifies that a
// non-admin caller only sees notifications stamped with their own ClientID.
func TestTenantScopingNonAdminSeesOnlyOwnNotifications(t *testing.T) {
svc := createTestService(t)
tenantA := &domain.Notification{
ID: uuid.New().String(),
Type: domain.TypeStdout,
Status: domain.StatusSent,
Recipients: []string{"a@example.com"},
ClientID: "tenant-a",
}
tenantB := &domain.Notification{
ID: uuid.New().String(),
Type: domain.TypeStdout,
Status: domain.StatusSent,
Recipients: []string{"b@example.com"},
ClientID: "tenant-b",
}
svc.storeNotification(tenantA)
svc.storeNotification(tenantB)
ctxA := ctxForClient("tenant-a", "user")
list, err := svc.ListNotifications(ctxA, &domain.NotificationFilter{})
if err != nil {
t.Fatalf("ListNotifications failed: %v", err)
}
if len(list) != 1 || list[0].ID != tenantA.ID {
t.Fatalf("expected tenant-a to see only its own notification, got %d results", len(list))
}
if _, err := svc.GetNotification(ctxA, tenantA.ID); err != nil {
t.Errorf("tenant-a should be able to get its own notification: %v", err)
}
if _, err := svc.GetNotification(ctxA, tenantB.ID); err == nil {
t.Error("tenant-a should not be able to get tenant-b's notification")
}
stats, err := svc.GetStats(ctxA)
if err != nil {
t.Fatalf("GetStats failed: %v", err)
}
if stats.TotalSent != 1 {
t.Errorf("expected tenant-a stats to count only its own notification, got %d", stats.TotalSent)
}
}
// TestTenantScopingAdminSeesAll verifies that a caller with the admin role
// can see notifications belonging to any tenant.
func TestTenantScopingAdminSeesAll(t *testing.T) {
svc := createTestService(t)
tenantA := &domain.Notification{ID: uuid.New().String(), Type: domain.TypeStdout, Status: domain.StatusSent, Recipients: []string{"a@example.com"}, ClientID: "tenant-a"}
tenantB := &domain.Notification{ID: uuid.New().String(), Type: domain.TypeStdout, Status: domain.StatusSent, Recipients: []string{"b@example.com"}, ClientID: "tenant-b"}
svc.storeNotification(tenantA)
svc.storeNotification(tenantB)
adminCtx := ctxForClient("admin-client", "admin")
list, err := svc.ListNotifications(adminCtx, &domain.NotificationFilter{})
if err != nil {
t.Fatalf("ListNotifications failed: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected admin to see both notifications, got %d", len(list))
}
if _, err := svc.GetNotification(adminCtx, tenantA.ID); err != nil {
t.Errorf("admin should be able to get tenant-a's notification: %v", err)
}
if _, err := svc.GetNotification(adminCtx, tenantB.ID); err != nil {
t.Errorf("admin should be able to get tenant-b's notification: %v", err)
}
stats, err := svc.GetStats(adminCtx)
if err != nil {
t.Fatalf("GetStats failed: %v", err)
}
if stats.TotalSent != 2 {
t.Errorf("expected admin stats to count both notifications, got %d", stats.TotalSent)
}
}
// TestTenantScopingAuthDisabledUnchanged verifies that when no auth context
// is present (auth disabled), behavior is unchanged: every notification is
// visible regardless of ClientID.
func TestTenantScopingAuthDisabledUnchanged(t *testing.T) {
svc := createTestService(t)
tenantA := &domain.Notification{ID: uuid.New().String(), Type: domain.TypeStdout, Status: domain.StatusSent, Recipients: []string{"a@example.com"}, ClientID: "tenant-a"}
tenantB := &domain.Notification{ID: uuid.New().String(), Type: domain.TypeStdout, Status: domain.StatusSent, Recipients: []string{"b@example.com"}, ClientID: ""}
svc.storeNotification(tenantA)
svc.storeNotification(tenantB)
ctx := context.Background() // no auth context attached
list, err := svc.ListNotifications(ctx, &domain.NotificationFilter{})
if err != nil {
t.Fatalf("ListNotifications failed: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected both notifications visible when auth is disabled, got %d", len(list))
}
if _, err := svc.GetNotification(ctx, tenantA.ID); err != nil {
t.Errorf("expected to get tenant-a's notification with auth disabled: %v", err)
}
if _, err := svc.GetNotification(ctx, tenantB.ID); err != nil {
t.Errorf("expected to get tenant-b's notification with auth disabled: %v", err)
}
}
// TestTenantScopingCrossTenantAccessReturnsNotFound verifies that
// Get/Cancel/Retry on another tenant's notification return the exact same
// not-found error as a genuinely missing ID, so existence isn't leaked.
func TestTenantScopingCrossTenantAccessReturnsNotFound(t *testing.T) {
svc := createTestService(t)
tenantA := &domain.Notification{
ID: uuid.New().String(),
Type: domain.TypeStdout,
Status: domain.StatusPending,
Recipients: []string{"a@example.com"},
MaxRetries: 3,
ClientID: "tenant-a",
}
svc.storeNotification(tenantA)
ctxB := ctxForClient("tenant-b", "user")
wantMsg := fmt.Sprintf("notification not found: %s", tenantA.ID)
_, getErr := svc.GetNotification(ctxB, tenantA.ID)
if getErr == nil {
t.Fatal("expected not-found error for cross-tenant Get")
}
if getErr.Error() != wantMsg {
t.Errorf("expected cross-tenant Get error %q, got %q", wantMsg, getErr.Error())
}
_, missingErr := svc.GetNotification(ctxB, "does-not-exist")
if missingErr == nil {
t.Fatal("expected not-found error for missing ID")
}
cancelErr := svc.CancelNotification(ctxB, tenantA.ID)
if cancelErr == nil {
t.Fatal("expected not-found error for cross-tenant Cancel")
}
if cancelErr.Error() != wantMsg {
t.Errorf("expected cross-tenant Cancel error %q, got %q", wantMsg, cancelErr.Error())
}
_, retryErr := svc.RetryNotification(ctxB, tenantA.ID)
if retryErr == nil {
t.Fatal("expected not-found error for cross-tenant Retry")
}
if retryErr.Error() != wantMsg {
t.Errorf("expected cross-tenant Retry error %q, got %q", wantMsg, retryErr.Error())
}
// The owning tenant should still be able to see and act on it.
ctxA := ctxForClient("tenant-a", "user")
if _, err := svc.GetNotification(ctxA, tenantA.ID); err != nil {
t.Errorf("tenant-a should still be able to get its own notification: %v", err)
}
}