Node.jsでURLセーフなBase64変換したいときはbase64urlが使える

全然投稿できてないので小ネタをば。

Node.jsでBase64変換するときは Buffer#toString('base64') することが多いが、URLセーフなBase64にしたい場合、 Buffer#toString('base64url') が使える。

コードレビューで buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') としていたのを見かけたので、こう書けるよと教えたかったのだが、なんて指定するのかど忘れしたのでメモ。

ちなみに結果は上記replaceをしたものと同様。

const buffer = Buffer.from('~~~????')
const base64 = buffer.toString('base64') // fn5+Pz8/Pw==
const base64url = buffer.toString('base64url') // fn5-Pz8_Pw

いまだに検索すると、 buffer.toString('base64').replace... が上位に出てくるんだよな...

SSM Session Managerを使ってRDSへ接続できる環境をAWS CDKで作成する

検証環境として、RDSにSSM Session ManagerおよびNLBを用いたPrivateLinkでアクセスできる環境を構築することとなった。

今後も同じような環境を作ることになりそうなので、CDKで書くことにしたが、そのものズバリこうすればできる、といったサンプルが見つからなかった。

試行錯誤して環境構築できたので、まずはSSM Session ManagerでRDSにアクセスできる状態までをメモ。

実装

TypeScriptで記述。

環境変数は、 dotenv を追加し、 .env ファイルに記述している。

検証環境のため、EC2およびRDSは無料枠内のスペックで作成。

import {
  CfnOutput,
  Duration,
  SecretValue,
  Stack,
  StackProps,
} from 'aws-cdk-lib'

import {
  AmazonLinuxCpuType,
  Instance,
  InstanceClass,
  InstanceSize,
  InstanceType,
  InterfaceVpcEndpointAwsService,
  MachineImage,
  Peer,
  Port,
  SubnetType,
  Vpc,
} from 'aws-cdk-lib/aws-ec2'

import {
  Credentials,
  DatabaseInstance,
  DatabaseInstanceEngine,
  MysqlEngineVersion,
} from 'aws-cdk-lib/aws-rds'

import { Construct } from 'constructs'

import 'dotenv/config'

const {
  // RDSの管理者パスワード
  RDS_MYSQL_ADMIN_PASSWORD = '',
} = process.env

// 接続元のCIDRを設定
const validCidrs = [
  ...
] as const

const prefix = (name: string) => 'example-' + name

export class CdkStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    // サブネット指定用に変数を宣言
    const ec2SubnetName = prefix('subnet-private-ec2')
    const rdsSubnetName = prefix('subnet-private-rds')
    const ec2Subnet = { subnetGroupName: ec2SubnetName }
    const rdsSubnet = { subnetGroupName: rdsSubnetName }

    // VPCの作成
    // RDSは単一AZに配置するが、VPCのAZが1つだけだとRDSの配置がエラーとなるため、
    // maxAzsには2以上を指定しておく
    const vpc = new Vpc(this, prefix('vpc'), {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: ec2SubnetName,
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
        {
          cidrMask: 24,
          name: rdsSubnetName,
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
      ],
    })

    // 各インスタンスを単一のAZに配置するため取得
    const availabilityZone = vpc.availabilityZones[0]
    const availabilityZones = [availabilityZone]

    // SSM用EC2インスタンスの作成
    const ec2Instance = new Instance(this, prefix('bastion'), {
      vpc,
      availabilityZone,
      vpcSubnets: ec2Subnet,
      instanceType: InstanceType.of(
        InstanceClass.T4G,
        InstanceSize.MICRO,
      ),
      machineImage: MachineImage.latestAmazonLinux2023({
        cpuType: AmazonLinuxCpuType.ARM_64,
        cachedInContext: true,
      }),
      ssmSessionPermissions: true, // SSMを有効化
    })

    // RDSインスタンスの作成
    const rdsMysql = new DatabaseInstance(this, prefix('rds-mysql'), {
      engine: DatabaseInstanceEngine.mysql({
        version: MysqlEngineVersion.VER_8_0_40,
      }),
      instanceType: InstanceType.of(
        InstanceClass.T4G,
        InstanceSize.MICRO,
      ),
      databaseName: 'example',
      credentials: Credentials.fromUsername('admin', {
        password: SecretValue.unsafePlainText(RDS_MYSQL_ADMIN_PASSWORD),
      }),
      allocatedStorage: 20,
      vpc,
      availabilityZone,
      vpcSubnets: rdsSubnet,
      multiAz: false,
      backupRetention: Duration.days(0), // バックアップなしに設定
    })

    // EC2からRDSへの接続を許可
    rdsMysql.connections.allowDefaultPortFrom(ec2Instance)

    // SSM用VPCエンドポイントを作成
    const ssmVpcEndpoints = [
      InterfaceVpcEndpointAwsService.SSM,
      InterfaceVpcEndpointAwsService.SSM_MESSAGES,
    ].map(service => {
      const endpointId = prefix('vpc-endpoint-' + service.shortName)
      return vpc.addInterfaceEndpoint(endpointId, {
        service,
        subnets: { availabilityZones, ...ec2Subnet },
      })
    })

    // SSM用VPCエンドポイントに、EC2および特定IPアドレスからの接続を許可
    ssmVpcEndpoints.forEach(endpoint => {
      endpoint.connections.allowFrom(ec2Instance, Port.HTTPS)

      validCidrs.forEach(cidr => {
        endpoint.connections.allowFrom(Peer.ipv4(cidr), Port.HTTPS)
      })
    })

    // EC2のIDおよびRDSのエンドポイントを出力
    const ountput = (id: string, value: string) => {
      new CfnOutput(this, id, { value })
    }

    ountput('EC2_INSTANCE_ID', ec2Instance.instanceId)
    ountput('MYSQL_INSTANCE_HOSTNAME', rdsMysql.dbInstanceEndpointAddress)
  }
}

解説

EC2インスタンス

Session Managerを使う場合、EC2インスタンスAWS Systems Manager Agent(SSMエージェント)が実行されている必要がある。

Amazon Linux系AMIであれば、デフォルトでインストール済みかつインスタンス起動時に自動実行されるため、2025年現在は latestAmazonLinux2023 を使っておけばいい。

SSM Session Managerを使うためには、IAMロールを作成してAWS 管理ポリシー AmazonSSMManagedInstanceCore のアタッチを行う必要があるが、 ssmSessionPermissionstrue にすることで、このIAMロール作成などを自動的に行ってくれる。

dev.classmethod.jp

SSMエージェントはVPCエンドポイントに送信されたコマンドをポーリングするため、EC2のインバウンド許可は不要。また、サブネットタイプはEC2も PRIVATE_ISOLATED でいい。

VPCエンドポイント

公式ドキュメントを見ると、以下3つのVPCエンドポイントへのHTTPSトラフィックを許可する必要があると記載されている。

  • ec2messages.<region>.amazonaws.com
  • ssm.<region>.amazonaws.com
  • ssmmessages.<region>.amazonaws.com

CDKではそれぞれ、 InterfaceVpcEndpointAwsServiceEC2_MESSAGES, SSM, SSM_MESSAGES に該当。

ただ、 EC2_MESSAGES は不要になった模様。

dev.classmethod.jp

公式ドキュメントをもう少し見てみる。

結果としては、 EC2_MESSAGES なしでも接続可能だった。

また、SSMエージェントからポーリングされるため、EC2からのHTTPSアクセスを許可しておく。

振り返り

SSM Session Managerを使うのが初めてだったので、どういった仕組みなのか調べたが、EC2をISOLATEDサブネットに配置してもOKというのは面白い。

今回はVPCエンドポイントへのアクセスをCIDRで制限したが、IAMロールで実行可能な処理を細かく指定したりもできるので、パブリックサブネットにEC2を置いてSSHより、Session Managerを使ったほうがセキュアな環境を構築できると感じた。

LLMを用いたテスト自動作成の記事のご紹介

社内向けにTDDの説明資料を書いており、その調べ物をしているときにたまたま見つけた、LLMを用いたテストの自動作成に関する記事が大変興味深かったのでメモ。

経緯

TDDについての勉強会をすることになり、もろもろ資料としてまとめていた。

その中で、 Testing vs. Checking および Testing and Checking Refined に絡めて、 Checking vs Testing についても軽く触れておこうと該当記事を確認。

その際、記事を執筆された jason arbon 氏のその他の記事として、おすすめされていたのが「AI QuickStart for Testers」。

jarbon.medium.com

記事の内容

詳しくは該当の記事を見てもらえばわかるが、ざっくり意訳&要約。

  • テストしたいページのキャプチャをLLMに添付し、バグを見つけさせる
  • テストしたいページのキャプチャをLLMに添付し、テストケースを生成させる
    • 「経験豊富なテスターでも見逃す可能性のある、重要なバグを発見できるテストケースを作成してください。各テストケースについて、テスターがそれを見逃す可能性がある理由と、重要なバグである理由を説明してください。」のようなプロンプトを指定すると、さらに複雑なテストケースを作成させることができる
  • 「~のテストケースについて、問題やバグを起こす可能性のある例をいくつか挙げ、なぜそれが問題なのかを技術的な観点から説明してください。」のようなプロンプトで、具体例と詳細な説明をさせることができる
  • 自動テストのためのコードを生成させる
    • プログラミング言語やブラウザ操作自動化用のツール(Selenium, Playwright等)の指定が可能
    • POM(Page Object Model)に即したクラスの生成も可能
    • 「堅牢かつ冗長なコード」にするようプロンプトを追加することで、ログ出力やtry/catchのようなエラー処理が追加される
  • テスト計画の作成や、リバースエンジニアリングして製品要件/仕様のドキュメント作成を行うことも可能

画面キャプチャだけでなく、製品仕様をプロンプトに追加することでより精度を高めることも可能とのこと。

振り返り

ソフトウェアテストの効率化について、ちょうど考えていたところだったので大変参考になった。

特に、Page Object Modelを使うときに面倒だったページオブジェクトの作成を、この方法を使えばだいぶ簡略化できると感じた。

また、リバースエンジニアリングによる要件/使用のドキュメント作成も、どの程度のクオリティのものが出力されるのか興味深い。

いずれも機会があれば試してみたい。

Slack APIのconversations.historyはPOSTなのかGETなのか

Slack APIでチャンネルのメッセージを抽出するときに conversations.history を使っている。オプションを確認しようと思ってドキュメントを確認したら、ちょっと気になることがあった。

ドキュメントの「Reference docs」ではPOSTだが、「Sample code」ではGETになっている。

もともとはGETを使っており、POSTのリクエスト方法も複数種類あり、どれを使うか迷ったのでメモ。

状況

以下が conversations.history のドキュメント。

api.slack.com

2024/11/15 現在、リクエスト時のHTTPメソッドが、「Reference docs」タブではPOST、「Sample code」タブではGETになっている。ただし、サンプルコードではリクエストボディが設定されているように見える。

Reference docs

Sample code

どちらが正か

試してみたところ、GET/POSTともに実行できた。また、書き込みではないが、JSONリクエストボディも許可されている。

SLACK_TOKEN=...
SLACK_CHANNEL_ID=...

AUTH_HEADER="Authorization: Bearer ${SLACK_BOT_TOKEN}"

# GET
curl "https://slack.com/api/conversations.history?channel=${SLACK_CHANNEL_ID}" \
  -H "${AUTH_HEADER}"

# POST
# curlの場合、 `Content-Type: x-www-form-urlencoded` が自動的に付与されるため省略
curl -X POST "https://slack.com/api/conversations.history" \
  -H "${AUTH_HEADER}" \
  -d "channel=${SLACK_CHANNEL_ID}"

# POST JSON、Content-Typeの理由は後述
curl -X POST "https://slack.com/api/conversations.history" \
  -H "${AUTH_HEADER}" \
  -H "Content-Type: application/json; charset=UTF-8" \
  -d "{ \"channel\": \"${SLACK_CHANNEL_ID}\" }"

# POSTでもクエリパラメータが有効
# この場合はContent-Type未指定となるが問題ない
curl -X POST "https://slack.com/api/conversations.history?channel=${SLACK_CHANNEL_ID}" \
  -H "${AUTH_HEADER}"

どの方法がいいか

POSTについては、クエリパラメータ指定以外だと、 Content-Type を指定する必要がある。それぞれ Content-Type: x-www-form-urlencoded または Content-Type: application/json を指定しないと、以下のエラーが発生。

{
  "ok": false,
  "error": "missing_post_type"
}

また、JSON形式の場合、 Content-Type: application/json を指定しても、レスポンスに警告が含まれる。この警告を消すには Content-Type: application/json; charset=UTF-8 のように、文字コードを指定する必要がある。

{
  "ok": true,
  // 中略
  "warning": "missing_charset",
  "response_metadata": {
    "warnings": [
      "missing_charset"
    ]
  }
}

たしかJSONって、UTF-8にする必要がなかったっけ?

www.publickey1.jp

zenn.dev

楽なのはGETかPOSTのクエリパラメータ指定で、 Content-Type の指定は不要。

シンプルなのはGETだが、はたして利用していいのか判断がつかない。

とはいえ今まで使えていたので、結局はシンプルさを重視してGETを使うことにした。

振り返り

当然GETだろうと思っていたが、ドキュメントを見たらPOSTになっていたのでびっくり。

というか、以前からPOSTだったとしたら、なぜ自分はGETにしたのだろうか...

使用頻度が高いメソッドだと思うので、GETできるならできると明記してほしいな。

Azure FunctionsをVSCodeからデプロイする際、files.excludeで指定したファイルがデプロイ対象から除外される

新しいプロジェクトの一環で、Azure FunctionsをVisual Studio Codeで開発しているが、デプロイしても関数が認識されない事態が発生。

めちゃくちゃハマったのでメモ。

環境

Azure Functions Core Tools v4.0.6280、VSCode v1.94.2、VSCodeのAzure Functions拡張機能v1.15.4。

Azure Functionsのランタイムバージョンはv4.36.0.23246だが、クライアント側の問題なので関係ない。

問題

Azure FunctionsをVSCodeで開発し始めた。言語はTypeScriptを使用。

プロジェクト作成やVSCode上でのデバッグ、デプロイパイプラインの作成など、一通り検証が済んだため本格的な開発に入った。

しかし、いざAzureにデプロイすると、デプロイは成功するが関数が反映されない。コマンドパレットから「Azure Functions: Execute Function Now」しても「No functions found.」となる。

調査

F5でのローカルデバッグでは関数が動き、デプロイも成功しているので、デプロイ時にアップロードされるZIPファイルに問題があるのではと考えた。

Azureポータルの関数アプリからデプロイ先の関数を選択し、「概要」の「アプリ コンテンツのダウンロード」から、アップロードされたZIPファイルをダウンロードできる。

ZIPファイルの内容を確認すると、distおよびnode_modulesディレクトリが含まれていなかった。これらは本格的に開発を開始した段階で、 .vscode/settings.jsonfiles.exclude に指定し、VSCodeエクスプローラーで非表示としていた。

{
  ...
  "files.exclude": {
    "dist": true,
    "node_modules": true
  },
  ...

files.exclude からdistおよびnode_modulesの記述を削除し、デプロイを実行すると、ZIPファイルのサイズが増加。関数が認識され、実行可能となった。ZIPファイルの内容も確認したが、両ディレクトリとも含まれていた。

対応

issueがあるだろうと思い、Azure Functions for Visual Studio Codeのissueを検索。

似たものはあったが、こちらは .funcignore ファイルが存在しない場合、 files.exclude で指定したファイルがZIPファイルから除外されるというもの。

github.com

同じ原因のissueはなさそう。 .funcignore!**/dist などの指定を追加してみたが変わらず。

結局、 .vscode/settings.json から files.exclude の記述を削除して対応した。

また、 files.exclude の代わりに files.readonlyInclude および search.exclude に指定し、読み取り専用および検索対象外にしておく。

{
  ...
  "files.exclude": {
  },
  "files.readonlyInclude": {
    "dist/**/*": true,
    "node_modules/**/*": true
  },
  "search.exclude": {
    "dist/**/*": true,
    "node_modules/**/*": true
  },
  ...

振り返り

今回はTypeScriptの場合だが、JavaScriptでnode_modulesを非表示にしたり、コンパイルが必要な言語で出力ディレクトリを非表示にしたりするので、他の言語でも発生すると思われる。

適当なプロジェクト設定で進めていた検証時は動き、検証終わってきっちりプロジェクト設定したら動かなくなるという罠。

ZIPファイル化するときに、 files.exclude の設定を反映するか選択できるようになればいいんだが。

Slack Events APIで飛んでくるJSONの内容を確認するだけのLambdaコード

複数サービスを連携して処理するとき、Slack Events API(いわゆるWebhook)を使って、AWS LambdaやAzure Logic Apps経由でもろもろ処理させることが増えた。

ただ、Events APIで飛んでくるJSONが、公式ドキュメントを見てもわかりにくい(というか、メッセージの種類が多くて把握できていない)。

ドキュメント読むよりは、実際にWebhookを設定し、メッセージ送信してログ出力したほうが楽だなと思い、適当なLambdaを書いたのでメモ。

環境

LambdaをNode.js v20で作成。おそらくバージョンには依存していないと思う。

Lambdaの関数URLを有効にし、認証タイプは NONE でパブリックアクセス可能にしておく。

実装

チャレンジリクエスト(url_verification)には対応しているが、Verification Tokenの検証はしていない、あくまで検証用。

index.mjs に張り付ければOK。

export const handler = async event => {
  const {
    requestContext: {
      http: {
        method,
      },
    },
    body: bodyString, // stringのため、JSON.parseが必要
  } = event

  if (method !== 'POST') {
    console.error('invalid method', { method, bodyString })
    return { statusCode: 404 }
  }

  const {
    // token, // 本番ではチェックが必要だが、検証なので無視
    challenge,
    type: eventType,
    event: slackEvent,
  } = JSON.parse(bodyString)

  // チャレンジ
  if (eventType === 'url_verification') {
    return {
      statusCode: 200,
      headers: { 'Content-Type': 'text/plain' },
      body: challenge,
    }
  }

  console.log(bodyString)

  return { statusCode: 200 }
}

Slack Apps側の設定

Slack Appsの「Event Subscriptions」にて、「Enable Events」を有効化。

「Request URL」にLambdaの関数URLを設定すればいい。

イベント通知が来れば、Lambdaに紐づくCloudWatch LogsにリクエストされたJSONが出力される。

振り返り

とりあえずこのLambdaを書いておいて、必要に応じて関数URLの有効/無効を切り替えて使っている。非公開であれば課金も発生しないので、似たようなLambdaをサービスごとに用意している。

Slackのメッセージ、チャンネルがpublic/private、送信者がユーザー/ボット、チャンネル直下/スレッド内、スレッド内から「以下にも投稿する」でチャンネルにも投稿した場合、などなど種類と組み合わせが多いし、それが全部メッセージとして飛んでくる。

一度やったことを焼き直すのは簡単なんだが、別種のメッセージを処理しようと思うと結構大変。

サブタイプなどで分かれてはいるが、もう少し細かく分けてくれると嬉しいと思いつつ、大多数のユースケースではそこまで細かな分類/制御はしないだろうとも思う。

ただでさえAPI設計は大変だし、もともと小さなサービスだったのが、これほど急成長するとは中の人も思ってなかったのではなかろうか。

Amplify Gen2のLINEログイン時にLINEユーザーIDを保存する

前回、 Amplify Gen2のLINEログイン時にLINEのIDトークンを取得、検証できた。

今回はIDトークンの検証結果から、LINEユーザーIDを取得し、保存するまでをメモ。

環境

前々回・前回同様、 @aws-amplify/backend v1.0.4, @aws-amplify/backend-cli (ampx) v1.2.1。

実装

前回、preSignUpトリガーにてIDトークンのverifyを行った。ただ、どうやらpreSignUp内ではCognitoの属性の変更ができないらしい。

今回はpostConfirmationにて同様の処理を行い、Cognitoの属性を更新することでLINEユーザーIDを保存する。

Googleログイン時に、postConfirmationトリガーは作成済みのため、そこに追記する。

トリガーへのアクセス許可の追加

amplify/auth/resource.ts にて、postConfirmationへアクセス許可を追加する。もともと addUserToGroup を設定していたが、今回は updateUserAttributes, deleteUserAttributes の2つを追加。

export const auth = defineAuth({
  // 中略

  // 前回追加しておいた、custom:line_user_idをユーザーID保存に用いる
  userAttributes: {
    'custom:line_id_token': { dataType: 'String', maxLen: 2048 },
    'custom:line_user_id': { dataType: 'String', maxLen: 33 },
  },
  triggers: { preSignUp, postConfirmation },
  access: allow => [
    allow
      .resource(postConfirmation)
      // updateUserAttributes, deleteUserAttributesを追加
      .to(['addUserToGroup', 'updateUserAttributes', 'deleteUserAttributes']),
  ],
})

postConfirmationトリガーの実装

まずはpreSignUpと同様、 amplify/auth/postConfirmation/resource.ts でLINEチャネルIDを環境変数として参照できるようにしておく。

export const postConfirmation = defineFunction({
  environment: {
    LINE_CHANNEL_ID: secret('LINE_CHANNEL_ID'),
  },
})

続いて、 amplify/auth/postConfirmation/handler.ts を実装。IDトークンの検証はpreSignUpと同じだが、検証結果として取得したユーザーIDをCognitoのカスタム属性として保存し、あわせてIDトークンを保存した属性を削除している。

import {
  AdminDeleteUserAttributesCommand,
  AdminUpdateUserAttributesCommand,
  CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider'

const cognitoClient = new CognitoIdentityProviderClient()

export const handler: PostConfirmationTriggerHandler = async event => {
  const {
    userName,
    userPoolId,
    request: { userAttributes },
  } = event

  // IDトークンからLINEユーザーIDの取得は前回と同じため省略
  const { sub: lineUserId } = await verifyLineIdToken(userAttributes['custom:line_id_token'])

  const commonCommandInput = {
    UserPoolId: userPoolId,
    Username: userName,
  }

  // LINEユーザーIDの保存
  const updateCommand = new AdminUpdateUserAttributesCommand({
    ...commonCommandInput,
    UserAttributes: [{ Name: 'custom:line_user_id', Value: lineUserId }],
  })

  await cognitoClient.send(updateCommand)

  // 不要となったIDトークンの削除
  const deleteCommand = new AdminDeleteUserAttributesCommand({
    ...commonCommandInput,
    UserAttributeNames: ['custom:line_id_token'],
  })

  await cognitoClient.send(deleteCommand)

  return event
}

これでLINEユーザーIDが custom:line_user_id に保存される。その値を用いて、LINE公式アカウントのユーザーとのマッピングが可能となった。

振り返り

preSignUpでCognitoの属性の更新ができれば、そこで完結できて楽だったんだが。

ひとまずトリガーを分けて対応できたので、良しとしておこう。