1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

18 Commits

Author SHA1 Message Date
d5a85639b1 fix: broken summary aggregation (resolve #385) 2022-07-03 20:39:16 +02:00
b6a8185957 Merge branch 'Enthys_master' 2022-07-02 00:30:15 +02:00
c5da5e4622 fix: bug in same day comparison 2022-07-02 00:28:56 +02:00
a0f69a371f fix: api tests time zone bug (resolve #355) 2022-07-01 23:31:51 +02:00
2f0cb112dd test: user api retrieving user information 2022-06-30 10:36:13 +03:00
2173954b84 docs: instructions for go install [ci skip] 2022-06-29 23:39:35 +02:00
991e64b961 fix: returning current user instead of requested one 2022-06-29 10:46:46 +03:00
affff0c386 fix: admin users can't fetch other user data 2022-06-28 13:01:35 +03:00
099cdaddbc chore: add example systemd service unit file [ci skip] 2022-06-22 00:18:50 +02:00
409405117e Merge pull request #377 from NChechulin/master
elaborate on cloud server API URL [ci skip]
2022-05-29 15:06:42 +02:00
af89ecc9c1 update client configuration URLs for the new version 2022-05-29 15:43:59 +03:00
be354fa790 elaborate on cloud server API URL
Personally, for me, it was slightly unclear which URL has to be pasted.
2022-05-28 11:05:12 +03:00
a1c4c5da6b Merge pull request #376 from Daste745/avatar_url_template_env
Add an env configuration option for AvatarURLTemplate
2022-05-22 23:06:25 +02:00
33509beaf7 Enable env configuration for AvatarURLTemplate
Added an `env:"WAKAPI_AVATAR_URL_TEMPLATE"` option for the Avatar URL Template configuration setting.

I wanted to configure this on my instance, but the only way now is through the yaml config file.
2022-05-22 02:04:25 +02:00
ab6ccbdfbe README: Mention the WAKAPI_AVATAR_URL_TEMPLATE configuration variable 2022-05-22 02:03:04 +02:00
77e6cd9faa Merge pull request #375 from daief/master
Github Actions add a release for macOS
2022-05-20 09:40:26 +02:00
34bc38cecf chore: add descriptive names for workflows 2022-05-20 09:34:54 +02:00
69d3e0494b chore: add a new endline 2022-05-20 12:17:35 +08:00
14 changed files with 877 additions and 673 deletions

View File

@ -7,6 +7,7 @@ on:
jobs:
test:
name: 'Unit- & API tests'
runs-on: ubuntu-latest
steps:
@ -31,7 +32,7 @@ jobs:
./testing/run_api_tests.sh
mapi:
name: 'Mayhem for API'
name: 'Automated pen-tests with Mayhem for API'
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
@ -74,6 +75,8 @@ jobs:
sarif_file: mapi.sarif
build:
name: 'Build (Win, Linux, Mac)'
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
@ -94,4 +97,4 @@ jobs:
run: go get
- name: Build
run: go build -v .
run: go build -v .

View File

@ -8,6 +8,7 @@ on:
jobs:
docker-publish:
name: 'Build and publish Docker image'
runs-on: ubuntu-latest
steps:
- name: Set up QEMU

View File

@ -7,7 +7,8 @@ on:
jobs:
release:
name: Release
name: 'Build, package and release to GitHub'
strategy:
fail-fast: false
matrix:

View File

@ -100,19 +100,22 @@ If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https:
#### Compile & run
```bash
# Build the executable
$ go build -o wakapi
# Build and install
# Alternatively: go build -o wakapi
$ go install github.com/muety/wakapi@latest
# Adapt config to your needs
$ cp config.default.yml config.yml
$ vi config.yml
# Get default config and customize
$ curl -o wakapi.yml https://raw.githubusercontent.com/muety/wakapi/master/config.default.yml
$ vi wakapi.yml
# Run it
$ ./wakapi
$ ./wakapi -config wakapi.yml
```
**Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
### 💻 Client setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics for Wakapi, you need to set them up.
@ -123,8 +126,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
```ini
[settings]
# Your Wakapi server URL or 'https://wakapi.dev' when using the cloud server
api_url = http://localhost:3000/api/heartbeat
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
api_url = http://localhost:3000/api
# Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
@ -145,7 +148,7 @@ You can specify configuration options either via a config file (default: `config
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `app.custom_languages` | - | Map from file endings to language names |
| `app.avatar_url_template` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
| `app.avatar_url_template` /<br>`WAKAPI_AVATAR_URL_TEMPLATE` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |

View File

@ -70,7 +70,7 @@ type appConfig struct {
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}

File diff suppressed because it is too large Load Diff

53
etc/wakapi.service Normal file
View File

@ -0,0 +1,53 @@
[Unit]
Description=Wakapi
StartLimitIntervalSec=400
StartLimitBurst=3
# Optional, in case you're running MySQL / Postgres with Systemd, too
Requires=mysql.service
After=mysql.service
[Service]
Type=simple
# Assuming Wakapi executable is under /opt/wakapi and config file at /etc
# Feel free to change this
WorkingDirectory=/opt/wakapi
ExecStart=/opt/wakapi/wakapi -config /etc/wakapi.yml
# Environment variables, see README for more
Environment=WAKAPI_DB_HOST=localhost
Environment=WAKAPI_DB_USER=wakapi
Environment=WAKAPI_DB_NAME=wakapi
Environment=WAKAPI_DB_PASSWORD=secretpassword
Environment=WAKAPI_PASSWORD_SALT=somerandomstring
# TODO: Use Systemd's credentials management (https://systemd.io/CREDENTIALS/) introduced in v247 (%d syntax in v250) once more established
# sudo groupadd wakapi
# sudo useradd -g wakapi wakapi
User=wakapi
Group=wakapi
Restart=on-failure
RestartSec=90
# Security hardening
PrivateTmp=true
PrivateUsers=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
PrivateDevices=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
ProtectClock=true
RestrictSUIDSGID=true
ProtectHostname=true
ProtectProc=invisible
[Install]
WantedBy=multi-user.target

View File

@ -109,7 +109,7 @@ func (r *SummaryRepository) populateItems(summaries []*models.Summary, condition
}
for _, item := range items {
if _, ok := summaryMap[item.SummaryID]; ok {
if _, ok := summaryMap[item.SummaryID]; !ok {
continue
}
l := summaryMap[item.SummaryID][0].ItemsByType(item.Type)

View File

@ -0,0 +1,128 @@
package v1
import (
"encoding/base64"
"fmt"
"github.com/gorilla/mux"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
var (
adminUser = &models.User{
ID: "AdminUser",
ApiKey: "admin-user-api-key",
Email: "admin@user.com",
IsAdmin: true,
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 1, time.UTC)),
LastLoggedInAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 2, time.UTC)),
}
basicUser = &models.User{
ID: "BasicUser",
ApiKey: "basic-user-api-key",
Email: "basic@user.com",
IsAdmin: false,
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 3, time.UTC)),
LastLoggedInAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 4, time.UTC)),
}
)
func TestUsersHandler_Get(t *testing.T) {
router := mux.NewRouter()
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
apiRouter.Use(middlewares.NewPrincipalMiddleware())
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserById", "AdminUser").Return(adminUser, nil)
userServiceMock.On("GetUserByKey", "admin-user-api-key").Return(adminUser, nil)
userServiceMock.On("GetUserById", "BasicUser").Return(basicUser, nil)
userServiceMock.On("GetUserByKey", "basic-user-api-key").Return(basicUser, nil)
heartbeatServiceMock := new(mocks.HeartbeatServiceMock)
heartbeatServiceMock.On("GetLatestByUser", adminUser).Return(&models.Heartbeat{
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 5, time.UTC)),
Time: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 6, time.UTC)),
}, nil)
heartbeatServiceMock.On("GetLatestByUser", basicUser).Return(&models.Heartbeat{
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 5, time.UTC)),
Time: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 6, time.UTC)),
}, nil)
usersHandler := NewUsersHandler(userServiceMock, heartbeatServiceMock)
usersHandler.RegisterRoutes(apiRouter)
t.Run("when requesting own user data", func(t *testing.T) {
t.Run("should return own data", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/AdminUser", nil)
req.Header.Add(
"Authorization",
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(adminUser.ApiKey))),
)
requestRecorder := httptest.NewRecorder()
apiRouter.ServeHTTP(requestRecorder, req)
res := requestRecorder.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
}
if !strings.Contains(string(data), "\"username\":\"AdminUser\"") {
t.Errorf("invalid response received. Expected json Received: %s", string(data))
}
})
})
t.Run("when requesting another users data", func(t *testing.T) {
t.Run("should respond with '401 unauthorized' if not an admin user", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/AdminUser", nil)
req.Header.Add(
"Authorization",
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(basicUser.ApiKey))),
)
requestRecorder := httptest.NewRecorder()
apiRouter.ServeHTTP(requestRecorder, req)
res := requestRecorder.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
}
if string(data) != "401 unauthorized" {
t.Errorf("invalid response received. Expected: '401 unauthorized' Received: %s", string(data))
}
})
t.Run("should receive user data if requesting user is an admin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/BasicUser", nil)
req.Header.Add(
"Authorization",
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(adminUser.ApiKey))),
)
requestRecorder := httptest.NewRecorder()
apiRouter.ServeHTTP(requestRecorder, req)
res := requestRecorder.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("unextected error. Error: %s", err)
}
if !strings.Contains(string(data), "\"username\":\"BasicUser\"") {
t.Errorf("invalid response received. Expected 'BasicUser' info Received: %s", string(data))
}
})
})
}

View File

@ -35,12 +35,12 @@ func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService serv
return nil, err
}
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID {
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID && !authorizedUser.IsAdmin {
err := errors.New(conf.ErrUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(err.Error()))
return nil, err
}
return authorizedUser, nil
return requestedUser, nil
}

View File

@ -1,6 +1,7 @@
package services
import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -60,7 +61,7 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
continue
}
sameDay := d1.Time.T().Day() == latest.Time.T().Day()
sameDay := datetime.BeginOfDay(d1.Time.T()) == datetime.BeginOfDay(latest.Time.T())
dur := time.Duration(mathutil.Min(
int64(d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))),
int64(HeartbeatDiffThreshold),

View File

@ -12,6 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
inactive_days: 7
custom_languages:
vue: Vue

View File

@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "43639725-0458-40d7-a4d4-9f55a539a7f7",
"_postman_id": "5c0749a5-6ddf-41ea-82f1-140578788bc3",
"name": "Wakapi API Tests",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
@ -973,10 +973,9 @@
"listen": "test",
"script": {
"exec": [
"// 1640995199 Friday, 31 December 2021 11:59:59 PM (Jan 1st in +1, +2)",
"// 1641074399 Saturday, 1 January 2022 9:59:59 PM (Jan 1st in +1, +2)",
"// 1641081599 Saturday, 1 January 2022 11:59:59 PM (Jan 2nd in +1, +2)",
""
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});"
],
"type": "text/javascript"
}
@ -997,7 +996,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995199\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074399\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081599\n}]",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995200\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074400\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081600\n}]",
"options": {
"raw": {
"language": "json"
@ -1331,8 +1330,8 @@
"",
"pm.test(\"Correct dates\", function () {",
" const jsonData = pm.response.json();",
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayDate')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.gte(moment(pm.variables.get('tsEndOfDayDate')).unix())",
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayIso')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.lte(moment(pm.variables.get('tsEndOfDayIso')).unix())",
"});",
""
],
@ -1358,7 +1357,7 @@
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE_URL}}/api/summary?from={{tsStartOfDayDate}}&to={{tsEndOfTomorrowDate}}",
"raw": "{{BASE_URL}}/api/summary?from={{tsStartOfDayIso}}&to={{tsEndOfDayIso}}",
"host": [
"{{BASE_URL}}"
],
@ -1369,11 +1368,11 @@
"query": [
{
"key": "from",
"value": "{{tsStartOfDayDate}}"
"value": "{{tsStartOfDayIso}}"
},
{
"key": "to",
"value": "{{tsEndOfTomorrowDate}}"
"value": "{{tsEndOfDayIso}}"
}
]
}
@ -3371,46 +3370,60 @@
"exec": [
"const moment = require('moment')",
"",
"const now = moment()",
"const startOfDay = moment().startOf('day')",
"const endOfDay = moment().endOf('day')",
"const endOfTomorrow = moment().add(1, 'd').endOf('day')",
"// pretend we're in Berlin, as this is also the time zone configured for the user",
"const userZone = 'Europe/Berlin'",
"",
"console.log(`Current timestamp is: ${now.format('x') / 1000}`)",
"",
"",
"// Auth stuff",
"const readApiKey = pm.variables.get('READUSER_API_KEY')",
"const writeApiKey = pm.variables.get('WRITEUSER_API_KEY')",
"",
"if (!readApiKey || !writeApiKey) {",
" throw new Error('no api key given')",
"// postman doesn't have moment-timezone package included",
"// and we can't just use utcOffset(2), because of summer / winter time",
"// inspired by https://stackoverflow.com/a/56853085/3112139",
"function getUtcOffset(cb) {",
" let offset = pm.globals.get('utcOffset')",
" if (offset) return cb(offset)",
" pm.sendRequest(`https://worldtimeapi.org/api/timezone/${userZone}`, (err, res) => {",
" offset = res.json().utc_offset",
" pm.globals.set('utcOffset', offset)",
" return cb(offset)",
" })",
"}",
"",
"pm.variables.set('READUSER_TOKEN', base64encode(readApiKey))",
"pm.variables.set('WRITEUSER_TOKEN', base64encode(writeApiKey))",
"getUtcOffset((utcOffset) => {",
" const now = moment().utcOffset(utcOffset)",
" const startOfDay = now.clone().startOf('day')",
" const endOfDay = now.clone().endOf('day')",
" const endOfTomorrow = now.clone().add(1, 'd').endOf('day')",
"",
"function base64encode(str) {",
" return Buffer.from(str, 'utf-8').toString('base64')",
"}",
" // Auth stuff",
" const readApiKey = pm.variables.get('READUSER_API_KEY')",
" const writeApiKey = pm.variables.get('WRITEUSER_API_KEY')",
"",
"// Heartbeat stuff",
"pm.variables.set('tsNow', now.format('x') / 1000)",
"pm.variables.set('tsNowMinus1Min', now.add(-1, 'm').format('x') / 1000)",
"pm.variables.set('tsNowMinus2Min', now.add(-2, 'm').format('x') / 1000)",
"pm.variables.set('tsNowMinus3Min', now.add(-3, 'm').format('x') / 1000)",
"pm.variables.set('tsStartOfDay', startOfDay.format('x') / 1000)",
"pm.variables.set('tsEndOfDay', endOfDay.format('x') / 1000)",
"pm.variables.set('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)",
"pm.variables.set('tsStartOfDayIso', startOfDay.toISOString())",
"pm.variables.set('tsEndOfDayIso', endOfDay.toISOString())",
"pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
"pm.variables.set('tsStartOfDayDate', startOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfDayDate', endOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))",
"pm.variables.set('ts1', now.startOf('hour').format('x') / 1000)",
"pm.variables.set('ts2', now.startOf('hour').add(1, 'm').format('x') / 1000)",
"pm.variables.set('ts3', now.startOf('hour').add(2, 'm').format('x') / 1000)"
" console.log(readApiKey)",
"",
" if (!readApiKey || !writeApiKey) {",
" throw new Error('no api key given')",
" }",
"",
" pm.variables.set('READUSER_TOKEN', base64encode(readApiKey))",
" pm.variables.set('WRITEUSER_TOKEN', base64encode(writeApiKey))",
"",
" function base64encode(str) {",
" return Buffer.from(str, 'utf-8').toString('base64')",
" }",
"",
" // Heartbeat stuff",
" pm.variables.set('tsNow', now.clone().format('x') / 1000)",
" pm.variables.set('tsNowMinus1Min', now.clone().add(-1, 'm').format('x') / 1000)",
" pm.variables.set('tsNowMinus2Min', now.clone().add(-2, 'm').format('x') / 1000)",
" pm.variables.set('tsNowMinus3Min', now.clone().add(-3, 'm').format('x') / 1000)",
" pm.variables.set('tsStartOfDay', startOfDay.format('x') / 1000)",
" pm.variables.set('tsEndOfDay', endOfDay.format('x') / 1000)",
" pm.variables.set('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)",
" pm.variables.set('tsStartOfDayIso', startOfDay.toISOString())",
" pm.variables.set('tsEndOfDayIso', endOfDay.toISOString())",
" pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
" pm.variables.set('ts1', now.clone().startOf('hour').format('x') / 1000)",
" pm.variables.set('ts2', now.clone().startOf('hour').add(1, 'm').format('x') / 1000)",
" pm.variables.set('ts3', now.clone().startOf('hour').add(2, 'm').format('x') / 1000)",
"})"
]
}
},