package wsauth import ( "context" "database/sql" "regexp" "testing" "github.com/DATA-DOG/go-sqlmock" ) func setupMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock) { t.Helper() db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) if err != nil { t.Fatalf("sqlmock.New: %v", err) } t.Cleanup(func() { db.Close() }) // The platform_inbound_secret cache is package-level state shared // across every test in this package — without a reset between // tests a write-through Issue from one test shadows the SELECT // expectation in the next test that touches the same workspaceID // (e.g. "ws-abc" reused across PersistsPlaintext + HappyPath). // Reset before each test that uses setupMock; the no-op cost on // pure-token tests is one Range over an empty sync.Map. ResetInboundSecretCacheForTesting() t.Cleanup(ResetInboundSecretCacheForTesting) return db, mock } // ------------------------------------------------------------ // IssueToken // ------------------------------------------------------------ func TestIssueToken_PersistsHashNotPlaintext(t *testing.T) { db, mock := setupMock(t) mock.ExpectExec(`INSERT INTO workspace_auth_tokens`). WithArgs( "ws-abc", sqlmock.AnyArg(), // hash (bytea) sqlmock.AnyArg(), // prefix ). WillReturnResult(sqlmock.NewResult(1, 1)) tok, err := IssueToken(context.Background(), db, "ws-abc") if err != nil { t.Fatalf("IssueToken: %v", err) } if len(tok) < 40 { t.Errorf("token looks too short to be 256-bit: len=%d", len(tok)) } // Standard base64url-no-padding alphabet only. if !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(tok) { t.Errorf("token contains non-urlsafe chars: %q", tok) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet expectations: %v", err) } } func TestIssueToken_ReturnsDifferentTokensEachCall(t *testing.T) { db, mock := setupMock(t) mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).WillReturnResult(sqlmock.NewResult(1, 1)) a, _ := IssueToken(context.Background(), db, "ws-1") b, _ := IssueToken(context.Background(), db, "ws-1") if a == b { t.Errorf("expected unique tokens across calls, got %q twice", a) } } // ------------------------------------------------------------ // ValidateToken // ------------------------------------------------------------ func TestValidateToken_HappyPath(t *testing.T) { db, mock := setupMock(t) // First insert a token we can validate. mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).WillReturnResult(sqlmock.NewResult(1, 1)) tok, err := IssueToken(context.Background(), db, "ws-xyz") if err != nil { t.Fatalf("IssueToken: %v", err) } // Validate: lookup by hash with removed-workspace JOIN returns matching row. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WithArgs(sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-id-1", "ws-xyz")) // Best-effort last_used_at update. mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`). WithArgs("tok-id-1"). WillReturnResult(sqlmock.NewResult(0, 1)) if err := ValidateToken(context.Background(), db, "ws-xyz", tok); err != nil { t.Errorf("expected valid token, got error: %v", err) } } func TestValidateToken_WrongWorkspaceRejected(t *testing.T) { db, mock := setupMock(t) // Token belongs to ws-owner; caller claims to be ws-attacker. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-id-2", "ws-owner")) err := ValidateToken(context.Background(), db, "ws-attacker", "some-token") if err != ErrInvalidToken { t.Errorf("expected ErrInvalidToken, got %v", err) } } func TestValidateToken_RejectsEmptyInputs(t *testing.T) { db, _ := setupMock(t) if err := ValidateToken(context.Background(), db, "", "x"); err != ErrInvalidToken { t.Errorf("empty workspace id: got %v, want ErrInvalidToken", err) } if err := ValidateToken(context.Background(), db, "ws-x", ""); err != ErrInvalidToken { t.Errorf("empty token: got %v, want ErrInvalidToken", err) } } func TestValidateToken_UnknownTokenRejected(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WillReturnError(sql.ErrNoRows) if err := ValidateToken(context.Background(), db, "ws-a", "not-a-real-token"); err != ErrInvalidToken { t.Errorf("got %v, want ErrInvalidToken", err) } } // TestValidateToken_RemovedWorkspaceRejected — defense-in-depth (#697): // a token belonging to a workspace with status='removed' must be rejected // even when the token row itself is still live (revoked_at IS NULL). // The JOIN on workspaces with AND w.status != 'removed' filters the row // out at the DB layer, returning ErrNoRows which collapses to ErrInvalidToken. func TestValidateToken_RemovedWorkspaceRejected(t *testing.T) { db, mock := setupMock(t) // JOIN with w.status != 'removed' causes no rows — same path as ErrNoRows. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WithArgs(sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"})) // empty: workspace removed err := ValidateToken(context.Background(), db, "ws-removed", "token-for-removed-workspace") if err != ErrInvalidToken { t.Errorf("removed workspace token: expected ErrInvalidToken, got %v", err) } } // ------------------------------------------------------------ // WorkspaceFromToken — #2306 // ------------------------------------------------------------ func TestWorkspaceFromToken_HappyPath(t *testing.T) { db, mock := setupMock(t) mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).WillReturnResult(sqlmock.NewResult(1, 1)) tok, err := IssueToken(context.Background(), db, "ws-source") if err != nil { t.Fatalf("IssueToken: %v", err) } // Shared lookupTokenByHash projects (id, workspace_id) — caller picks // which fields to use. WorkspaceFromToken needs only workspace_id but // the mock matches the unified SELECT that lookupTokenByHash issues. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WithArgs(sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-id-1", "ws-source")) wsID, err := WorkspaceFromToken(context.Background(), db, tok) if err != nil { t.Fatalf("WorkspaceFromToken: %v", err) } if wsID != "ws-source" { t.Errorf("workspace_id: got %q, want %q", wsID, "ws-source") } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet expectations: %v", err) } } func TestWorkspaceFromToken_EmptyTokenRejected(t *testing.T) { db, _ := setupMock(t) if _, err := WorkspaceFromToken(context.Background(), db, ""); err != ErrInvalidToken { t.Errorf("empty token: got %v, want ErrInvalidToken", err) } } func TestWorkspaceFromToken_UnknownTokenRejected(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WillReturnError(sql.ErrNoRows) if _, err := WorkspaceFromToken(context.Background(), db, "not-a-real-token"); err != ErrInvalidToken { t.Errorf("got %v, want ErrInvalidToken", err) } } // Defense-in-depth: a token belonging to a workspace with status='removed' // must NOT yield a workspace_id usable for callerID derivation. func TestWorkspaceFromToken_RemovedWorkspaceRejected(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WithArgs(sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"})) // empty rows if _, err := WorkspaceFromToken(context.Background(), db, "token-for-removed-workspace"); err != ErrInvalidToken { t.Errorf("removed workspace token: expected ErrInvalidToken, got %v", err) } } // ------------------------------------------------------------ // HasAnyLiveToken // ------------------------------------------------------------ func TestHasAnyLiveToken(t *testing.T) { cases := []struct { name string count int want bool }{ {"no tokens", 0, false}, {"one token", 1, true}, {"many tokens", 7, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tc.count)) got, err := HasAnyLiveToken(context.Background(), db, "ws-x") if err != nil { t.Fatalf("err: %v", err) } if got != tc.want { t.Errorf("got %v, want %v", got, tc.want) } }) } } // ------------------------------------------------------------ // WorkspaceExists — #318 // ------------------------------------------------------------ func TestWorkspaceExists(t *testing.T) { cases := []struct { name string exists bool }{ {"present", true}, {"absent", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`). WithArgs("ws-id-42"). WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(tc.exists)) got, err := WorkspaceExists(context.Background(), db, "ws-id-42") if err != nil { t.Fatalf("err: %v", err) } if got != tc.exists { t.Errorf("got %v, want %v", got, tc.exists) } }) } } // ------------------------------------------------------------ // RevokeAllForWorkspace // ------------------------------------------------------------ func TestRevokeAllForWorkspace(t *testing.T) { db, mock := setupMock(t) mock.ExpectExec(`UPDATE workspace_auth_tokens\s+SET revoked_at`). WithArgs("ws-delete-me"). WillReturnResult(sqlmock.NewResult(0, 3)) if err := RevokeAllForWorkspace(context.Background(), db, "ws-delete-me"); err != nil { t.Fatalf("err: %v", err) } } // ------------------------------------------------------------ // BearerTokenFromHeader // ------------------------------------------------------------ func TestBearerTokenFromHeader(t *testing.T) { cases := map[string]string{ "": "", "xyz": "", // no Bearer prefix "bearer lowercase-no-match": "", // case-sensitive "Bearer ": "", "Bearer abc123": "abc123", "Bearer spaced ": "spaced", // TrimSpace "Bearer token-with-dashes": "token-with-dashes", } for in, want := range cases { got := BearerTokenFromHeader(in) if got != want { t.Errorf("BearerTokenFromHeader(%q) = %q, want %q", in, got, want) } } } // ------------------------------------------------------------ // HasAnyLiveTokenGlobal // ------------------------------------------------------------ func TestHasAnyLiveTokenGlobal(t *testing.T) { cases := []struct { name string count int want bool }{ {"no tokens anywhere", 0, false}, {"one live token", 1, true}, {"many live tokens", 5, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tc.count)) got, err := HasAnyLiveTokenGlobal(context.Background(), db) if err != nil { t.Fatalf("err: %v", err) } if got != tc.want { t.Errorf("got %v, want %v", got, tc.want) } }) } } // ------------------------------------------------------------ // ValidateAnyToken // ------------------------------------------------------------ func TestValidateAnyToken_HappyPath(t *testing.T) { db, mock := setupMock(t) // Issue a token for some workspace. mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).WillReturnResult(sqlmock.NewResult(1, 1)) tok, err := IssueToken(context.Background(), db, "ws-admin") if err != nil { t.Fatalf("IssueToken: %v", err) } // ValidateAnyToken: lookup by hash with removed-workspace JOIN. // Shared lookupTokenByHash projects (id, workspace_id) — caller picks // which fields to use. ValidateAnyToken needs only id but the mock // matches the unified SELECT that lookupTokenByHash issues. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WithArgs(sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-id-global", "ws-admin")) // Best-effort last_used_at update. mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`). WithArgs("tok-id-global"). WillReturnResult(sqlmock.NewResult(0, 1)) if err := ValidateAnyToken(context.Background(), db, tok); err != nil { t.Errorf("expected valid token, got error: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet expectations: %v", err) } } func TestValidateAnyToken_UnknownTokenRejected(t *testing.T) { db, mock := setupMock(t) mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WillReturnError(sql.ErrNoRows) if err := ValidateAnyToken(context.Background(), db, "not-a-real-token"); err != ErrInvalidToken { t.Errorf("got %v, want ErrInvalidToken", err) } } // TestValidateAnyToken_RemovedWorkspaceRejected — defense-in-depth (#682): // a token belonging to a workspace with status='removed' must be rejected. // The JOIN on workspaces filters it out before the revoked_at check, so the // query returns no rows even though the token row itself is still live. func TestValidateAnyToken_RemovedWorkspaceRejected(t *testing.T) { db, mock := setupMock(t) // JOIN with w.status != 'removed' causes no rows — same as ErrNoRows. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`). WithArgs(sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"})) // empty: workspace is removed err := ValidateAnyToken(context.Background(), db, "token-for-removed-workspace") if err != ErrInvalidToken { t.Errorf("removed workspace token: expected ErrInvalidToken, got %v", err) } } func TestValidateAnyToken_EmptyTokenRejected(t *testing.T) { db, _ := setupMock(t) if err := ValidateAnyToken(context.Background(), db, ""); err != ErrInvalidToken { t.Errorf("got %v, want ErrInvalidToken", err) } }