Added OIDC workflow and Helm Chart
Checks / test (push) Failing after 4m13s

This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-07 14:22:37 +01:00
1 parent 984c7083a1
commit 7afdc4be97
29 files changed
+872 -33

No files matched your search

+5
View File
@@ -38,6 +38,11 @@ jobs:
- run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo test --locked
- run: cargo test --locked --tests -- --ignored --test-threads=1
- uses: azure/setup-helm@v4
- name: Helm chart
run: |
helm lint helm/meguri --strict -f helm/meguri/ci/values.yaml
helm template meguri helm/meguri -f helm/meguri/ci/values.yaml > /dev/null
- uses: actions/setup-node@v4
with:
node-version: 22
+1 -1
View File
@@ -1,6 +1,6 @@
# GKE and Cloud SQL
Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Templates are in `deploy/`. They have not been applied to a cloud project.
Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Plain templates are in `deploy/`; the Helm chart in `helm/meguri` (see its README) deploys the same layout and runs the migrate Job as a pre-install/pre-upgrade hook, so the steps below are what the chart automates. Neither has been applied to a cloud project.
1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine).
2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment.
+1 -1
View File
@@ -49,7 +49,7 @@ Workspace configuration edits take effect when saved. Historical queries use sav
Navigation and language coverage: tests/browser-navigation.cjs checks direct menu/workspace links, reload and browser history, saved language versus browser preference, Russian/French display, safe interpolation, catalog completeness and the prohibition on literal template copy. It runs in npm run test:browser.
Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner and 18 Japanese holidays/days off from https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html (2026 table, verified 2026-09-24). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation.
Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner, the 8 England and Wales bank holidays of 2026 from https://www.gov.uk/bank-holidays.json (verified 2026-10-07), and the 13 statutory Armenian public holidays/days off of 2026 (listed from the Labour Code, not re-verified against an official calendar). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation. Demo accounts also differ in scheduling country so that country holidays are visible: alice and dave Cyprus, bob and erin United Kingdom (`GB`), carol Armenia (`AM`).
Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice administers all four; Bob is an employee in Acme/Regional, Carol in Acme/Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview.
+2
View File
@@ -8,6 +8,8 @@ Meguri is a multi-tenant on-call configuration service. Workspace is the single
**Roles.** An *instance administrator* sees every enabled workspace, may act in it with every permission without being a member, and manages password users and disabled workspaces from the instance page; configuration, workspaces and group mappings are read-only there. A *workspace administrator* holds every permission in exactly that workspace and cannot see, create or affect any other. An *employee* holds the workspace's employee permission set: calendar, schedule editing and replacement requests by default (`view`, `edit_schedules`, `request_overrides`). Administrators change that set in workspace settings and it applies to every current and future employee immediately; `view` is mandatory and `manage_members`/`manage_integration` can never be granted to employees. Granting `force_overrides` lets every employee apply replacements without confirmation; leaving it out requires approval.
**Employees and workspace settings.** `edit_schedules` lets an employee plan: schedules, escalations, routing, absences, holidays and new email contacts. It never covers the workspace preferences (name, planning horizon), existing entries of the scheduling directory, the working interval, the drift policy, the provider integration or access; those are workspace administrator (and instance administrator) actions that employees may view but not change. The server rejects such edits with 403 and the UI hides the controls.
**Groups decide roles.** A person's groups are compared to the configured groups by exact string match (case, slashes and prefixes matter). Workspace administrator beats employee when both match; matching `instance_admin_groups` makes an instance administrator. Memberships are recomputed from the stored groups at every SSO sign-in, whenever an administrator edits a password user's groups, and for every user at each configuration sync, so removing a group or a workspace removes access. A sign-in whose groups map to nothing is refused with an explanation and a support reference; no account is created or kept enabled for it.
**Accounts.** There is no self-registration. Accounts come into being only through an SSO sign-in whose groups map to something, or when an instance administrator creates a password user (username, display name, password of 12–256 characters, optional email, groups chosen from the configured list). SSO accounts have no password and their groups belong to the identity provider (read-only in Meguri); password accounts have groups edited by an instance administrator, who cannot edit their own, and can have their password reset (which ends their sessions). An SSO identity is never linked to an existing password account automatically. Ordinary password sign-in works only when `auth.password_login` is true.
+5
View File
@@ -0,0 +1,5 @@
.git
.gitignore
ci/
*.swp
*.bak
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v2
name: meguri
description: On-call schedules, replacements and escalation policies as a source of truth
type: application
version: 0.1.0
appVersion: "0.1.0"
kubeVersion: ">=1.29.0-0"
+79
View File
@@ -0,0 +1,79 @@
# Meguri Helm chart
Deploys the web/worker Deployment, Service, optional Ingress and PodDisruptionBudget, and runs a
**pre-install/pre-upgrade migrate Job** that applies Cot migrations and the declarative instance
configuration *before* the Deployment is updated. The chart contains no secrets and no Helm-managed
database. Requires Kubernetes 1.29+ (native sidecars) and Helm 3.
## What you provide
1. An image: `ultradesu/meguri:latest` by default (build your own from the repository `Dockerfile` and set `image.repository` / `image.tag`; pin a tag for reproducible releases).
2. A Secret in the release namespace, named by `secretName`. All of its keys become environment
variables:
```sh
kubectl -n meguri create secret generic meguri-secrets \
--from-literal=DATABASE_URL='postgresql://USER:PERCENT_ENCODED_PASSWORD@HOST:5432/meguri' \
--from-literal=MEGURI_OIDC_CLIENT_SECRET='...'
```
Per-tenant provider credentials (`MEGURI_JSM_<TENANT>_EMAIL` / `_TOKEN`) go in the same Secret. `extraSecretNames` lists further Secrets injected the same way, for example one that holds only `MEGURI_OIDC_CLIENT_SECRET`.
3. Your values. The two required ones are `publicUrl` (the exact browser origin) and `secretName`;
everything about people and access lives under `instanceConfig`.
## Install and upgrade
```sh
helm upgrade --install meguri ./helm/meguri -n meguri --create-namespace -f my-values.yaml
kubectl -n meguri logs job/meguri-migrate-1 # what the sync did; revision number increments
```
`instanceConfig` is rendered verbatim into `/etc/meguri/config.yaml` (field reference:
`deploy/instance-config.example.yaml`, behavior: `docs/deployment.md`). The chart schema rejects
unknown fields and obviously invalid values at `helm lint`/`install`; the application validates it
again, and a rejected configuration fails the migrate Job, so the release stops before any pod
changes. A configuration change rolls the Deployment (checksum annotation) after the Job succeeded.
```yaml
publicUrl: https://meguri.example.internal
secretName: meguri-secrets
image: {repository: ultradesu/meguri, tag: "1.4.0"}
instanceConfig:
auth: {password_login: false}
oidc:
issuer: https://sso.example.internal/realms/corp
client_id: meguri
instance_admin_groups: [/meguri/admins]
workspaces:
- key: payments
name: Payments
admin_groups: [/payments/leads]
member_groups: [/payments/team]
- key: customer-support
name: Customer Support
admin_groups: [/support/leads]
member_groups: [/support/team, /support/contractors]
```
Removing a workspace from `workspaces` **disables** it at the next release (data is kept; an
instance administrator can delete it permanently). Giving a new team access is one more list entry.
## Break-glass administrator
Add `instanceConfig.auth.bootstrap_admin.usernames: [rescue]` and upgrade. The generated password is
printed in the migrate Job log (`kubectl logs job/meguri-migrate-N`) and rotates each UTC hour; anyone who can read
that log can sign in as an instance administrator, so remove the block as soon as SSO works again.
Finished Jobs are deleted after `migrate.ttlSecondsAfterFinished` (a day).
## Notable values
| Value | Purpose |
|---|---|
| `replicaCount`, `resources`, `podDisruptionBudget` | Sizing and availability (publication is serialized by database leases, so replicas are safe). |
| `cloudSqlProxy.*` | Cloud SQL Auth Proxy native sidecar for the app and the Job; point `DATABASE_URL` at `127.0.0.1:5432`. |
| `serviceAccount.create` / `.annotations` | Workload Identity account, created once at install (the pre-install hook needs it). Later annotation changes need `kubectl annotate`. |
| `ingress.*` | Host and TLS follow `publicUrl`. |
| `allowDisableAll` | Permit a release whose `workspaces` list is empty to disable every workspace. |
| `extraEnv`, `nodeSelector`, `tolerations`, `affinity`, `topologySpreadConstraints` | Usual scheduling and environment hooks. |
The demonstration mode is not available in the chart: it only runs on loopback.
+14
View File
@@ -0,0 +1,14 @@
# Values for `helm lint` and `helm template` in CI; not a deployment example.
publicUrl: https://meguri.example.internal
secretName: meguri-secrets
serviceAccount:
create: true
annotations:
iam.gke.io/gcp-service-account: meguri@project.iam.gserviceaccount.com
cloudSqlProxy:
enabled: true
instanceConnectionName: project:region:instance
ingress:
enabled: true
className: nginx
tlsSecretName: meguri-tls
+17
View File
@@ -0,0 +1,17 @@
Meguri {{ .Chart.AppVersion }} is released as {{ .Release.Name }}.
Open {{ .Values.publicUrl }} (register {{ .Values.publicUrl | trimSuffix "/" }}/api/oidc/callback as the redirect URI of the OIDC client).
Workspaces and group mappings come only from `instanceConfig`; the migrate Job applied them
before the Deployment was updated. See what it did:
kubectl -n {{ .Release.Namespace }} logs job/{{ include "meguri.fullname" . }}-migrate-{{ .Release.Revision }}
{{- if (.Values.instanceConfig.auth).bootstrap_admin }}
WARNING: bootstrap_admin is enabled. Its generated password is in that Job log, so anyone who can
read the log can sign in as an instance administrator. Remove the block when SSO works again.
{{- end }}
{{- if not .Values.instanceConfig.workspaces }}
No workspaces are configured.
{{- end }}
+84
View File
@@ -0,0 +1,84 @@
{{- define "meguri.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 40 | trimSuffix "-" -}}
{{- end -}}
{{- define "meguri.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 40 | trimSuffix "-" -}}
{{- else if contains .Chart.Name .Release.Name -}}
{{- .Release.Name | trunc 40 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 40 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "meguri.selectorLabels" -}}
app.kubernetes.io/name: {{ include "meguri.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "meguri.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "meguri.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "meguri.image" -}}
{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
{{- end -}}
{{- define "meguri.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "meguri.fullname" .) .Values.serviceAccount.name -}}
{{- else -}}
{{- default "default" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}
{{/* The configuration file exactly as the application reads it. */}}
{{- define "meguri.instanceConfig" -}}
{{- toYaml .Values.instanceConfig -}}
{{- end -}}
{{/* Fails the render early with a readable message. Outputs nothing. */}}
{{- define "meguri.requirements" -}}
{{- $_ := required "publicUrl is required: the exact browser origin, for example https://meguri.example.internal" .Values.publicUrl -}}
{{- $_ := required "secretName is required: a Secret with DATABASE_URL (and MEGURI_OIDC_CLIENT_SECRET when oidc is configured)" .Values.secretName -}}
{{- end -}}
{{- define "meguri.cloudSqlProxy" -}}
- name: cloud-sql-proxy
restartPolicy: Always
image: {{ .Values.cloudSqlProxy.image | quote }}
args:
{{- if .Values.cloudSqlProxy.privateIp }}
- --private-ip
{{- end }}
- --structured-logs
- --port={{ .Values.cloudSqlProxy.port }}
- --health-check
- --http-address=0.0.0.0
- --http-port=9090
- {{ required "cloudSqlProxy.instanceConnectionName is required when the proxy is enabled" .Values.cloudSqlProxy.instanceConnectionName | quote }}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
{{- toYaml .Values.cloudSqlProxy.resources | nindent 4 }}
startupProbe:
httpGet: {path: /startup, port: 9090}
periodSeconds: 2
failureThreshold: 30
{{- end -}}
{{/* Environment shared by the application and the migrate Job. */}}
{{- define "meguri.env" -}}
- {name: MEGURI_BIND, value: "0.0.0.0:3000"}
- {name: MEGURI_PUBLIC_URL, value: {{ .Values.publicUrl | trimSuffix "/" | quote }}}
- {name: MEGURI_CONFIG_FILE, value: /etc/meguri/config.yaml}
- {name: RUST_LOG, value: {{ .Values.logLevel | quote }}}
{{- with .Values.extraEnv }}
{{ toYaml . }}
{{- end }}
{{- end -}}
+25
View File
@@ -0,0 +1,25 @@
{{- include "meguri.requirements" . -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "meguri.fullname" . }}-instance-config
labels:
{{- include "meguri.labels" . | nindent 4 }}
data:
config.yaml: |
{{- include "meguri.instanceConfig" . | nindent 4 }}
---
{{- /* The migrate Job runs before ordinary resources are updated, so it reads its own copy. */}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "meguri.fullname" . }}-instance-config-migrate
labels:
{{- include "meguri.labels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
data:
config.yaml: |
{{- include "meguri.instanceConfig" . | nindent 4 }}
+89
View File
@@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "meguri.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: app
template:
metadata:
labels:
{{- include "meguri.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: app
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
annotations:
checksum/instance-config: {{ include "meguri.instanceConfig" . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "meguri.serviceAccountName" . }}
terminationGracePeriodSeconds: 60
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
{{- if .Values.cloudSqlProxy.enabled }}
initContainers:
{{- include "meguri.cloudSqlProxy" . | nindent 8 }}
{{- end }}
volumes:
- name: instance-config
configMap:
name: {{ include "meguri.fullname" . }}-instance-config
containers:
- name: meguri
image: {{ include "meguri.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports: [{name: http, containerPort: 3000}]
envFrom:
- secretRef: {name: {{ required "secretName is required" .Values.secretName | quote }}}
{{- range .Values.extraSecretNames }}
- secretRef: {name: {{ . | quote }}}
{{- end }}
env:
{{- include "meguri.env" . | nindent 12 }}
- {name: MEGURI_ROLE, value: {{ .Values.role | quote }}}
- {name: MEGURI_SKIP_MIGRATIONS, value: "true"}
volumeMounts:
- {name: instance-config, mountPath: /etc/meguri, readOnly: true}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
{{- toYaml .Values.resources | nindent 12 }}
startupProbe:
httpGet: {path: /health/ready, port: http}
periodSeconds: 2
failureThreshold: 60
readinessProbe:
httpGet: {path: /health/ready, port: http}
livenessProbe:
httpGet: {path: /health/live, port: http}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
+32
View File
@@ -0,0 +1,32 @@
{{- if .Values.ingress.enabled }}
{{- $url := urlParse (required "publicUrl is required" .Values.publicUrl) }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tlsSecretName }}
tls:
- hosts: [{{ $url.hostname | quote }}]
secretName: {{ .Values.ingress.tlsSecretName }}
{{- end }}
rules:
- host: {{ $url.hostname | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "meguri.fullname" . }}
port: {name: http}
{{- end }}
+62
View File
@@ -0,0 +1,62 @@
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "meguri.fullname" . }}-migrate-{{ .Release.Revision }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
app.kubernetes.io/component: migrate
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation
spec:
backoffLimit: 0
activeDeadlineSeconds: {{ .Values.migrate.activeDeadlineSeconds }}
ttlSecondsAfterFinished: {{ .Values.migrate.ttlSecondsAfterFinished }}
template:
metadata:
labels:
{{- include "meguri.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
serviceAccountName: {{ include "meguri.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
{{- if .Values.cloudSqlProxy.enabled }}
initContainers:
{{- include "meguri.cloudSqlProxy" . | nindent 8 }}
{{- end }}
volumes:
- name: instance-config
configMap:
name: {{ include "meguri.fullname" . }}-instance-config-migrate
containers:
- name: migrate
image: {{ include "meguri.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- secretRef: {name: {{ required "secretName is required" .Values.secretName | quote }}}
{{- range .Values.extraSecretNames }}
- secretRef: {name: {{ . | quote }}}
{{- end }}
env:
{{- include "meguri.env" . | nindent 12 }}
- {name: MEGURI_ROLE, value: migrate}
- {name: MEGURI_SKIP_MIGRATIONS, value: "false"}
{{- if .Values.allowDisableAll }}
- {name: MEGURI_ALLOW_DISABLE_ALL, value: "true"}
{{- end }}
volumeMounts:
- {name: instance-config, mountPath: /etc/meguri, readOnly: true}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
{{- toYaml .Values.migrate.resources | nindent 12 }}
+14
View File
@@ -0,0 +1,14 @@
{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.replicaCount) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "meguri.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: app
{{- end }}
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "meguri.fullname" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "meguri.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: app
ports:
- {name: http, port: {{ .Values.service.port }}, targetPort: http}
+16
View File
@@ -0,0 +1,16 @@
{{- if .Values.serviceAccount.create }}
{{- /* A hook, because the pre-install migrate Job needs it before ordinary resources exist. */}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "meguri.serviceAccountName" . }}
labels:
{{- include "meguri.labels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-install
helm.sh/hook-weight: "-20"
helm.sh/hook-delete-policy: before-hook-creation
{{- with .Values.serviceAccount.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["publicUrl", "secretName", "instanceConfig"],
"properties": {
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": {"type": "string", "minLength": 1},
"tag": {"type": "string"},
"pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]}
}
},
"publicUrl": {"type": "string", "pattern": "^https?://[^/\\s]+/?$"},
"secretName": {"type": "string", "minLength": 1},
"extraSecretNames": {"type": "array", "items": {"type": "string", "minLength": 1}},
"replicaCount": {"type": "integer", "minimum": 1},
"role": {"enum": ["all", "web", "worker"]},
"logLevel": {"type": "string"},
"allowDisableAll": {"type": "boolean"},
"cloudSqlProxy": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"instanceConnectionName": {"type": "string"},
"port": {"type": "integer", "minimum": 1, "maximum": 65535}
}
},
"instanceConfig": {
"type": "object",
"additionalProperties": false,
"properties": {
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"password_login": {"type": "boolean"},
"bootstrap_admin": {
"type": "object",
"additionalProperties": false,
"required": ["usernames"],
"properties": {
"usernames": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[a-z0-9._-]{3,64}$"}
}
}
}
}
},
"oidc": {
"type": "object",
"additionalProperties": false,
"required": ["issuer", "client_id"],
"properties": {
"issuer": {"type": "string", "pattern": "^https://[^\\s]+$"},
"client_id": {"type": "string", "minLength": 1},
"scopes": {"type": "array", "items": {"type": "string"}, "contains": {"const": "openid"}},
"groups_claim": {"type": "string", "minLength": 1},
"groups_sources": {
"type": "array",
"minItems": 1,
"items": {"enum": ["id_token", "userinfo"]}
}
}
},
"instance_admin_groups": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"workspaces": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "name", "admin_groups"],
"properties": {
"key": {"type": "string", "pattern": "^[a-z0-9]([a-z0-9-]{1,62})[a-z0-9]$"},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"admin_groups": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"member_groups": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}
}
}
}
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
# Meguri Helm chart. Secrets are never part of the chart: create the Secret named by
# `secretName` first (see README.md). Everything under `instanceConfig` is rendered verbatim into
# the instance configuration file; the chart schema and the application validate it strictly.
image:
repository: ultradesu/meguri
# Pin an immutable tag or digest for reproducible releases; `latest` is re-pulled on every restart.
tag: latest
pullPolicy: Always
imagePullSecrets: []
# Exact browser origin (scheme, host, port) of the service. Cookies, CSRF checks and the OIDC
# redirect URI ({publicUrl}/api/oidc/callback) depend on it. Required.
publicUrl: ""
# Name of a Secret holding at least DATABASE_URL (postgresql://USER:PERCENT_ENCODED_PASSWORD@HOST:5432/DB)
# and, when instanceConfig.oidc is set, MEGURI_OIDC_CLIENT_SECRET. All of its keys become environment
# variables, so per-tenant provider credentials (MEGURI_JSM_<TENANT>_EMAIL / _TOKEN) belong there too.
# Required.
secretName: ""
# Further Secrets whose keys are also injected as environment variables (for example a separate
# MEGURI_OIDC_CLIENT_SECRET kept apart from the database credentials).
extraSecretNames: []
replicaCount: 2
# all = web and publication worker in each replica; web = HTTP only (add a separate release
# with role worker if you split them).
role: all
logLevel: meguri=info,cot=warn
# Disables every existing workspace when instanceConfig.workspaces is empty. Leave false.
allowDisableAll: false
# The instance configuration. Written to /etc/meguri/config.yaml. The migrate hook applies it at
# every install and upgrade: workspaces are created, adopted, renamed, enabled or DISABLED (never
# deleted) so that the database matches this list. Field reference: deploy/instance-config.example.yaml.
instanceConfig:
auth:
# Lets ordinary password accounts (created by an instance administrator) sign in.
password_login: false
# Break-glass instance administrators. Remove in production. While present, their generated
# password is written to the migrate Job log.
# bootstrap_admin:
# usernames: [rescue]
oidc:
issuer: https://sso.example.internal/realms/corp
client_id: meguri
scopes: [openid, profile, email]
groups_claim: groups
groups_sources: [id_token, userinfo]
instance_admin_groups:
- /meguri/admins
workspaces:
- key: payments
name: Payments
admin_groups:
- /payments/leads
member_groups:
- /payments/team
migrate:
# Runs the Cot migrations and the configuration sync before the Deployment is touched.
activeDeadlineSeconds: 300
# Finished Jobs (and the log that may contain bootstrap passwords) are removed after this long.
ttlSecondsAfterFinished: 86400
resources:
requests: {cpu: 250m, memory: 128Mi}
limits: {memory: 256Mi}
resources:
requests: {cpu: 250m, memory: 256Mi}
limits: {memory: 512Mi}
# Optional Cloud SQL Auth Proxy as a native sidecar (Kubernetes 1.29+), for the application and
# the migrate Job. Point DATABASE_URL at 127.0.0.1:<port>.
cloudSqlProxy:
enabled: false
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1
instanceConnectionName: "" # PROJECT:REGION:INSTANCE
privateIp: true
port: 5432
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {memory: 256Mi}
serviceAccount:
# Created once, at install, because the migrate hook runs before ordinary resources exist.
# Later annotation changes need `kubectl annotate`. Leave create false to use an existing
# account named `name` (or the namespace default).
create: false
name: ""
annotations: {}
# iam.gke.io/gcp-service-account: meguri@PROJECT.iam.gserviceaccount.com
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# Host and TLS follow publicUrl. Set tlsSecretName for an https publicUrl terminated at the ingress.
tlsSecretName: ""
podDisruptionBudget:
enabled: true
minAvailable: 1
podAnnotations: {}
podLabels: {}
extraEnv: []
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
+9
View File
@@ -508,6 +508,15 @@ workspaces:
assert!(config.bootstrap_usernames().is_empty());
}
#[test]
fn the_helm_chart_default_instance_configuration_is_valid() {
let values: serde_yaml_ng::Value =
serde_yaml_ng::from_str(include_str!("../helm/meguri/values.yaml")).unwrap();
let rendered = serde_yaml_ng::to_string(&values["instanceConfig"]).unwrap();
let config = Config::parse(&rendered).unwrap();
assert!(config.oidc.is_some() && !config.workspaces.is_empty());
}
#[test]
fn known_groups_are_unique_and_sorted_and_the_form_flags_follow_the_configuration() {
let c = config();
+16
View File
@@ -127,6 +127,9 @@ pub struct NewUser {
pub email: Option<String>,
#[serde(default)]
pub groups: Vec<String>,
/// Scheduling country (ISO 3166-1 alpha-2); defaults to CY like every new account.
#[serde(default)]
pub country: Option<String>,
}
/// Creates a password account. `actor` is None only for trusted startup seeding; the request
@@ -158,6 +161,19 @@ pub async fn create_password_user(
let hash = service::hash_password(input.password).await?;
let mut row = new_user_row(username.clone(), input.display_name, Some(hash));
row.email = email;
if let Some(country) = input
.country
.as_deref()
.map(str::trim)
.filter(|c| !c.is_empty())
{
let country = country.to_ascii_uppercase();
ensure(
crate::domain::valid_country(&country),
crate::i18n::text("err_choose_a_valid_country_code_e79c72"),
)?;
row.country = country;
}
match row.clone().insert(&db.db).await {
Ok(_) => {}
Err(cot::db::DatabaseError::UniqueViolation) => {
+21 -18
View File
@@ -14,21 +14,24 @@ CY,2026-10-28,Ohi Day
CY,2026-12-24,Christmas Eve
CY,2026-12-25,Christmas Day
CY,2026-12-26,Boxing Day
JP,2026-01-01,New Year's Day
JP,2026-01-12,Coming of Age Day
JP,2026-02-11,National Foundation Day
JP,2026-02-23,Emperor's Birthday
JP,2026-03-20,Vernal Equinox Day
JP,2026-04-29,Showa Day
JP,2026-05-03,Constitution Memorial Day
JP,2026-05-04,Greenery Day
JP,2026-05-05,Children's Day
JP,2026-05-06,Substitute Holiday
JP,2026-07-20,Marine Day
JP,2026-08-11,Mountain Day
JP,2026-09-21,Respect for the Aged Day
JP,2026-09-22,Citizens' Holiday
JP,2026-09-23,Autumnal Equinox Day
JP,2026-10-12,Sports Day
JP,2026-11-03,Culture Day
JP,2026-11-23,Labour Thanksgiving Day
GB,2026-01-01,New Year's Day
GB,2026-04-03,Good Friday
GB,2026-04-06,Easter Monday
GB,2026-05-04,Early May bank holiday
GB,2026-05-25,Spring bank holiday
GB,2026-08-31,Summer bank holiday
GB,2026-12-25,Christmas Day
GB,2026-12-28,Boxing Day (substitute day)
AM,2026-01-01,New Year's Day
AM,2026-01-02,New Year holiday
AM,2026-01-06,Christmas Day
AM,2026-01-28,Army Day
AM,2026-03-08,Women's Day
AM,2026-04-07,Motherhood and Beauty Day
AM,2026-04-24,Genocide Remembrance Day
AM,2026-05-01,Labour Day
AM,2026-05-09,Victory and Peace Day
AM,2026-05-28,Republic Day
AM,2026-07-05,Constitution Day
AM,2026-09-21,Independence Day
AM,2026-12-31,New Year's Eve
1 country date name
14 CY 2026-12-24 Christmas Eve
15 CY 2026-12-25 Christmas Day
16 CY 2026-12-26 Boxing Day
17 JP GB 2026-01-01 New Year's Day
18 JP GB 2026-01-12 2026-04-03 Coming of Age Day Good Friday
19 JP GB 2026-02-11 2026-04-06 National Foundation Day Easter Monday
20 JP GB 2026-02-23 2026-05-04 Emperor's Birthday Early May bank holiday
21 JP GB 2026-03-20 2026-05-25 Vernal Equinox Day Spring bank holiday
22 JP GB 2026-04-29 2026-08-31 Showa Day Summer bank holiday
23 JP GB 2026-05-03 2026-12-25 Constitution Memorial Day Christmas Day
24 JP GB 2026-05-04 2026-12-28 Greenery Day Boxing Day (substitute day)
25 JP AM 2026-05-05 2026-01-01 Children's Day New Year's Day
26 JP AM 2026-05-06 2026-01-02 Substitute Holiday New Year holiday
27 JP AM 2026-07-20 2026-01-06 Marine Day Christmas Day
28 JP AM 2026-08-11 2026-01-28 Mountain Day Army Day
29 JP AM 2026-09-21 2026-03-08 Respect for the Aged Day Women's Day
30 JP AM 2026-09-22 2026-04-07 Citizens' Holiday Motherhood and Beauty Day
31 JP AM 2026-09-23 2026-04-24 Autumnal Equinox Day Genocide Remembrance Day
32 JP AM 2026-10-12 2026-05-01 Sports Day Labour Day
33 JP AM 2026-11-03 2026-05-09 Culture Day Victory and Peace Day
34 JP AM 2026-11-23 2026-05-28 Labour Thanksgiving Day Republic Day
35 AM 2026-07-05 Constitution Day
36 AM 2026-09-21 Independence Day
37 AM 2026-12-31 New Year's Eve
+16 -9
View File
@@ -228,12 +228,14 @@ pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variab
}
/// Demonstration accounts: groups assigned exactly as an instance administrator would.
const ACCOUNTS: [(&str, &str, &str, &[&str]); 6] = [
("admin", "Instance Admin", "instance_admin", &[]),
/// Scheduling countries differ on purpose, so holiday handling is visible in the examples.
const ACCOUNTS: [(&str, &str, &str, &str, &[&str]); 6] = [
("admin", "Instance Admin", "instance_admin", "CY", &[]),
(
"alice",
"Alice Morgan",
"workspace_admin",
"CY",
&[
"acme-leads",
"support-leads",
@@ -245,16 +247,18 @@ const ACCOUNTS: [(&str, &str, &str, &[&str]); 6] = [
"bob",
"Bob Chen",
"employee",
"GB",
&["acme-staff", "regional-staff"],
),
(
"carol",
"Carol Davis",
"employee",
"AM",
&["acme-staff", "support-staff", "commerce-staff"],
),
("dave", "Dave Wilson", "employee", &["acme-staff"]),
("erin", "Erin Patel", "employee", &["acme-staff"]),
("dave", "Dave Wilson", "employee", "CY", &["acme-staff"]),
("erin", "Erin Patel", "employee", "GB", &["acme-staff"]),
];
/// `MEGURI_DEMO_SANDBOXES=n` (0-20) adds empty "Sandbox 1..n" workspaces, each with its own
@@ -285,10 +289,12 @@ pub fn config() -> Result<crate::access::Config> {
pub fn accounts() -> Vec<crate::demo_runtime::DemoAccount> {
ACCOUNTS
.iter()
.map(|(username, _, role, _)| crate::demo_runtime::DemoAccount {
username: (*username).into(),
role: (*role).into(),
})
.map(
|(username, _, role, _, _)| crate::demo_runtime::DemoAccount {
username: (*username).into(),
role: (*role).into(),
},
)
.collect()
}
@@ -315,7 +321,7 @@ pub async fn seed(db: &Store) -> Result<()> {
},
)
.await?;
for (username, display, _, groups) in ACCOUNTS {
for (username, display, _, country, groups) in ACCOUNTS {
let name = username.to_owned();
if query!(UserRow,$username==name).exists(&db.db).await? {
continue;
@@ -330,6 +336,7 @@ pub async fn seed(db: &Store) -> Result<()> {
password: notice.password.clone(),
email: None,
groups: groups.iter().map(|g| (*g).to_owned()).collect(),
country: Some(country.into()),
},
)
.await?;
+38
View File
@@ -104,6 +104,41 @@ pub async fn login(
Ok((user, token, csrf))
}
/// Employees with schedule-editing rights plan schedules, but the workspace preferences and the
/// scheduling directory belong to workspace administrators: name, planning horizon and every
/// existing person stay exactly as they were. New email contacts may be added while planning.
fn ensure_employee_edit(before: &WorkspaceDocument, after: &WorkspaceDocument) -> Result<()> {
let normal = |p: &Person| {
(
p.display_name.clone(),
p.email
.as_deref()
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty()),
p.country
.as_deref()
.map(|c| c.trim().to_ascii_uppercase())
.filter(|c| !c.is_empty())
.unwrap_or_else(|| "CY".into()),
p.user_id,
)
};
let unchanged = after.name == before.name
&& after.horizon_days == before.horizon_days
&& before.people.iter().all(|old| {
after
.people
.iter()
.find(|p| p.id == old.id)
.is_some_and(|new| normal(new) == normal(old))
});
if unchanged {
Ok(())
} else {
Err(Error::Forbidden)
}
}
pub async fn update_document(
db: &Store,
actor: Uuid,
@@ -115,6 +150,9 @@ pub async fn update_document(
.prepare(workspace, revision, actor, Permission::EditSchedules)
.await?;
document.requests = record.document.requests.clone();
if db.owner(workspace, actor).await.is_err() {
ensure_employee_edit(&record.document, &document)?;
}
for request in &mut document.requests {
if request.status == RequestStatus::Pending
&& request
+3 -3
View File
@@ -393,7 +393,7 @@
</p>
<p x-text="t('planning_horizon_b249cc')+doc.horizon_days+t('days_e72d54')">
</p>
<button x-show="can('edit_schedules')" @click="open('preferences')"><span x-text="t('edit_preferences_4cefcb')"></span></button>
<button x-show="detail?.is_owner" @click="open('preferences')"><span x-text="t('edit_preferences_4cefcb')"></span></button>
</article>
<article class="panel rule-card">
<span class="eyebrow"><span x-text="t('connected_provider_f22440')"></span></span>
@@ -460,7 +460,7 @@
<small x-text="(p.email || (p.username?'@'+p.username:t('no_email_configured_b309ce')))+' · '+countryName(p.country)+' · '+(p.user_id?t('workspace_user_95981a'):t('email_contact_e9e0b3'))">
</small>
</div>
<button x-show="can('edit_schedules')" @click="open('person',p)"><span x-text="t('edit_person_d98516')"></span></button>
<button x-show="detail?.is_owner" @click="open('person',p)"><span x-text="t('edit_person_d98516')"></span></button>
</div>
</template>
</div>
@@ -649,7 +649,7 @@
<small x-text="countryName(personById(id)?.country)">
</small>
</div>
<button type="button" @click="editPerson(id)" :aria-label="t('edit_participant_details_73e491')"><span x-text="t('details_dc3dec')"></span></button>
<button type="button" x-show="detail?.is_owner" @click="editPerson(id)" :aria-label="t('edit_participant_details_73e491')"><span x-text="t('details_dc3dec')"></span></button>
<button type="button" @click="moveParticipant(index,-1)" :aria-label="t('move_earlier_07dd6f')"><span x-text="t('symbol_9555ff')"></span></button>
<button type="button" @click="moveParticipant(index,1)" :aria-label="t('move_later_bb4c84')"><span x-text="t('symbol_fc4866')"></span></button>
<button type="button" @click="form.participants.splice(index,1)" :aria-label="t('remove_participant_131344')"><span x-text="t('symbol_67fba2')"></span></button>
+92
View File
@@ -692,6 +692,7 @@ async fn only_instance_administrators_manage_password_users_and_delete_disabled_
password: "long-enough-password".into(),
email: None,
groups: vec![],
country: None,
};
// Nothing is available to ordinary users, not even read access to the overview.
@@ -1079,6 +1080,7 @@ async fn taken_username(db: &Store, config: &Config) {
password: "long-enough-password".into(),
email: None,
groups: vec![],
country: None,
},
)
.await
@@ -1265,3 +1267,93 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
);
iso.drop().await;
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn employees_plan_schedules_but_only_administrators_change_preferences_and_the_directory() {
let f = support::Fixture::new().await;
let (lead, bob) = (f.people[0].id, f.people[1].id);
let team = f.team.id;
// Employees may edit schedules here, as with the default employee permission set.
let record = service::set_member_permissions(
&f.db,
lead,
team,
f.team.revision,
Permission::employee_default(),
)
.await
.unwrap();
let contact = Uuid::new_v4();
let mut doc = record.document.clone();
doc.people.push(Person {
id: contact,
user_id: None,
display_name: "Dana Contact".into(),
email: Some("dana@corp.test".into()),
country: Some("AM".into()),
aliases: vec![],
});
let record = service::update_document(&f.db, lead, team, record.revision, doc)
.await
.unwrap();
let try_edit = |change: &dyn Fn(&mut WorkspaceDocument)| {
let mut doc = record.document.clone();
change(&mut doc);
service::update_document(&f.db, bob, team, record.revision, doc)
};
// Workspace preferences and existing directory entries are administrator-only.
for (label, change) in [
(
"name",
&(|d: &mut WorkspaceDocument| d.name = "Renamed".into())
as &dyn Fn(&mut WorkspaceDocument),
),
("horizon", &|d| d.horizon_days = 30),
("person name", &|d| {
d.people[0].display_name = "Someone else".into()
}),
("person email", &|d| {
d.people[0].email = Some("other@corp.test".into())
}),
("person country", &|d| {
d.people[0].country = Some("CY".into())
}),
("person removal", &|d| d.people.clear()),
] {
assert!(
matches!(try_edit(change).await, Err(Error::Forbidden)),
"an employee must not change the {label}"
);
}
// Planning is allowed, including new email contacts and unchanged people sent back as is.
let planned = try_edit(&|d| {
d.schedules[0].name = "Weekly rotation".into();
d.people.push(Person {
id: Uuid::new_v4(),
user_id: None,
display_name: "New contact".into(),
email: Some("new@corp.test".into()),
country: None,
aliases: vec![],
});
d.people[0].email = Some(" DANA@corp.test ".into());
})
.await
.unwrap();
assert_eq!(planned.document.schedules[0].name, "Weekly rotation");
assert_eq!(planned.document.name, record.document.name);
assert_eq!(planned.document.people.len(), 2);
// The workspace administrator still changes both.
let mut doc = planned.document.clone();
doc.name = "Administrators rename".into();
doc.horizon_days = 45;
doc.people[0].display_name = "Dana Renamed".into();
let changed = service::update_document(&f.db, lead, team, planned.revision, doc)
.await
.unwrap();
assert_eq!(changed.document.name, "Administrators rename");
assert_eq!(changed.document.horizon_days, 45);
assert_eq!(changed.document.people[0].display_name, "Dana Renamed");
}
+5
View File
@@ -142,6 +142,11 @@ fs.mkdirSync('test-results', { recursive: true });
const detail = await carol.get('/api/workspaces/' + commerceId);
const denied = await carol.call('PUT', `/api/workspaces/${commerceId}/member-permissions`, { revision: detail.workspace.revision, permissions: ['view', 'force_overrides', 'edit_schedules'] });
assert.equal(denied.status, 403);
// Employees may plan schedules, but workspace preferences and the scheduling directory are read-only.
assert.equal(await worker.getByRole('button', { name: 'Edit preferences', exact: true }).count(), 0, 'Employees cannot edit workspace preferences');
assert.equal(await worker.getByRole('button', { name: 'Edit person', exact: true }).count(), 0, 'Employees cannot edit the scheduling directory');
const rename = await carol.call('PUT', `/api/workspaces/${commerceId}/config`, { revision: detail.workspace.revision, document: { ...detail.workspace.document, name: 'Renamed by an employee' } });
assert.equal(rename.status, 403);
await worker.screenshot({ path: 'test-results/access-employee.png', fullPage: true });
// The change applies at once to everyone: turning it off removes the permission without a new login.
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'36');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});for(const [country,count] of [['Cyprus','15'],['United Kingdom','8'],['Armenia','13']]){const group=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:country})});assert.equal(await group.getAttribute('open'),null,country);assert.equal((await group.locator('.badge').innerText()).trim(),count,country);}const armenia=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Armenia'})});await armenia.locator('summary').click();await armenia.getByText('Independence Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deeLine truncated
+1
View File
@@ -61,6 +61,7 @@ pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String])
password: "test-only-password".into(),
email: None,
groups: groups.to_vec(),
country: None,
},
)
.await