Skip to content

fix(HNT-1804): fix logic bugs across monorepo — timestamp rounding, off-by-one, null safety#371

Open
mmiermans wants to merge 7 commits intomainfrom
HNT-1804-code-audit
Open

fix(HNT-1804): fix logic bugs across monorepo — timestamp rounding, off-by-one, null safety#371
mmiermans wants to merge 7 commits intomainfrom
HNT-1804-code-audit

Conversation

@mmiermans
Copy link
Copy Markdown
Contributor

Goal

Fix 6 real logic bugs found via systematic TDD audit across the monorepo.

  • Timestamp rounding (4 fixes): Math.round / .toFixed(0) produces timestamps 1 second in the future when milliseconds >= 500. Changed to Math.floor to
    match unix timestamp convention. Affected: getUnixTimestamp in shared utils, UnixTimestamp resolver, JWT iat generation, and Snowplow prospect event fallback.
  • Off-by-one in sanitizeText (1 fix): substring(0, maxLength - 1) returned 99 characters when maxLength was 100. Fixed to substring(0, maxLength).
  • Null safety in getUrlId (1 fix): path.match(slugRegex)[1] crashes with TypeError on getpocket.com collection URLs without a slug (e.g. /collections/).
    Added null check.

I'd love feedback/perspectives on:

  • There are additional bugs identified that require integration tests (DB/mocked Prisma) to fix — notably rejectApprovedItem deleting before validating the
    rejection reason, and createSectionItem missing a sectionId scope on the manually-removed check. Should those be addressed in a follow-up?

Implementation Decisions

  • Each bug was fixed using strict TDD: failing test written first, then minimal code fix, then verified all existing tests still pass.
  • One commit per bug to keep changes reviewable and independently revertable.
  • Only fixed bugs that could be validated with unit tests. Bugs requiring integration test infrastructure are documented but deferred.

Deployment steps

  • Database migrations? No
  • Deployed to dev? No
  • Secrets? No

References

JIRA ticket:

Issue:

  • N/A

Documentation:

  • N/A

… rounding in getUnixTimestamp

getUnixTimestamp used .toFixed(0) which rounds to nearest integer,
causing timestamps to be 1 second too high for dates with >=500ms.
Unix timestamps should truncate, not round. This affected all
Snowplow events for Sections and SectionItems.
@mmiermans mmiermans requested a review from a team as a code owner February 25, 2026 17:16
@claude
Copy link
Copy Markdown

claude bot commented Feb 25, 2026

Claude finished @mmiermans's task in 1m 55s —— View job


Code Review

  • Read CLAUDE.md and repo guidelines
  • Review changed files
  • Analyze test quality and coverage
  • Post review findings

Good TDD discipline throughout — failing tests written first, minimal targeted fixes, clear test names that describe the specific regression being prevented. Here are my observations:

Timestamp Fixes (4 instances)

All four Math.round/.toFixed(0)Math.floor changes are correct. A nuance worth noting: for getUnixTimestamp in utils.ts and the UnixTimestamp resolver, the input dates come from MySQL DATETIME fields which have 1-second precision, so the milliseconds are always zero in practice. The fixes still matter for correctness and for the JWT/Snowplow Date.now() fallback paths. The fix is correct everywhere.

One observation on the JWT test (packages/lambda-common/src/jwt.spec.ts:63): the afterAll has a commented-out jest.restoreAllMocks(). This is pre-existing, but since the new test uses jest.setSystemTime (and restores it manually at line 125), it's worth confirming the cleanup is complete. It is — jest.setSystemTime(now) restores the fake timer, so no issue here. The dead comment could be cleaned up separately.

Off-by-One in sanitizeText

The fix (substring(0, maxLength)) is correct. The new test at index.spec.ts:82-87 is a clean, direct assertion of the boundary.

On the pre-existing sanitizeText concern raised by the reviewer: the regex [^a-zA-Z0-9_ \-.!?] stripping apostrophes, commas, and colons means curator comments can't contain contractions ("don't", "it's") or list punctuation. If the context is just a curator rejection note (max 100 chars, historically max 76), this may be acceptable but it's undocumented. The reviewer's suggestion to document the intent is worth doing: even a one-line comment explaining the constraint (e.g., "SQL-safe allowlist for curator-entered text") would help future maintainers.

Null Safety in getUrlId

The fix is correct:

// servers/curated-corpus-api/src/shared/utils.ts:149-152
export const getUrlId = (path: string): string | null => {
  const match = path.match(slugRegex);
  return match ? match[1] : null;
};

The test at utils.spec.ts:215-227 covers the exact crash scenario (slug-less /collections/ URL returning key: null).

On the "unused code" comment: getUrlId is still called by getPocketPath at line 183 — it's not dead code. What's no longer happening is creation of new syndicated/collection URLs. The parsing code still handles existing ones. The comment in the review thread saying "this should be unused code" is slightly inaccurate. The function serves a live read path.

Test Quality

Tests are well-written across the board:

  • snowplow.spec.ts correctly uses jest.spyOn(Date, 'now').mockReturnValue(mockNow) with jest.restoreAllMocks() in cleanup.
  • UnixTimestamp.spec.ts adds a null guard test that was previously missing.
  • jwt.spec.ts new test properly isolates the floor-vs-round case with a high-ms timestamp.
  • All tests are precise about why the old behavior was wrong (comments explain the Math.round difference).

On the Deferred Bugs

The rejectApprovedItem delete-before-validate issue mentioned in the PR description is worth pursuing if the deletion is hard to reverse (i.e., not a soft delete). The cross-section scope issue being "by design" is fine to defer.

Summary

The PR is clean and the fixes are correct. No blocking issues. The practical impact is low as noted by the author, but the correctness improvements are genuine and the test additions improve the test baseline for future changes.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 25, 2026

Plan Result (corpus-scheduler-lambda-cdk-production)

CI link

Plan: 0 to add, 1 to change, 0 to destroy.
  • Update
    • aws_lambda_function.corpus-scheduler-sqs-lambda_F2ECDF9F
Change Result (Click me)
  # aws_lambda_function.corpus-scheduler-sqs-lambda_F2ECDF9F will be updated in-place
  ~ resource "aws_lambda_function" "corpus-scheduler-sqs-lambda_F2ECDF9F" {
        id                             = "CorpusSchedulerLambda-Prod-SQS-Function"
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:996905175585:function:CorpusSchedulerLambda-Prod-SQS-Function:286" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:996905175585:function:CorpusSchedulerLambda-Prod-SQS-Function:286/invocations" -> (known after apply)
        tags                           = {
            "app_code"       = "content"
            "component_code" = "content-corpusschedulerlambda"
            "env_code"       = "prod"
            "environment"    = "Prod"
            "service"        = "CorpusSchedulerLambda"
        }
      ~ version                        = "286" -> (known after apply)
        # (20 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              ~ "GIT_SHA"                          = (sensitive value)
              ~ "SENTRY_DSN"                       = (sensitive value)
                # (6 unchanged elements hidden)
            }
        }

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

⚠️ Errors

  • failed to add a label corpus-scheduler-lambda-cdk-production/add-or-update: label name is too long (max: 50)

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 25, 2026

Plan Result (section-manager-lambda-cdk-production)

CI link

Plan: 0 to add, 1 to change, 0 to destroy.
  • Update
    • aws_lambda_function.section-manager-sqs-lambda_D7365DAE
Change Result (Click me)
  # aws_lambda_function.section-manager-sqs-lambda_D7365DAE will be updated in-place
  ~ resource "aws_lambda_function" "section-manager-sqs-lambda_D7365DAE" {
        id                             = "SectionManagerLambda-Prod-SQS-Function"
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:996905175585:function:SectionManagerLambda-Prod-SQS-Function:82" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:996905175585:function:SectionManagerLambda-Prod-SQS-Function:82/invocations" -> (known after apply)
        tags                           = {
            "app_code"       = "content"
            "component_code" = "content-sectionmanagerlambda"
            "env_code"       = "prod"
            "environment"    = "Prod"
            "service"        = "SectionManagerLambda"
        }
      ~ version                        = "82" -> (known after apply)
        # (20 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              - "GIT_SHA"     = "4091bdab8ff6fcb734392fdf2c606351e52246c9" -> null
                # (5 unchanged elements hidden)
            }
        }

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

⚠️ Errors

  • failed to add a label section-manager-lambda-cdk-production/add-or-update: label name is too long (max: 50)

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 25, 2026

Plan Result (prospect-translation-lambda-cdk-production)

CI link

Plan: 0 to add, 1 to change, 0 to destroy.
  • Update
    • aws_lambda_function.translation-lambda_translation-sqs-lambda_B9BDF6BA
Change Result (Click me)
  # aws_lambda_function.translation-lambda_translation-sqs-lambda_B9BDF6BA will be updated in-place
  ~ resource "aws_lambda_function" "translation-lambda_translation-sqs-lambda_B9BDF6BA" {
        id                             = "ProspectAPI-Prod-Sqs-Translation-Function"
        tags                           = {
            "app_code"       = "content"
            "component_code" = "content-prospectapi"
            "env_code"       = "prod"
            "environment"    = "Prod"
            "service"        = "ProspectAPI-Sqs-Translation"
        }
        # (22 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              ~ "GIT_SHA"                      = (sensitive value)
              ~ "SENTRY_DSN"                   = (sensitive value)
                # (4 unchanged elements hidden)
            }
        }

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

⚠️ Errors

  • failed to add a label prospect-translation-lambda-cdk-production/add-or-update: label name is too long (max: 50)

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 25, 2026

Plan Result (prospect-api-cdk-production)

CI link

Plan: 0 to add, 2 to change, 0 to destroy.
  • Update
    • aws_dynamodb_table.dynamodb_prospects_dynamodb_table_9854E41E
    • aws_iam_policy.application_ecs_service_ecs-iam_ecs-task-role-policy_6FC89FB6
Change Result (Click me)
  # data.aws_iam_policy_document.application_ecs_service_ecs-iam_data-ecs-task-role-policy_090CC3AD will be read during apply
  # (depends on a resource or a module with changes pending)
 <= data "aws_iam_policy_document" "application_ecs_service_ecs-iam_data-ecs-task-role-policy_090CC3AD" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)
      + version       = "2012-10-17"

      + statement {
          + actions   = [
              + "dynamodb:BatchGet*",
              + "dynamodb:DescribeTable",
              + "dynamodb:Get*",
              + "dynamodb:Query",
              + "dynamodb:Scan",
              + "dynamodb:UpdateItem",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:dynamodb:us-east-1:996905175585:table/PROAPI-Prod-Prospects",
              + "arn:aws:dynamodb:us-east-1:996905175585:table/PROAPI-Prod-Prospects/*",
            ]
        }
      + statement {
          + actions   = [
              + "s3:*",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:s3:::pocket-prospectapi-prod-images",
              + "arn:aws:s3:::pocket-prospectapi-prod-images/*",
            ]
        }
      + statement {
          + actions   = [
              + "logs:CreateLogGroup",
              + "logs:CreateLogStream",
              + "logs:DescribeLogGroups",
              + "logs:DescribeLogStreams",
              + "logs:PutLogEvents",
            ]
          + effect    = "Allow"
          + resources = [
              + "*",
            ]
        }
    }

  # aws_dynamodb_table.dynamodb_prospects_dynamodb_table_9854E41E will be updated in-place
  ~ resource "aws_dynamodb_table" "dynamodb_prospects_dynamodb_table_9854E41E" {
        id                          = "PROAPI-Prod-Prospects"
        name                        = "PROAPI-Prod-Prospects"
        tags                        = {
            "app_code"       = "content"
            "component_code" = "content-prospectapi"
            "env_code"       = "prod"
            "environment"    = "Prod"
            "service"        = "ProspectAPI"
        }
        # (9 unchanged attributes hidden)

      - global_secondary_index {
          - hash_key           = "scheduledSurfaceGuid" -> null
          - name               = "scheduledSurfaceGuid-prospectType" -> null
          - non_key_attributes = [] -> null
          - projection_type    = "ALL" -> null
          - range_key          = "prospectType" -> null
          - read_capacity      = 0 -> null
          - write_capacity     = 0 -> null
        }
      + global_secondary_index {
          + hash_key           = "scheduledSurfaceGuid"
          + name               = "scheduledSurfaceGuid-prospectType"
          + non_key_attributes = []
          + projection_type    = "ALL"
          + range_key          = "prospectType"
          + read_capacity      = 5
          + write_capacity     = 5
        }

        # (5 unchanged blocks hidden)
    }

  # aws_iam_policy.application_ecs_service_ecs-iam_ecs-task-role-policy_6FC89FB6 will be updated in-place
  ~ resource "aws_iam_policy" "application_ecs_service_ecs-iam_ecs-task-role-policy_6FC89FB6" {
        id               = "arn:aws:iam::996905175585:policy/ProspectAPI-Prod-TaskRolePolicy"
        name             = "ProspectAPI-Prod-TaskRolePolicy"
      ~ policy           = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "dynamodb:UpdateItem",
                          - "dynamodb:Scan",
                          - "dynamodb:Query",
                          - "dynamodb:Get*",
                          - "dynamodb:DescribeTable",
                          - "dynamodb:BatchGet*",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:dynamodb:us-east-1:996905175585:table/PROAPI-Prod-Prospects/*",
                          - "arn:aws:dynamodb:us-east-1:996905175585:table/PROAPI-Prod-Prospects",
                        ]
                    },
                  - {
                      - Action   = "s3:*"
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:s3:::pocket-prospectapi-prod-images/*",
                          - "arn:aws:s3:::pocket-prospectapi-prod-images",
                        ]
                    },
                  - {
                      - Action   = [
                          - "logs:PutLogEvents",
                          - "logs:DescribeLogStreams",
                          - "logs:DescribeLogGroups",
                          - "logs:CreateLogStream",
                          - "logs:CreateLogGroup",
                        ]
                      - Effect   = "Allow"
                      - Resource = "*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        tags             = {
            "app_code"       = "content"
            "component_code" = "content-prospectapi"
            "env_code"       = "prod"
            "environment"    = "Prod"
            "service"        = "ProspectAPI"
        }
        # (5 unchanged attributes hidden)
    }

Plan: 0 to add, 2 to change, 0 to destroy.

* * @param maxLength the maximum returned length allowed
* @returns sanitized string
*/
export const sanitizeText = (input: string, maxLength: number): string => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude How is sanitizeText used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only used for the optional comment curators leave when removing/scheduling items (maxLength = 100). The longest comment ever recorded is 76 characters, so the off-by-one never truncated real data. It's a correct fix, but no real-world impact.

My bigger concern with this function is that the allowlist regex [^a-zA-Z0-9_ \-.!?] does not document why these characters are allowed. It silently strips commas, apostrophes, quotes, colons, and all non-ASCII. My guess is that this may have been done to protect against SQL-injection, but that would be an anti-pattern.

export const getUrlId = (path: string): string | null => {
const match = path.match(slugRegex);
return match ? match[1] : null;
};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be unused code, because we are no longer creating new syndicated or collection URLs.

*/
export const generateJwt = (config: JwtConfig, privateKey: any): string => {
const now = Math.round(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an improvement, but without any real-world impact that I can think of.

@mmiermans
Copy link
Copy Markdown
Contributor Author

mmiermans commented Mar 3, 2026

I've thought about what we should review in this PR, and I think there are a few levels.

The fixes

Correct, but no real-world impact.

Other bugs found

The loop also identified higher-severity bugs that were deferred because they need integration tests. Of those, the delete-before-validate (issue 1) looks like a real bug worth investigating, though probably low/medium rather than high severity. The cross-section scope (issue 2) is by design.

The approach

I wonder if code audits are a hard starting point for autonomous loops. The bugs that matter require context that isn't in the code: which paths are hot, what the data looks like, what are our OKRs, which features are deprecated. A CLAUDE.md with roadmap context and deprecation notes, or MCP access to production signals (Jira, BigQuery read-only access, error rates), might help a loop assess impact, not just correctness.

Other applications

Other applications might get traction faster:

  1. Find small, recent Jira PRs tickets, and create a PR. We could start with tickets filed by our editorial team.
  2. Fix Renovate dependency updates that need migration work.

I'm excited about this direction overall!

Copy link
Copy Markdown
Contributor

@jpetto jpetto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as mentioned in comments, the code changes here are fine, but don't have much (if any) real world impact.

as discussed, let's drop the tests around Math.floor, as they aren't providing value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants